Progress border card
A task card whose border draws itself around the edge as work progresses, ticking off steps, then glows Done.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
const STEPS = ["Fetch 1,284 rows from Sheets", "Remove 37 duplicates", "Enrich company details", "Sync contacts to CRM"];
const RUN = 6400; // milliseconds from 0 to 100
const REST = 4200; // pause on Done before the loop replays itself
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const rect = useRef<SVGRectElement>(null);
const pct = useRef<HTMLSpanElement>(null);
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
const progress = useRef(0);
const [done, setDone] = useState(0); // steps completed, 0 to STEPS.length
const [run, setRun] = useState(0); // bumping this restarts the loop
const finished = done === STEPS.length;
const paint = (p: number) => {
rect.current?.style.setProperty("stroke-dashoffset", String(100 - p));
if (pct.current) pct.current.textContent = `${Math.round(p)}%`;
};
const replay = () => {
progress.current = 0;
paint(0);
setDone(0);
setRun((r) => r + 1);
};
// Reduced motion: no drawing, the finished card.
useEffect(() => {
if (!reduced) return;
progress.current = 100;
paint(100);
setDone(STEPS.length);
}, [reduced, run]);
useEffect(() => {
if (reduced || !inView || progress.current >= 100) return;
let raf = 0, last = performance.now();
const frame = (now: number) => {
const p = Math.min(100, progress.current + ((now - last) / RUN) * 100);
last = now;
progress.current = p;
paint(p);
// Steps only change state when the line crosses their share of the border.
const steps = Math.floor((p / 100) * STEPS.length + 1e-6);
setDone((d) => (d === steps ? d : steps));
if (p < 100) raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [inView, reduced, run]);
// After a rest on Done, replay on its own so the demo keeps living.
useEffect(() => {
if (!finished || reduced || !inView) return;
const timer = setTimeout(replay, REST);
return () => clearTimeout(timer);
}, [finished, reduced, inView]);
return (
<div ref={root} className={styles.root}>
<section className={styles.card} data-done={finished || undefined} aria-label="Import leads">
<span className={styles.srOnly} role="progressbar" aria-label="Import progress" aria-valuemin={0} aria-valuemax={100} aria-valuenow={done * 25} />
<svg className={styles.border} aria-hidden="true">
<rect className={styles.track} width="100%" height="100%" rx="16" />
<rect ref={rect} className={styles.line} width="100%" height="100%" rx="16" pathLength={100} />
</svg>
<header className={styles.head}>
<span className={styles.logo} aria-hidden="true">
<svg viewBox="0 0 16 16"><path d="M8 2v8M4.5 6.5 8 10l3.5-3.5M3 13h10" /></svg>
</span>
<p className={styles.name}>Import leads</p>
<span className={styles.status} aria-live="polite">
{finished ? (
<span key="done" className={styles.doneChip}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m3.5 8.5 3 3 6-7" /></svg>Done
</span>
) : (
<span ref={pct} className={styles.pct} aria-hidden="true">0%</span>
)}
</span>
</header>
<ol className={styles.steps}>
{STEPS.map((s, i) => {
const state = i < done ? "done" : i === done ? "active" : "todo";
return (
<li key={s} data-state={state}>
<span className={styles.mark} aria-hidden="true">
{state === "done" && <svg viewBox="0 0 16 16"><path d="m4 8.5 2.5 2.5L12 5.5" /></svg>}
</span>
<span className={styles.label}>{s}</span>
<span className={styles.srOnly}>{state === "done" ? ", done" : state === "active" ? ", in progress" : ""}</span>
</li>
);
})}
</ol>
<button type="button" className={styles.replay} onClick={replay}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8a5 5 0 1 0 1.5-3.6M3 2.5v2.5h2.5" /></svg>
Replay
</button>
</section>
</div>
);
}
demo.module.css
.root {
container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 16px;
box-sizing: border-box; background: var(--surface);
}
.card {
position: relative; width: min(360px, 100%); display: grid; gap: 14px; padding: 18px 18px 16px; box-sizing: border-box;
border-radius: 16px; background: var(--white); transition: box-shadow .6s var(--out);
}
/* The border is an SVG rect sized by percentages; pathLength=100 makes progress equal the dash offset at any size. */
.border { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.border rect { fill: none; }
.track { stroke: var(--line); stroke-width: 1.5; }
.line { stroke: var(--accent); stroke-width: 2.5; stroke-linecap: round; stroke-dasharray: 100 100; stroke-dashoffset: 100; }
.card[data-done] { box-shadow: 0 0 0 4px var(--accent-pale), 0 16px 40px -18px color-mix(in srgb, var(--accent) 70%, transparent); }
.card[data-done] .line { animation: glow 1.4s var(--out); }
@keyframes glow {
0% { filter: drop-shadow(0 0 0 var(--accent)); }
35% { filter: drop-shadow(0 0 6px var(--accent)); }
100% { filter: drop-shadow(0 0 0 transparent); }
}
.head { display: flex; align-items: center; gap: 10px; }
.head p { margin: 0; }
.logo { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; background: var(--accent-pale); color: var(--accent-text); }
.logo svg, .replay svg, .doneChip svg, .mark svg { fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; }
.logo svg { width: 16px; height: 16px; stroke-width: 1.6; }
.name { flex: 1; min-width: 0; font-size: 14px; font-weight: 650; }
.status { flex: none; }
.pct { display: inline-block; min-width: 40px; text-align: right; font-size: 12px; font-weight: 650; color: var(--muted); font-variant-numeric: tabular-nums; }
.doneChip {
display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 10px 0 8px; border-radius: 999px;
background: var(--accent-pale); color: var(--accent-text); font-size: 12px; font-weight: 700; animation: pop .45s var(--out);
}
.doneChip svg { width: 13px; height: 13px; stroke-width: 2.2; stroke-dasharray: 16; animation: draw .4s .1s var(--out) both; }
@keyframes pop { from { opacity: 0; scale: .8; } }
@keyframes draw { from { stroke-dashoffset: 16; } to { stroke-dashoffset: 0; } }
.steps { list-style: none; margin: 0; padding: 0; display: grid; gap: 9px; }
.steps li { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--muted); transition: color .3s; }
.steps li[data-state="active"] { color: var(--ink); font-weight: 550; }
.steps li[data-state="done"] { color: var(--ink); }
.label { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mark {
flex: none; display: grid; place-items: center; width: 18px; height: 18px; border-radius: 50%; box-sizing: border-box;
border: 1.5px solid var(--line); transition: background-color .3s, border-color .3s;
}
.steps li[data-state="active"] .mark { border-color: var(--line); border-top-color: var(--accent-text); animation: spin .8s linear infinite; }
.steps li[data-state="done"] .mark { border-color: var(--accent-text); background: var(--accent-text); color: var(--white); }
.mark svg { width: 12px; height: 12px; stroke-width: 2.4; stroke-dasharray: 14; animation: tick .35s var(--out) both; }
@keyframes spin { to { rotate: 1turn; } }
@keyframes tick { from { stroke-dashoffset: 14; } to { stroke-dashoffset: 0; } }
.replay {
justify-self: start; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 14px 0 11px; border-radius: 999px;
border: 1px solid var(--line); background: var(--white); color: var(--ink); font: inherit; font-size: 12px; font-weight: 650; cursor: pointer;
transition: border-color .2s, transform .18s var(--out);
}
.replay:hover { border-color: var(--accent-text); }
.replay:active { transform: scale(.95); }
.replay:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
.replay svg { width: 14px; height: 14px; stroke-width: 1.7; }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@container (max-height: 300px) {
.card { gap: 10px; padding: 14px 16px 12px; }
.steps { gap: 6px; }
.replay { min-height: 32px; }
}
@container (max-height: 240px) {
.root { padding: 10px; }
.card { gap: 8px; padding-block: 12px 10px; }
.steps { gap: 4px; }
.steps li { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.card[data-done] .line, .doneChip, .doneChip svg, .mark svg, .steps li[data-state="active"] .mark { animation: none; }
.card { transition: none; }
}
Use it for
- Imports, syncs, builds or agent runs with a few named steps
- Hero demos of automation products
- Cards where a separate progress bar would add clutter
Avoid
- Unknown durations, where an indeterminate spinner is honest
- Dense lists, where many drawing borders compete
How it works
An SVG fills the card with a rect sized by percentages and pathLength set to 100, so the stroke dash offset equals 100 minus progress at any card size. A frame loop writes the offset and the percentage through refs and only sets state when the line crosses a step's share, which ticks that step. At 100 the card adds a glow ring, the stroke pulses with a drop shadow and a Done chip pops in; it replays after a rest or on Replay.
Accessibility
A hidden progressbar moves step by step, each step has hidden done or in progress text and the Done chip sits in a polite live region. Replay is a real button. Reduced motion shows the finished card with every step ticked and no animation.