Swish UI
All componentsCards and layout Free

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.

Found on