← All components

Checklist

What an answer needs before it starts, with a meter, rows that blur in, and a banner at the end.

What this answer needs0% ready
  • 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

terminal1 line
npm i mihirmodi-ui motion

The 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

Checklist — usage14 lines
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

PropNotes
titleReactNodeThe heading, left of the percentage.
itemsChecklistItem[]`{ id, label, ready, note?, action? }`. `action` shows on the right of a missing item.
readyTextReactNodeThe banner that lifts in once every item is ready.
theatrebooleanStagger 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.

packages/ui/src/components/Checklist.tsx127 lines
"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>
  );
}