Multi step ticks
A checklist of booking steps ticks off one after another, each check drawn in as its circle fills.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
const STEPS = ["Offer opened", "Availability rechecked", "Payment confirmed by Stripe", "Stay updated in Hostaway"];
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
const [on, setOn] = useState(false);
const raf = useRef(0);
const titleId = useId();
useEffect(() => {
if (inView) setOn(true);
}, [inView]);
useEffect(() => () => cancelAnimationFrame(raf.current), []);
// Drop back to the empty state for one frame, then tick again.
const replay = () => {
setOn(false);
cancelAnimationFrame(raf.current);
raf.current = requestAnimationFrame(() => {
raf.current = requestAnimationFrame(() => setOn(true));
});
};
const done = on || reduced;
return (
<div ref={root} className={styles.root}>
<section className={styles.card} aria-labelledby={titleId}>
<header className={styles.head}>
<b id={titleId}>Extra night for Maya</b>
<span>Villa Azul · Wed 14</span>
</header>
<ol className={`${styles.steps} ${done ? styles.on : ""}`}>
{STEPS.map((step, i) => (
<li key={step} style={{ "--d": `${0.3 + i * 0.5}s` } as React.CSSProperties}>
<i aria-hidden="true">
<svg viewBox="0 0 18 18"><path pathLength="1" d="M5 9.5 7.8 12 13 6.5" /></svg>
</i>
<span>{step}</span>
<span className={styles.sr}>{done ? ", done" : ", pending"}</span>
</li>
))}
</ol>
</section>
<button type="button" className={styles.replay} onClick={replay}>
Replay
</button>
</div>
);
}
demo.module.css
.root { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 10px 16px; }
.card { width: min(340px, 100%); padding: 12px 18px 12px; border: 1px solid var(--line); border-radius: 16px; background: var(--white); }
.head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding-bottom: 10px; margin-bottom: 4px; border-bottom: 1px solid var(--line); font-size: 14px; }
.head span { font-size: 12px; color: var(--muted); white-space: nowrap; }
.steps { position: relative; display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.steps li { position: relative; display: flex; align-items: center; gap: 12px; padding: 5px 0; font-size: 14px; color: var(--muted); transition: color .25s; }
/* The rail between two circles fills as the step below completes. */
.steps li + li::before { content: ""; position: absolute; left: 9px; bottom: calc(50% + 11px); height: calc(100% - 22px); width: 2px; border-radius: 1px; background: var(--line); }
.steps li + li::after { content: ""; position: absolute; left: 9px; bottom: calc(50% + 11px); height: calc(100% - 22px); width: 2px; border-radius: 1px; background: var(--accent-text); transform: scaleY(0); transform-origin: top; transition: transform .3s var(--out); }
.steps i { display: grid; place-items: center; width: 20px; height: 20px; flex: none; border-radius: 50%; border: 1.5px solid var(--line); background: var(--white); transition: background-color .25s, border-color .25s, scale .35s var(--out); }
.steps svg { width: 18px; height: 18px; fill: none; stroke: var(--white); stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.steps path { stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset .35s var(--out); }
.on li { color: var(--ink); transition-delay: var(--d); }
.on li + li::after { transform: scaleY(1); transition-delay: calc(var(--d) - .3s); }
.on i { background: var(--accent-text); border-color: var(--accent-text); transition-delay: var(--d); animation: pop .4s var(--out) var(--d) both; }
.on path { stroke-dashoffset: 0; transition-delay: calc(var(--d) + .1s); }
@keyframes pop { 40% { scale: 1.18; } }
/* Resetting for a replay is instant. */
.steps:not(.on) *, .steps:not(.on) li::after { transition-duration: 0s; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.replay { min-height: 40px; padding: 0 18px; border: 1px solid var(--line); border-radius: 999px; background: var(--white); font-weight: 600; font-size: 14px; cursor: pointer; transition: border-color .18s, scale .18s var(--out); }
.replay:hover { border-color: var(--ink); }
.replay:active { scale: .97; }
@media (prefers-reduced-motion: reduce) {
.steps *, .steps li::after { transition: none !important; animation: none !important; }
}
Use it for
- Showing what happened after a single action, such as a paid offer
- Onboarding or setup flows with three to five steps
Avoid
- Real progress you cannot measure: do not fake steps the system is not doing
- Long lists, where the stagger makes people wait
How it works
One class on the list starts everything; each item has its own delay variable, so color, circle fill, the rail above it and the check stroke all transition with staggered transition-delay. The check is an SVG path with pathLength 1, drawn by moving stroke-dashoffset from 1 to 0. Replay removes the class for one frame with transitions forced to zero, then adds it again.
Accessibility
Each step carries hidden text saying whether it is done. Reduced motion shows every step ticked straight away. The Replay button is a normal button.