Teseria
All elements

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

Terminal
npm i framer-motion lucide-react

2. Files

Progress.tsx291 lines
"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.css153 lines
/* 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; } }
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { Progress } from "./Progress";
import "./Progress.css";

export default function Demo() {
  return <Progress />;
}