Setup Checklist Complete
A finished task fills its box, draws its mark, sweeps a rule through the label, and moves the counter up by one.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Setup Checklist Complete
*
* A setup task finishes: the box fills, its mark draws itself, a rule
* sweeps through the label, the row settles back, and the counter above
* moves up by one.
*
* Self-contained: depends only on `react` and `motion`. Neutrals are
* mixed from the inherited text color, so it reads on light and dark
* pages alike. Works with zero props; tune via `variant`, `items`,
* `accent`. Rows can also be pressed directly.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ChecklistTask = {
label: string;
/** Completed before the sequence starts. */
done?: boolean;
};
export type ChecklistItemCompleteProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
title?: string;
/** Your own tasks. The embedded sample is used when omitted. */
items?: ChecklistTask[];
/** Delay before the pending task completes on its own, in ms. */
autoCompleteMs?: number;
/** Fill, rule and counter color. */
accent?: string;
/** Fires with the index of the task that just completed. */
onComplete?: (index: number) => void;
};
type VariantConfig = {
/** Seconds the mark takes to draw. */
drawSeconds: number;
/** Seconds the rule takes to sweep the label. */
ruleSeconds: number;
/** Seconds between the mark landing and the rule starting. */
gap: number;
boxSpring: { type: "spring"; stiffness: number; damping: number };
/** px the counter travels as it swaps. */
countRise: number;
};
// Quality rule: damping ratios (damping / 2√stiffness) stay at or above
// 0.8 — a box that bounces after it fills turns a small completion into
// a celebration it has not earned. Variants change pace, never bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Quick and quiet. For checklists with a dozen rows.
subtle: {
drawSeconds: 0.2,
ruleSeconds: 0.2,
gap: 0.04,
boxSpring: { type: "spring", stiffness: 540, damping: 46 },
countRise: 6,
},
// Enough separation to read the mark before the rule sweeps. The
// all-purpose setting.
default: {
drawSeconds: 0.28,
ruleSeconds: 0.26,
gap: 0.08,
boxSpring: { type: "spring", stiffness: 460, damping: 40 },
countRise: 8,
},
// A slower hand, for a short list where each row is a real milestone.
playful: {
drawSeconds: 0.34,
ruleSeconds: 0.32,
gap: 0.12,
boxSpring: { type: "spring", stiffness: 380, damping: 34 },
countRise: 10,
},
};
const SAMPLE_ITEMS: ChecklistTask[] = [
{ label: "Name your workspace", done: true },
{ label: "Invite two teammates", done: true },
{ label: "Connect a calendar" },
{ label: "Create your first project" },
];
/** Theme-adaptive neutral: mixing the text color in scope with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function ChecklistItemComplete({
variant = "default",
title = "Finish setting up",
items = SAMPLE_ITEMS,
autoCompleteMs = 900,
accent = "#2FA36B",
onComplete,
}: ChecklistItemCompleteProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [done, setDone] = useState<boolean[]>(() =>
items.map((item) => Boolean(item.done))
);
const complete = (index: number) => {
setDone((current) => {
if (current[index]) return current;
const next = [...current];
next[index] = true;
return next;
});
onComplete?.(index);
};
// The first unfinished row completes on its own, so the motion can be
// watched without hunting for the control that triggers it.
useEffect(() => {
const target = done.findIndex((value) => !value);
if (target === -1) return;
const timer = setTimeout(() => complete(target), autoCompleteMs);
return () => clearTimeout(timer);
// Runs once per completion: each one re-arms for the next row.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [done, autoCompleteMs]);
const count = done.filter(Boolean).length;
return (
<div
style={{
width: 320,
boxSizing: "border-box",
padding: 18,
borderRadius: 18,
border: `1px solid ${tone(12)}`,
background: tone(5),
}}
>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 10,
}}
>
<span style={{ fontSize: 13.5, fontWeight: 650 }}>{title}</span>
{/* The counter swaps rather than counting: a number that rolls
through digits pulls attention off the row that just moved.
Constant font size, vertical travel only — text never scales. */}
<span
style={{
position: "relative",
display: "inline-flex",
justifyContent: "flex-end",
minWidth: 58,
height: 16,
fontSize: 11.5,
fontWeight: 600,
letterSpacing: 0.2,
opacity: 0.55,
overflow: "hidden",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={count}
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, y: cfg.countRise }
}
animate={{ opacity: 1, y: 0 }}
exit={
reduceMotion
? { opacity: 0 }
: { opacity: 0, y: -cfg.countRise }
}
transition={{ duration: 0.22, ease: "easeOut" }}
style={{ position: "absolute", top: 0, right: 0 }}
>
{`${count} of ${items.length}`}
</motion.span>
</AnimatePresence>
</span>
</div>
<div
aria-hidden
style={{
position: "relative",
height: 3,
margin: "10px 0 4px",
borderRadius: 3,
background: tone(12),
overflow: "hidden",
}}
>
<motion.div
initial={false}
animate={{ scaleX: count / items.length }}
transition={
reduceMotion
? { duration: 0 }
: { type: "spring", stiffness: 260, damping: 32 }
}
style={{
position: "absolute",
inset: 0,
borderRadius: 3,
background: accent,
// scaleX rather than width: width is a layout property and
// would relayout the bar on every frame.
transformOrigin: "0% 50%",
}}
/>
</div>
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
{items.map((item, index) => (
<Row
key={item.label}
label={item.label}
done={done[index]}
cfg={cfg}
accent={accent}
reduceMotion={Boolean(reduceMotion)}
onPress={() => complete(index)}
/>
))}
</ul>
</div>
);
}
function Row({
label,
done,
cfg,
accent,
reduceMotion,
onPress,
}: {
label: string;
done: boolean;
cfg: VariantConfig;
accent: string;
reduceMotion: boolean;
onPress: () => void;
}) {
return (
<li>
<motion.button
type="button"
onClick={onPress}
disabled={done}
aria-pressed={done}
// The finished row steps back so the unfinished ones read as the
// live part of the list. Opacity only — no size change.
animate={{ opacity: done ? 0.5 : 1 }}
transition={{ duration: 0.24, ease: "easeOut", delay: done ? 0.1 : 0 }}
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "9px 0",
fontFamily: "inherit",
color: "inherit",
background: "transparent",
border: "none",
borderTop: `1px solid ${tone(8)}`,
textAlign: "left",
cursor: done ? "default" : "pointer",
}}
>
<span
aria-hidden
style={{
position: "relative",
flex: "0 0 auto",
width: 20,
height: 20,
borderRadius: 999,
border: `1.5px solid ${tone(26)}`,
display: "grid",
placeItems: "center",
}}
>
{/* The fill is its own layer whose opacity animates, rather
than an animated background-color: theme-adaptive neutrals
are color-mix() values and no engine can interpolate those.
It is a plain disc, so it may scale. */}
<motion.span
initial={false}
animate={{ opacity: done ? 1 : 0, scale: done ? 1 : 0.6 }}
transition={
reduceMotion
? { duration: 0 }
: {
opacity: { duration: cfg.drawSeconds * 0.5, ease: "easeOut" },
scale: { type: "spring", stiffness: 520, damping: 42 },
}
}
style={{
position: "absolute",
inset: -1.5,
borderRadius: 999,
background: accent,
}}
/>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
style={{ position: "relative" }}
>
<motion.path
d="M2.6 6.3 5 8.7l4.4-5"
stroke="#ffffff"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
// pathLength animates the dash offset, so the mark is
// written rather than faded in.
animate={{ pathLength: done ? 1 : 0, opacity: done ? 1 : 0 }}
transition={
reduceMotion
? { duration: 0 }
: {
pathLength: {
duration: cfg.drawSeconds,
delay: cfg.drawSeconds * 0.3,
ease: "easeOut",
},
opacity: { duration: 0.1, delay: cfg.drawSeconds * 0.3 },
}
}
/>
</svg>
</span>
<span
style={{
position: "relative",
fontSize: 13,
lineHeight: 1.4,
fontWeight: 500,
}}
>
{label}
{/* A rule sweeping across is the cheapest honest way to say
"finished": the label itself never moves or resizes. */}
<motion.span
aria-hidden
initial={false}
animate={
reduceMotion
? { opacity: done ? 0.7 : 0, scaleX: 1 }
: { scaleX: done ? 1 : 0, opacity: done ? 0.7 : 0 }
}
transition={
reduceMotion
? { duration: 0 }
: {
scaleX: {
duration: cfg.ruleSeconds,
delay: cfg.drawSeconds * 1.3 + cfg.gap,
ease: "easeOut",
},
opacity: {
duration: 0.08,
delay: cfg.drawSeconds * 1.3 + cfg.gap,
},
}
}
style={{
position: "absolute",
left: 0,
right: 0,
top: "52%",
height: 1.5,
borderRadius: 2,
background: "currentColor",
transformOrigin: "0% 50%",
}}
/>
</span>
</motion.button>
</li>
);
}About this pattern
The moment a setup task is satisfied. Four things happen in a short chain rather than at once: the box fills, the mark inside is written with pathLength, a rule sweeps left to right through the label, and the row settles back to half opacity so the remaining work reads as the live part of the list. The counter above swaps its value on a short vertical crossfade at a constant font size — a number that rolls through digits would pull attention off the row that actually moved. The label itself never moves or resizes; the rule does all the talking.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Setup checklist
Activation steps that mark themselves off with a running completion count.
Related patterns
- Goal PickerChosen goal tiles paint themselves and take a mark, and the continue action rises the moment the first one is picked.
- Invite Team SendEach address leaves the compose box and travels into a waiting seat, then marks itself sent.
- Sample Data PopulateExample figures sweep into an empty report so a new account can see what the product does before it has data of its own.