Progress
A bar and a ring whose fill moves on a spring while the percentage rolls like an odometer beside it. At 100% the fill turns green, the ring's number becomes a tick and a chime fires.
Code
1. Install
npm i framer-motion lucide-react
2. Files
"use client";
// Progress: a bar and a ring whose fill moves on a spring while the percentage rolls like an odometer beside it. At
// 100% the fill turns green, the ring's number becomes a tick and a chime fires. Indeterminate bars sweep, rings spin.
// By default it plays a short upload script once it scrolls into view; pass `value` to drive it yourself.
import { useEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Check } from "lucide-react";
export type ProgressCue = "done";
type Base = {
/** Current value, 0 to `max`. */
value?: number;
/** The value that counts as complete. */
max?: number;
/** Show an endless sweep (bar) or spin (ring) instead of a value. */
indeterminate?: boolean;
/** 0 to 100: how snappy the fill moves. */
snap?: number;
/** Label beside the percentage (bar) or the accessible name (ring, when a string). */
label?: ReactNode;
/** Sound hook: "done" on completion. */
onSound?: (cue: ProgressCue) => void;
className?: string;
"aria-label"?: string;
};
export type ProgressBarProps = Base & {
/** Bar width in px. */
width?: number;
/** Show the rolling percentage. */
showValue?: boolean;
};
export type ProgressRingProps = Base & {
/** Ring diameter in px. */
size?: number;
/** Stroke width in px. */
stroke?: number;
};
export type ProgressProps = {
/** Controlled value, 0 to `max`. Leave out to play the `script` when the block first scrolls into view. */
value?: number;
/** The value that counts as complete. */
max?: number;
/** The scripted run: [value, delay in ms] pairs, played in order from 0. */
script?: [number, number][];
/** Bar label. */
label?: string;
/** Bar width in px. */
width?: number;
/** Ring diameter in px. */
ringSize?: number;
/** 0 to 100: how snappy the fills move. */
snap?: number;
/** Sound hook: "done" on completion of a value you drive (the scripted demo stays silent). */
onSound?: (cue: ProgressCue) => void;
className?: string;
style?: CSSProperties;
};
const SCRIPT: [number, number][] = [[12, 300], [48, 900], [73, 1500], [100, 2300]];
const EXIT = [0.4, 0, 1, 1] as const;
const OUT = [0.22, 1, 0.36, 1] as const;
const cn = (...a: (string | false | null | undefined)[]) => a.filter(Boolean).join(" ");
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const reduced = () => typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
// 0..100 knob -> per-frame spring constants
const springK = (knob: number) => ({ k: 0.08 + (knob / 100) * 0.16, d: 0.62 + (knob / 100) * 0.2 });
// Endless CSS animations pause while the block is off screen (the CSS reads data-off).
function usePauseOffscreen(ref: RefObject<HTMLElement | null>) {
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(([e]) => (e.isIntersecting ? el.removeAttribute("data-off") : el.setAttribute("data-off", "")), { rootMargin: "160px" });
io.observe(el);
return () => io.disconnect();
}, [ref]);
}
// Re-targetable spring: velocity carries over when the target moves, so the value never jerks.
function useSpringValue(target: number, knob: number) {
const [v, setV] = useState(target);
const pos = useRef(target);
const vel = useRef(0);
useEffect(() => {
if (reduced()) {
pos.current = target;
vel.current = 0;
setV(target);
return;
}
const { k, d } = springK(knob);
let last = 0;
let raf = 0;
const frame = (t: number) => {
const dt = last ? Math.min(2.5, Math.max(0, (t - last) / 16.67)) : 1;
last = t;
vel.current += (target - pos.current) * k * dt;
vel.current *= d ** dt;
pos.current += vel.current * dt;
if (Math.abs(target - pos.current) < 0.02 && Math.abs(vel.current) < 0.02) {
pos.current = target;
vel.current = 0;
setV(target);
return;
}
setV(pos.current);
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [target, knob]);
return v;
}
// Columns from the right; each is a drum of 0-9-0 translated by a spring on the running quotient.
function Odometer({ value, snap = 56 }: { value: number; snap?: number }) {
const abs = Math.abs(Math.round(value));
const cols = Math.max(1, String(abs).length);
const strips = useRef<(HTMLSpanElement | null)[]>([]);
const sim = useRef<{ p: number; v: number; t: number }[]>([]);
const raf = useRef(0);
const last = useRef(0);
for (let k = 0; k < cols; k++) {
const t = Math.floor(abs / 10 ** k);
sim.current[k] ??= { p: t, v: 0, t };
sim.current[k].t = t;
}
sim.current.length = cols;
const frameRef = useRef<(t: number) => void>(() => {});
frameRef.current = (t: number) => {
const dt = last.current ? clamp((t - last.current) / 16.67, 0, 2.5) : 1;
last.current = t;
const { k, d } = springK(clamp(snap, 0, 100));
const still = reduced();
let moving = false;
sim.current.forEach((s, i) => {
if (still) {
s.p = s.t;
s.v = 0;
} else {
s.v += (s.t - s.p) * k * dt;
s.v *= d ** dt;
s.p += s.v * dt;
if (Math.abs(s.t - s.p) < 0.004 && Math.abs(s.v) < 0.004) {
s.p = s.t;
s.v = 0;
} else moving = true;
}
const el = strips.current[i];
if (!el) return;
el.style.transform = `translateY(${(-(((s.p % 10) + 10) % 10) * 1.3).toFixed(4)}em)`;
const b = Math.min(3, Math.abs(s.v) * 2.4);
el.style.filter = b > 0.15 ? `blur(${b.toFixed(2)}px)` : "";
});
if (moving) raf.current = requestAnimationFrame((tt) => frameRef.current(tt));
else {
raf.current = 0;
last.current = 0;
}
};
useEffect(() => {
if (!raf.current) raf.current = requestAnimationFrame((t) => frameRef.current(t));
return () => {
cancelAnimationFrame(raf.current);
raf.current = 0;
last.current = 0;
};
}, [value, cols]);
return (
<span className="tprg-odo" aria-hidden>
{/* zero-width, but its text gives the row the baseline the percent sign lines up with */}
<span className="tprg-sign">−</span>
{Array.from({ length: cols }, (_, i) => cols - 1 - i).map((k) => (
<span key={k} className="tprg-col">
<span ref={(el) => void (strips.current[k] = el)} className="tprg-strip">
{"01234567890".split("").map((d, i) => (
<span key={i}>{d}</span>
))}
</span>
</span>
))}
</span>
);
}
function useFill(value: number, max: number, snap: number, onSound?: (cue: ProgressCue) => void) {
const p = clamp(value / max, 0, 1);
const f = useSpringValue(p, clamp(snap, 0, 100));
const done = p >= 1;
const was = useRef(done);
useEffect(() => {
if (done && !was.current) onSound?.("done");
was.current = done;
}, [done, onSound]);
return { f: clamp(f, 0, 1), pct: Math.round(p * 100), done };
}
/** A bar whose fill moves on a spring with the number rolling beside it. */
export function ProgressBar({ value = 0, max = 100, indeterminate, snap = 44, label, width = 240, showValue = true, onSound, className, "aria-label": ariaLabel }: ProgressBarProps) {
const ref = useRef<HTMLDivElement>(null);
usePauseOffscreen(ref);
const { f, pct, done } = useFill(value, max, snap, onSound);
return (
<div ref={ref} className={cn("tprg-bar", className)} data-done={done || undefined} data-busy={indeterminate || undefined} style={{ width }}>
{(label || showValue) && (
<div className="tprg-head">
<span className="tprg-label">{label}</span>
{showValue && !indeterminate && (
<span className="tprg-num">
<Odometer value={pct} />%
</span>
)}
</div>
)}
<div className="tprg-track" role="progressbar" aria-label={ariaLabel ?? (typeof label === "string" ? label : undefined)} aria-valuemin={0} aria-valuemax={max} aria-valuenow={indeterminate ? undefined : value}>
<div className="tprg-fill" style={{ transform: indeterminate ? undefined : `scaleX(${f.toFixed(4)})` }} />
</div>
</div>
);
}
/** A ring whose stroke draws round on a spring; complete, the number becomes a tick. */
export function ProgressRing({ value = 0, max = 100, indeterminate, snap = 44, label, size = 72, stroke = 6, onSound, className, "aria-label": ariaLabel }: ProgressRingProps) {
const ref = useRef<HTMLDivElement>(null);
usePauseOffscreen(ref);
const { f, pct, done } = useFill(value, max, snap, onSound);
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
const gone = { opacity: 0, scale: 0.92, filter: "blur(4px)", transition: { duration: 0.14, ease: EXIT } };
return (
<div ref={ref} className={cn("tprg-ring", className)} data-done={done || undefined} data-busy={indeterminate || undefined} style={{ width: size, height: size }} role="progressbar" aria-label={ariaLabel ?? (typeof label === "string" ? label : undefined)} aria-valuemin={0} aria-valuemax={max} aria-valuenow={indeterminate ? undefined : value}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden>
<circle className="tprg-rail" cx={size / 2} cy={size / 2} r={r} strokeWidth={stroke} />
<circle className="tprg-arc" cx={size / 2} cy={size / 2} r={r} strokeWidth={stroke} strokeDasharray={c} strokeDashoffset={indeterminate ? c * 0.75 : c * (1 - f)} />
</svg>
<div className="tprg-centre" style={{ fontSize: size * 0.22 }}>
{/* the number blurs out while the tick pops in over it (tick entry is the CSS pop) */}
<AnimatePresence initial={false}>
{done ? (
<motion.span key="tick" className="tprg-tick" exit={gone}>
<Check size={size * 0.3} strokeWidth={2.6} />
</motion.span>
) : indeterminate ? null : (
<motion.span key="num" className="tprg-num" initial={{ opacity: 0, scale: 0.92, filter: "blur(4px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)", transitionEnd: { filter: "none" } }} exit={gone} transition={{ duration: 0.2, ease: OUT }}>
<Odometer value={pct} />
</motion.span>
)}
</AnimatePresence>
</div>
</div>
);
}
export function Progress({ value: ctrl, max = 100, script = SCRIPT, label = "Uploading", width = 230, ringSize = 68, snap, onSound, className, style }: ProgressProps) {
const [v, setV] = useState(0);
const root = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = root.current;
if (ctrl !== undefined || !el) return;
let timers: number[] = [];
const start = () => {
if (reduced()) return setV(script[script.length - 1]?.[0] ?? 0); // reduced motion settles straight to the end
timers = script.map(([n, t]) => window.setTimeout(() => setV(n), t));
};
if (typeof IntersectionObserver === "undefined") {
start();
return () => timers.forEach(clearTimeout);
}
const io = new IntersectionObserver(([e]) => {
if (!e.isIntersecting) return;
io.disconnect();
start();
});
io.observe(el);
return () => {
io.disconnect();
timers.forEach(clearTimeout);
};
}, [ctrl, script]);
const value = ctrl ?? v;
return (
<div ref={root} className={cn("tprg-demo", className)} style={style}>
<ProgressBar label={label} value={value} max={max} width={width} snap={snap} onSound={ctrl === undefined ? undefined : onSound} />
<div className="tprg-demo-row">
<ProgressRing value={value} max={max} size={ringSize} snap={snap} />
<ProgressRing indeterminate size={ringSize} aria-label="Working" />
</div>
</div>
);
}
/* Progress. Prefix tprg-. */
.tprg-demo, .tprg-demo *, .tprg-bar, .tprg-bar *, .tprg-ring, .tprg-ring * { box-sizing: border-box; }
.tprg-demo, .tprg-bar, .tprg-ring { font-family: var(--ts-font, system-ui, sans-serif); font-size: 14px; letter-spacing: -0.01em; -webkit-font-smoothing: antialiased; }
:where(.tprg-demo) button, :where(.tprg-bar) button, :where(.tprg-ring) button { font: inherit; letter-spacing: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; -webkit-tap-highlight-color: transparent; }
:where(.tprg-demo) :focus-visible, :where(.tprg-bar) :focus-visible, :where(.tprg-ring) :focus-visible { outline: 2px solid var(--ts-focus, #3446eb); outline-offset: 2px; }
.tprg-bar {
display: flex;
flex-direction: column;
gap: 8px;
color: var(--ts-fg);
--tprg-tone: var(--ts-fg);
}
.tprg-bar[data-done],
.tprg-ring[data-done] {
--tprg-tone: var(--ts-success);
}
.tprg-head {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 13px;
letter-spacing: -0.01em;
}
.tprg-label {
color: color-mix(
in srgb,
var(--ts-fg) 70%,
var(--ts-raised)
);
}
.tprg-num {
display: inline-flex;
align-items: baseline;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--tprg-tone);
transition: color 0.42s cubic-bezier(0.22, 1, 0.36, 1);
}
.tprg-track {
height: 6px;
border-radius: 999px;
background: rgba(var(--ts-fg-rgb), 0.1);
overflow: hidden;
}
.tprg-fill {
width: 100%;
height: 100%;
border-radius: 999px;
background: var(--tprg-tone);
transform-origin: 0 50%;
transition: background 0.42s cubic-bezier(0.22, 1, 0.36, 1);
}
.tprg-bar[data-busy] .tprg-fill {
width: 40%;
animation: tprg-sweep 1.2s cubic-bezier(0.22, 1, 0.36, 1) infinite;
}
@keyframes tprg-sweep {
0% {
translate: -110% 0;
}
to {
translate: 260% 0;
}
}
.tprg-ring {
position: relative;
display: grid;
place-items: center;
color: var(--ts-fg);
--tprg-tone: var(--ts-fg);
}
.tprg-ring svg {
grid-area: 1 / 1;
rotate: -90deg;
}
.tprg-rail {
fill: none;
stroke: rgba(var(--ts-fg-rgb), 0.1);
}
.tprg-arc {
fill: none;
stroke: var(--tprg-tone);
stroke-linecap: round;
transition: stroke 0.42s cubic-bezier(0.22, 1, 0.36, 1);
}
.tprg-ring[data-busy] svg {
animation: tprg-spin 1.1s linear infinite;
}
@keyframes tprg-spin {
to {
rotate: 270deg;
}
}
.tprg-centre {
grid-area: 1 / 1;
display: grid;
place-items: center;
font-weight: 500;
line-height: 1;
}
.tprg-centre > * {
grid-area: 1 / 1;
}
.tprg-tick {
display: grid;
color: var(--ts-success);
animation: tprg-pop 0.52s cubic-bezier(0.3, 1.5, 0.4, 1) both;
}
@keyframes tprg-pop {
0% {
scale: 0.4;
opacity: 0;
}
45% {
scale: 1.2;
opacity: 1;
}
to {
scale: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.tprg-tick {
animation: none;
}
}
/* off screen: endless animations hold still (the block sets data-off) */
.tprg-bar[data-off],
.tprg-bar[data-off] *,
.tprg-bar[data-off]::before,
.tprg-bar[data-off]::after,
.tprg-bar[data-off] ::before,
.tprg-bar[data-off] ::after,
.tprg-ring[data-off],
.tprg-ring[data-off] *,
.tprg-ring[data-off]::before,
.tprg-ring[data-off]::after,
.tprg-ring[data-off] ::before,
.tprg-ring[data-off] ::after {
animation-play-state: paused !important;
}
.tprg-demo { display: flex; flex-direction: column; align-items: center; gap: 20px; }
.tprg-demo-row { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; justify-content: center; }
.tprg-odo { display: inline-flex; height: 1.3em; line-height: 1.3; overflow: hidden; }
.tprg-sign { width: 0; overflow: hidden; }
.tprg-col { position: relative; width: 0.64em; height: 1.3em; animation: tprg-grow 0.3s cubic-bezier(0.3, 1.2, 0.4, 1); }
@keyframes tprg-grow { from { width: 0; } }
.tprg-strip { position: absolute; inset: 0 auto auto 0; width: 100%; display: flex; flex-direction: column; align-items: center; }
.tprg-strip > span { height: 1.3em; display: block; }
@media (prefers-reduced-motion: reduce) { .tprg-col { animation: none; } }
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { Progress } from "./Progress";
import "./Progress.css";
export default function Demo() {
return <Progress />;
}