A determinate progress bar that advances by capillary action — quick pull, slowing soak, brief dwell, next pull — with a faint wet-front runner previewing the track a few pixels ahead of the true fill, so bursty real-world progress (chunked uploads) reads as natural rather than janky.
npx shadcn add https://design.helpmarq.com /r/progress-wick.jsonregistry/core/progress-wick/component.tsx"use client";
import { useEffect, useId, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// WickRun — a determinate progress bar that advances by capillary action
// instead of gliding. True progress sets a `target`; the visible fill chases
// it in discrete draws (quick pull, slowing soak, brief dwell, next pull),
// each draw closing ~60% of the remaining gap with ease-out-expo over 300ms
// then holding 150ms — an asymptotic approach that re-aims itself whenever
// the target moves mid-draw. A second element at --foreground, low opacity,
// rides 4-10px ahead of the true fill edge during each draw (its lead
// collapsing to ~0 through the dwell) as a faint wet-front preview of where
// the fill is about to reach. Widths/positions are written as CSS custom
// properties (`--wick-fill`, `--wick-front`) from one rAF loop that sleeps
// the instant the gap closes, and wakes again only when `value` moves.
// Indeterminate mode drops the fill and sends the front pill alone down the
// track in the same draw-dwell cadence, slowed down, looping. `aria-valuenow`
// tracks the TRUE `value` directly every render — never the eased display —
// so a screen reader is never behind the animation; milestone crossings
// (25/50/75/100) are announced through a separate polite live region, and a
// Geist Mono percentage sits beside the track so the reading never depends
// on the bar alone. Reduced motion strips the wet front and the draws
// collapse to a single 150ms linear width transition per update.
// ---------------------------------------------------------------------------
const DRAW_MS = 300;
const DWELL_MS = 150;
const DRAW_FRACTION = 0.6; // each draw closes 60% of the remaining gap
const SETTLE_EPS = 0.05; // percent — below this the run is "arrived"
const SLOW_DRAW_MS = 480;
const SLOW_DWELL_MS = 260;
const SLOW_SETTLE_EPS = 2; // looser: indeterminate resets before crawling the last %
const TRAVEL_START = -16; // % — front pill starts just off the left edge
const TRAVEL_END = 116; // % — and exits just off the right edge
const MILESTONES = [25, 50, 75, 100];
function clamp(n: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, n));
}
function easeOutExpo(t: number) {
return t >= 1 ? 1 : 1 - Math.pow(2, -10 * t);
}
function useReducedMotion() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(mq.matches);
const onChange = () => setReduced(mq.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return reduced;
}
export interface WickRunProps {
/** true progress, 0-100 (controlled). Ignored while `indeterminate` is set. */
value?: number;
/** unknown-duration mode: the wet front alone travels the track in slow draws, no fill */
indeterminate?: boolean;
/** visible + accessible label, e.g. "Uploading assets.zip" (default "Progress") */
label?: string;
/** announce 25/50/75/100% crossings via a polite live region (default true) */
announceMilestones?: boolean;
className?: string;
}
export function WickRun({
value = 0,
indeterminate = false,
label = "Progress",
announceMilestones = true,
className = "",
}: WickRunProps) {
const uid = useId();
const labelId = `${uid}-label`;
const reduced = useReducedMotion();
const trackRef = useRef<HTMLDivElement>(null);
const retargetRef = useRef<((v: number) => void) | null>(null);
const lastMilestoneRef = useRef(0);
const [announce, setAnnounce] = useState("");
const clampedValue = clamp(value, 0, 100);
// Milestone announcements read the TRUE value directly, decoupled from the
// rAF loop entirely, so assistive tech is never a beat behind the fill.
useEffect(() => {
if (indeterminate || !announceMilestones) return;
if (clampedValue < lastMilestoneRef.current) lastMilestoneRef.current = 0;
for (const m of MILESTONES) {
if (clampedValue >= m && lastMilestoneRef.current < m) {
lastMilestoneRef.current = m;
setAnnounce(m === 100 ? "Complete" : `${m}% complete`);
}
}
}, [clampedValue, indeterminate, announceMilestones]);
// Engine: mounts once per indeterminate/reduced-motion combination, then
// sleeps between updates. A separate effect below feeds it every `value`
// change through `retargetRef` without re-running this setup.
useEffect(() => {
const track = trackRef.current;
if (!track) return;
if (reduced) {
// Reduced motion: no rAF, no wet front (never rendered, see JSX) — a
// plain width write lands on the CSS `transition: width 150ms linear`
// declared below, so every update glides once, linearly, and stops.
// Indeterminate has nothing to animate under reduced motion, so it
// paints one static partial bar rather than an uninformative 0-width
// track — `aria-valuetext` still carries the real "in progress" state.
if (indeterminate) {
track.style.setProperty("--wick-fill", "35%");
return;
}
const paint = (v: number) => {
track.style.setProperty("--wick-fill", `${clamp(v, 0, 100)}%`);
};
paint(clampedValue);
retargetRef.current = paint;
return () => {
retargetRef.current = null;
};
}
let raf = 0;
let trackW = track.clientWidth || 1;
let phase: "draw" | "dwell" | "idle" = "idle";
let phaseStart = 0;
let display = indeterminate ? TRAVEL_START : 0;
let target = indeterminate ? TRAVEL_END : clampedValue;
let drawFrom = display;
let drawTo = display;
const drawMs = indeterminate ? SLOW_DRAW_MS : DRAW_MS;
const dwellMs = indeterminate ? SLOW_DWELL_MS : DWELL_MS;
const eps = indeterminate ? SLOW_SETTLE_EPS : SETTLE_EPS;
const pxToPct = (px: number) => (px / trackW) * 100;
const beginDraw = (now: number) => {
const gap = target - display;
drawFrom = display;
drawTo = display + gap * DRAW_FRACTION;
phase = "draw";
phaseStart = now;
};
const paint = (fillPct: number, leadPct: number) => {
const fill = clamp(fillPct, 0, 100);
track.style.setProperty("--wick-fill", `${indeterminate ? 0 : fill}%`);
const frontPos = indeterminate ? fillPct : fill + leadPct;
track.style.setProperty("--wick-front", `${frontPos}%`);
};
const loop = (now: number) => {
if (phase === "idle" && Math.abs(target - display) > eps) beginDraw(now);
let leadPct = 0;
if (phase === "draw") {
const p = clamp((now - phaseStart) / drawMs, 0, 1);
display = drawFrom + (drawTo - drawFrom) * easeOutExpo(p);
if (!indeterminate) leadPct = pxToPct(10 - 6 * p); // quick pull -> slowing soak
if (p >= 1) {
display = drawTo;
phase = "dwell";
phaseStart = now;
}
} else if (phase === "dwell") {
const p = clamp((now - phaseStart) / dwellMs, 0, 1);
if (!indeterminate) leadPct = pxToPct(4 * (1 - p)); // front settles back onto the fill
if (p >= 1) {
const gap = target - display;
if (Math.abs(gap) <= eps) {
display = indeterminate ? TRAVEL_START : target;
phase = "idle";
} else {
phase = "idle"; // re-aim next tick against the latest target
}
}
}
paint(display, leadPct);
const settled = !indeterminate && phase === "idle" && Math.abs(target - display) <= eps;
raf = settled ? 0 : requestAnimationFrame(loop);
};
const wake = () => {
if (!raf) raf = requestAnimationFrame(loop);
};
retargetRef.current = (v: number) => {
if (indeterminate) return;
target = clamp(v, 0, 100);
wake();
};
wake();
const ro = new ResizeObserver(() => {
trackW = track.clientWidth || 1;
});
ro.observe(track);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
retargetRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- clampedValue read once at mount, then fed via retargetRef
}, [indeterminate, reduced]);
useEffect(() => {
retargetRef.current?.(clampedValue);
}, [clampedValue]);
const valueText = indeterminate ? "In progress" : `${Math.round(clampedValue)}%`;
return (
<div className={className}>
<style>{`
.ns-wick-fill{width:var(--wick-fill,0%)}
.ns-wick-front{left:var(--wick-front,0%)}
@media (prefers-reduced-motion: reduce){
.ns-wick-fill{transition:width 150ms linear}
}
`}</style>
<div className="mb-1.5 flex items-baseline justify-between gap-3">
<span id={labelId} className="font-mono text-xs text-muted">
{label}
</span>
{!indeterminate ? (
<span className="font-mono text-xs tabular-nums text-foreground" aria-hidden>
{Math.round(clampedValue)}%
</span>
) : null}
</div>
<div
ref={trackRef}
role="progressbar"
aria-labelledby={labelId}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={indeterminate ? undefined : Math.round(clampedValue)}
aria-valuetext={valueText}
className="relative h-1.5 overflow-hidden rounded-full border border-border"
>
<div className="ns-wick-fill absolute inset-y-0 left-0 rounded-full bg-foreground" />
{!reduced ? (
<div className="ns-wick-front absolute inset-y-0 w-3.5 -translate-x-1/2 rounded-full bg-foreground opacity-25" />
) : null}
</div>
{announceMilestones && !indeterminate ? (
<span role="status" aria-live="polite" className="sr-only">
{announce}
</span>
) : null}
</div>
);
}
a determinate progress bar for uploads or multi-step completion where the underlying progress genuinely arrives in bursts (chunked uploads, multi-file copy) and a smooth glide would look like it's lying about its own cadence — progress-wick's draw-dwell rhythm makes bursty progress look like its native gait instead of a stutter, and it doubles as a slow-draw indeterminate mode for unknown-duration waits. Pick progress-narrated instead when the progress has a small number of named phases worth narrating (a build, an onboarding checklist) and a typed mono caption plus a docked timestamped ledger is the point. This is not a level/quantity gauge: pick meter-quota-meniscus instead for a soft/hard-limit quota reading, where the signal is a vessel's liquid curvature at a fixed cap, not transport along a line.
A determinate `role=progressbar` (0-100 `value`, controlled) whose visible fill does not glide to its target but chases it in discrete capillary draws. Internally a single rAF loop tracks a `target` (the true `value`, clamped) and a `display` (the eased, shown percent); whenever they differ by more than a small epsilon it starts a 'draw': `display` eases from its current position toward `display + (target - display) * 0.6` — 60% of the remaining gap — over 300ms with ease-out-expo, then holds ('dwells') for 150ms before re-checking the gap against the (possibly since-moved) `target` and either settling or starting the next draw. Because each draw only closes 60% of what's left, a fixed `target` produces a naturally decaying sequence of draws that converges asymptotically rather than one animation; a `target` that moves mid-draw or mid-dwell is simply picked up by the next draw's gap calculation, so bursty real progress (a chunked upload landing irregular chunks) drives a rhythm that already looks native rather than stuttering against a glide. A second element at `bg-foreground opacity-25`, a 14px pill centered on a CSS `left` position, rides ahead of the true fill edge during the draw phase — its lead in pixels running `10 -> 4` across the draw (quick pull, slowing soak) then continuing down to 0 across the dwell (the front settles back onto the fill) — a faint preview of where the bar is about to reach, never touching a raw hex value since both elements are `bg-foreground`. Fill width and front position are written every frame as CSS custom properties (`--wick-fill`, `--wick-front`) on the track element, read by two static one-line style rules (`width:var(--wick-fill,0%)`, `left:var(--wick-front,0%)`); the rAF loop stops entirely once the gap closes and only wakes again when `value` changes, via a ref-held retarget function set up once per mount so the value-watching effect never re-runs the engine setup. `indeterminate` (default false) drops the fill to zero width and instead sends the front pill alone traveling the same draw-dwell cadence, slowed (480ms draws, 260ms dwells) and continuous: it resets to just off the left edge and re-travels toward just off the right edge every time it arrives, looping for as long as the component is mounted, ignoring `value` entirely while active. Accessibility: `aria-valuenow` is set from the true `value` (rounded) on every render, completely decoupled from the animation loop, so a screen reader is never a beat behind what's on screen (indeterminate correctly omits `aria-valuenow` and instead sets `aria-valuetext="In progress"`); a visually-hidden `role=status`/`aria-live=polite` span separately announces 25/50/75/100% milestone crossings ('Complete' at 100), computed straight off `value` with a ref tracking the last-announced threshold (reset if `value` drops back below it) so nothing double-fires; a Geist Mono percentage label sits beside the visible text label so the reading is never conveyed by bar length alone. `prefers-reduced-motion: reduce` (checked via `matchMedia` with a live change listener) removes the wet-front element from the DOM outright and switches the fill to a plain CSS `transition: width 150ms linear` driven directly by `value` with no rAF loop at all; a reduced-motion indeterminate render has nothing to animate, so it paints one static partial-width bar instead of an uninformative empty track, with the real 'in progress' state still carried by `aria-valuetext`. Props: `value` (0-100, default 0), `indeterminate` (default false), `label` (visible + accessible name, default "Progress"), `announceMilestones` (default true), `className`. DOM+CSS only, no canvas, no SVG, no dependencies.