Checklist
What an answer needs before it starts, with a meter, rows that blur in, and a banner at the end.
Churn by plan, last 12 months — missing
Waiting on the billing export
Contract end dates — missing
Waiting on the CRM
Renewal pricing — missing
Upload the current price sheet
The idea
Three signals, each saying something the others cannot. The meter says how far along in one glance. Each row's mark — a filled disc or an empty ring — says which piece is missing, by shape rather than colour, so it survives greyscale. And the banner that lifts in at the end says the wait is over, which neither a full bar nor the last tick quite manage alone.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Checklist } from "mihirmodi-ui";
<Checklist
title="What this answer needs"
items={[
{ id: "churn", label: "Churn by plan", ready: true, note: "From the billing export" },
{ id: "price", label: "Renewal pricing", ready: false, note: "Upload the price sheet",
action: <UploadButton /> },
]}
readyText="Everything is in hand. Reasoning begins."
// A checklist that was complete before the reader arrived should not
// replay its entrance.
theatre={!alreadyComplete}
/>Props
| Prop | Type | Notes |
|---|---|---|
| titleReactNode | ReactNode | The heading, left of the percentage. |
| itemsChecklistItem[] | ChecklistItem[] | `{ id, label, ready, note?, action? }`. `action` shows on the right of a missing item. |
| readyTextReactNode | ReactNode= "Everything is in hand." | The banner that lifts in once every item is ready. |
| theatreboolean | boolean= true | Stagger the rows in. Turn it off for a checklist that was complete before the reader arrived. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import type { ReactNode } from "react";
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { CheckIcon } from "../lib/glyph.js";
import { EASE_OUT_EXPO, FLAT, LIFT } from "../lib/motion.js";
export type ChecklistItem = {
id: string;
label: ReactNode;
ready: boolean;
/** Under the label: where a ready item came from, or what a missing one needs. */
note?: ReactNode;
/** On the right of a missing item — an upload button, a link. */
action?: ReactNode;
};
export type ChecklistProps = {
title: ReactNode;
items: ChecklistItem[];
/** The line that lands once every item is ready. */
readyText?: ReactNode;
/**
* Play the staggered blur-in as the rows arrive. On by default. Turn it off
* when rendering a checklist that was already complete before the reader got
* here — the performance is for watching it fill, not for reading it back.
*/
theatre?: boolean;
className?: string;
};
/**
* What an answer needs before it can start, and how much of it is in hand.
*
* Three signals, each carrying something the others cannot. The meter says how
* far along it is in one glance. Each row's mark says which piece is missing, in
* a filled disc for ready and an empty ring for not — shape, not colour, so it
* survives greyscale. And the banner that lifts in at the end says the wait is
* over, which neither a full meter nor the last tick quite do on their own.
*/
export function Checklist({ title, items, readyText = "Everything is in hand.", theatre = true, className = "" }: ChecklistProps) {
const reduce = useReducedMotion();
const ready = items.filter((i) => i.ready).length;
const pct = items.length ? Math.round((ready / items.length) * 100) : 0;
const complete = items.length > 0 && ready === items.length;
const play = theatre && !reduce;
return (
<LazyMotion features={domAnimation} strict>
<div className={`rounded-2xl border-[0.5px] border-gray-alpha-400 bg-background-100 p-4 ${className}`}>
<div className="flex items-center justify-between gap-4">
<span className="text-[14px] font-medium text-gray-1000">{title}</span>
<span className="text-[13px] tabular-nums text-muted" aria-live="polite">
{pct}% ready
</span>
</div>
<div
className="mt-3 h-1.5 overflow-hidden rounded-full bg-gray-200"
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Readiness"
>
<m.span
className="block h-full rounded-full bg-ink"
initial={false}
animate={{ width: `${pct}%` }}
transition={reduce ? FLAT : { type: "spring", bounce: 0.15, duration: 0.5 }}
/>
</div>
<m.ul
className="mt-4 flex flex-col gap-2.5"
initial="shut"
animate="open"
variants={{ open: { transition: { staggerChildren: play ? 0.09 : 0, delayChildren: play ? 0.06 : 0 } } }}
>
{items.map((it) => (
<m.li
key={it.id}
// The shut state is a constant so server and client agree; only
// the transition reads `play`.
variants={{
shut: { opacity: 0, y: 6, filter: "blur(4px)" },
open: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: play ? 0.34 : 0, ease: EASE_OUT_EXPO } },
}}
className="flex items-start gap-3"
>
<span
aria-hidden
className={`mt-0.5 flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full transition-colors duration-200 ${
it.ready ? "bg-ink text-background-100" : "border border-gray-500"
}`}
>
{it.ready && <CheckIcon className="h-[11px] w-[11px]" />}
</span>
<div className="min-w-0 flex-1">
<p className={`text-[14px] leading-[1.45] ${it.ready ? "text-gray-1000" : "text-gray-900"}`}>
{it.label}
<span className="sr-only">{it.ready ? " — ready" : " — missing"}</span>
</p>
{it.note && <p className="mt-0.5 text-[12px] text-faint">{it.note}</p>}
</div>
{!it.ready && it.action && <div className="shrink-0">{it.action}</div>}
</m.li>
))}
</m.ul>
<AnimatePresence>
{complete && readyText && (
<m.p
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, transition: FLAT }}
transition={reduce ? FLAT : { ...LIFT, opacity: { duration: 0.16, ease: "easeOut" } }}
className="mt-4 flex items-center gap-2 rounded-[10px] bg-gray-100 px-3 py-2.5 text-[13px] text-gray-1000"
>
<CheckIcon className="h-3.5 w-3.5" />
{readyText}
</m.p>
)}
</AnimatePresence>
</div>
</LazyMotion>
);
}