# Zeno Progress: Loading Progress Bar for React

> A progress bar that closes half the remaining gap at every step, so it never reaches the end while work is still running. The last stretch becomes a ruler that keeps zooming into itself, and the gap snaps shut the moment the real result arrives.

Loading Progress Bar for React: it halves the gap until the result arrives. Built with Tailwind CSS and Motion; install it with the shadcn CLI. It's a KeyframeUI component: the file lands in your project as source, yours to edit.

- Docs and live preview: https://keyframeui.com/components/zeno-progress
- Tags: Progress, Loading, AI, Canvas
- npm dependencies: motion, clsx, tailwind-merge
- Inspired by: [Jujutsu Kaisen](https://jujutsukaisen.jp/)

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/zeno-progress.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/zeno-progress
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/zeno-progress.json
```

## Usage

The main demo shows typical usage.

### Image Generation

A prompt card. Generate runs Composing, Denoising and Upscaling while the model takes a random 4 to 9 seconds, so the bar is often deep in the ruler when the image arrives; the gap snaps shut and the image resolves in. Generate again for another take.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/zeno-progress-demo
```

```tsx title="components/zeno-progress-demo.tsx"
"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";

import { ZenoProgress } from "@/components/ui/zeno-progress";

const prompts = [
  {
    text: "Alpine ridge at first light, low cloud pooled in the valley, 35mm film",
    image: "1464822759023-fed622ff2c3b",
  },
  {
    text: "Still lake under a pine slope, late summer, soft overcast light",
    image: "1501785888041-af3ef285b470",
  },
  {
    text: "Snowy peaks under a clear night sky, long exposure, cold blue tones",
    image: "1519681393784-d120267933ba",
  },
];

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=960&h=600&q=80`;

type Phase = "idle" | "running" | "finishing" | "shown";

export function ZenoProgressDemo() {
  const [phase, setPhase] = React.useState<Phase>("idle");
  const [take, setTake] = React.useState(0);
  const prompt = prompts[take % prompts.length];

  // The "model" answers after 4–9 s; the bar only finishes when it does.
  React.useEffect(() => {
    if (phase !== "running") return;
    const timer = window.setTimeout(() => setPhase("finishing"), 4000 + Math.random() * 5000);
    return () => window.clearTimeout(timer);
  }, [phase, take]);

  // Load the next image while the bar runs, so it's ready for the reveal.
  React.useEffect(() => {
    const next = new Image();
    next.src = photo(prompt.image);
  }, [prompt.image]);

  const generate = () => {
    if (phase === "shown") setTake((t) => t + 1);
    setPhase("running");
  };

  const busy = phase === "running" || phase === "finishing";

  return (
    <div className="w-full max-w-md rounded-2xl border border-zinc-200 bg-white p-3 shadow-sm dark:border-zinc-800 dark:bg-zinc-950">
      <div className="relative aspect-[8/5] overflow-hidden rounded-xl bg-zinc-100 dark:bg-zinc-900">
        <AnimatePresence initial={false}>
          {phase === "shown" ? (
            <motion.img
              key={prompt.image}
              src={photo(prompt.image)}
              alt={prompt.text}
              className="absolute inset-0 size-full object-cover"
              initial={{ opacity: 0, scale: 1.06, filter: "blur(18px)" }}
              animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
              exit={{ opacity: 0, transition: { duration: 0.25 } }}
              transition={{ type: "spring", visualDuration: 0.7, bounce: 0 }}
            />
          ) : null}
        </AnimatePresence>
        <AnimatePresence initial={false}>
          {phase !== "shown" ? (
            <motion.div
              key="empty"
              className="absolute inset-0 flex items-end p-4"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0, transition: { duration: 0.3 } }}
            >
              <div className="w-full">
                {busy ? (
                  <ZenoProgress
                    key={take}
                    done={phase === "finishing"}
                    steps={["Composing", "Denoising", "Upscaling"]}
                    doneLabel="Ready"
                    interval={900}
                    onComplete={() => window.setTimeout(() => setPhase("shown"), 220)}
                  />
                ) : (
                  <p className="text-sm text-zinc-500 dark:text-zinc-400">Your image appears here.</p>
                )}
              </div>
            </motion.div>
          ) : null}
        </AnimatePresence>
      </div>

      <div className="mt-3 flex items-end gap-3 px-1 pb-1">
        <div className="min-w-0 flex-1">
          <p className="text-xs font-medium text-zinc-400 dark:text-zinc-500">Prompt</p>
          <p className="mt-1 text-sm leading-snug text-zinc-800 dark:text-zinc-200">{prompt.text}</p>
        </div>
        <button
          type="button"
          onClick={generate}
          disabled={busy}
          className="shrink-0 rounded-full bg-zinc-900 px-4 py-2 text-sm font-medium text-white transition-[opacity,transform] active:scale-[0.97] disabled:opacity-40 dark:bg-zinc-100 dark:text-zinc-900"
        >
          {phase === "shown" ? "Generate again" : "Generate"}
        </button>
      </div>
    </div>
  );
}
```

## Examples

Finished sections built with Zeno Progress. Each one installs whole, with the component included.

### Deploy Log

A deploy streams its build log under a bar in the header, labelled with the current phase. Waiting for the health check hangs for seconds with nothing new in the log, and the ruler zooms the whole time until the 200 lands.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/zeno-progress-deploy-demo
```

```tsx title="components/zeno-progress-deploy-demo.tsx"
"use client";

import * as React from "react";
import { AnimatePresence, motion, useInView } from "motion/react";

import { ZenoProgress } from "@/components/ui/zeno-progress";

type Line = { at: number; text: string; phase: string; tone?: "dim" | "ok" | "wait" };

// ms from the start of the run. The health check hangs on purpose: nothing
// arrives for seconds, and the bar's ruler zooms the whole time.
const script: Line[] = [
  { at: 300, text: "Cloning acme/storefront@8f3c2a1", phase: "Cloning" },
  { at: 900, text: "Restored build cache (412 MB)", phase: "Cloning", tone: "dim" },
  { at: 1500, text: "$ npm ci", phase: "Installing" },
  { at: 2300, text: "added 1284 packages in 2.7s", phase: "Installing", tone: "dim" },
  { at: 2900, text: "$ next build", phase: "Building" },
  { at: 3700, text: "✓ Compiled successfully in 2.9s", phase: "Building", tone: "ok" },
  { at: 4300, text: "✓ Generating static pages (42/42)", phase: "Building", tone: "ok" },
  { at: 4900, text: "Uploading build output (18.4 MB)", phase: "Uploading" },
  { at: 5600, text: "Starting 3 instances in iad1", phase: "Starting" },
  { at: 6200, text: "Waiting for health check…", phase: "Waiting for health check", tone: "wait" },
  { at: 13800, text: "✓ GET /api/health 200 in 41ms", phase: "Waiting for health check", tone: "ok" },
];
const DONE_AT = 14300;

const toneClass = {
  dim: "text-zinc-500",
  ok: "text-emerald-400",
  wait: "text-amber-300",
} as const;

export function ZenoProgressDeployDemo() {
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, amount: 0.4 });
  const [run, setRun] = React.useState(0);
  const [elapsed, setElapsed] = React.useState(0);

  React.useEffect(() => {
    if (!inView) return;
    const start = performance.now();
    const timer = window.setInterval(() => {
      const t = performance.now() - start;
      setElapsed(Math.min(t, DONE_AT));
      if (t >= DONE_AT) window.clearInterval(timer);
    }, 100);
    return () => window.clearInterval(timer);
  }, [inView, run]);

  const lines = script.filter((line) => line.at <= elapsed);
  const done = elapsed >= DONE_AT;
  const phase = lines.at(-1)?.phase ?? "Queued";

  const redeploy = () => {
    setElapsed(0);
    setRun((r) => r + 1);
  };

  return (
    <div
      ref={ref}
      className="w-full max-w-xl overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950 font-mono text-[13px] text-zinc-300 shadow-sm"
    >
      <div className="border-b border-zinc-800/80 px-4 pt-3.5 pb-3">
        <div className="mb-3 flex items-center justify-between gap-3 font-sans">
          <div className="flex min-w-0 items-center gap-2">
            <span className="size-2 shrink-0 rounded-full bg-emerald-400/90" aria-hidden />
            <span className="truncate text-sm font-medium text-zinc-100">storefront</span>
            <span className="shrink-0 rounded-full border border-zinc-800 px-2 py-0.5 text-xs text-zinc-400">
              production
            </span>
          </div>
          <button
            type="button"
            onClick={redeploy}
            disabled={!done}
            className="shrink-0 rounded-full border border-zinc-700 px-3 py-1 text-xs font-medium text-zinc-200 transition-[opacity,transform] hover:bg-zinc-900 active:scale-[0.97] disabled:opacity-40"
          >
            Redeploy
          </button>
        </div>
        <ZenoProgress
          key={inView ? "live" : "idle"}
          done={done}
          label={phase}
          doneLabel="Deployed to storefront.acme.app"
          interval={1500}
          className="font-sans text-zinc-400"
          barClassName="text-zinc-100"
        />
      </div>

      <div className="flex h-64 flex-col justify-end overflow-hidden px-4 py-3 leading-6">
        <AnimatePresence initial={false}>
          {lines.map((line) => (
            <motion.div
              key={`${run}-${line.at}`}
              layout="position"
              initial={{ opacity: 0, y: 6 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ type: "spring", visualDuration: 0.3, bounce: 0 }}
              className={`truncate ${line.tone ? toneClass[line.tone] : ""}`}
            >
              {line.text}
            </motion.div>
          ))}
          {done ? (
            <motion.div
              key={`${run}-done`}
              layout="position"
              initial={{ opacity: 0, y: 6 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ type: "spring", visualDuration: 0.3, bounce: 0 }}
              className="truncate text-zinc-100"
            >
              ▲ Deployed in 14.3s
            </motion.div>
          ) : null}
        </AnimatePresence>
      </div>
    </div>
  );
}
```

### Upload List

Three files, each with its own bar, finishing at different times. The video's virus scan takes much longer, so you can watch its ruler go many decimals deep while the others are done.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/zeno-progress-upload-demo
```

```tsx title="components/zeno-progress-upload-demo.tsx"
"use client";

import * as React from "react";
import { useInView } from "motion/react";

import { cn } from "@/lib/utils";
import { ZenoProgress } from "@/components/ui/zeno-progress";

const files = [
  { name: "brand-guidelines.pdf", size: "4.2 MB", kind: "PDF", ms: 2600, interval: 600 },
  { name: "hero-loop.mp4", size: "86.1 MB", kind: "MP4", ms: 21000, interval: 900 },
  { name: "Q3-review.key", size: "18.9 MB", kind: "KEY", ms: 6400, interval: 750 },
];

export function ZenoProgressUploadDemo() {
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, amount: 0.4 });
  const [batch, setBatch] = React.useState(0);
  const [finished, setFinished] = React.useState<string[]>([]);

  // Each file's server answers on its own schedule; the video takes far longer.
  React.useEffect(() => {
    if (!inView) return;
    const timers = files.map((file) =>
      window.setTimeout(() => setFinished((names) => [...names, file.name]), file.ms),
    );
    return () => timers.forEach((timer) => window.clearTimeout(timer));
  }, [inView, batch]);

  const allDone = finished.length === files.length;

  const again = () => {
    setFinished([]);
    setBatch((b) => b + 1);
  };

  return (
    <div
      ref={ref}
      className="w-full max-w-md rounded-2xl border border-zinc-200 bg-white p-2 shadow-sm dark:border-zinc-800 dark:bg-zinc-950"
    >
      <div className="flex items-center justify-between px-3 pt-2 pb-3">
        <div>
          <p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">Uploads</p>
          <p className="text-xs text-zinc-500 dark:text-zinc-400">
            {finished.length} of {files.length} ready
          </p>
        </div>
        <button
          type="button"
          onClick={again}
          disabled={!allDone}
          className="rounded-full border border-zinc-200 px-3 py-1 text-xs font-medium text-zinc-700 transition-[opacity,transform] hover:bg-zinc-50 active:scale-[0.97] disabled:opacity-40 dark:border-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-900"
        >
          Upload again
        </button>
      </div>

      <ul className="space-y-1">
        {files.map((file) => {
          const done = finished.includes(file.name);
          return (
            <li
              key={file.name}
              className="flex items-center gap-3 rounded-xl bg-zinc-50 px-3 py-3 dark:bg-zinc-900/60"
            >
              <span
                aria-hidden
                className={cn(
                  "grid size-9 shrink-0 place-items-center rounded-lg border text-[10px] font-semibold tracking-wide transition-colors duration-300",
                  done
                    ? "border-zinc-900 bg-zinc-900 text-white dark:border-zinc-100 dark:bg-zinc-100 dark:text-zinc-900"
                    : "border-zinc-200 bg-white text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400",
                )}
              >
                {file.kind}
              </span>
              <div className="min-w-0 flex-1">
                <div className="mb-1 flex items-baseline justify-between gap-3">
                  <p className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">{file.name}</p>
                  <p className="shrink-0 text-xs text-zinc-400 dark:text-zinc-500">{file.size}</p>
                </div>
                <ZenoProgress
                  key={`${batch}-${inView}`}
                  done={done}
                  steps={["Uploading", "Scanning for viruses"]}
                  doneLabel="Uploaded"
                  interval={file.interval}
                  className="text-xs text-zinc-500 dark:text-zinc-400"
                />
              </div>
            </li>
          );
        })}
      </ul>
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `done` | `boolean` | `false` | The work is finished. The bar only reaches the end when this turns true, from wherever it is: the gap snaps shut on a spring that never overshoots. Turning it false again rewinds the bar and starts a new run. |
| `steps` | `string[]` |  | Labels that take turns on the left as the fill moves, sliding up into place. They're spread over the run up to 90%, so the last one holds through the crawl. |
| `label` | `string` | `"Working"` | A fixed label while running, for when your own code knows the phase. Overrides `steps`. |
| `doneLabel` | `string` | `"Done"` | Label once `done` is true. It's also announced to screen readers. |
| `interval` | `number` | `1000` | Milliseconds for each halving of the gap. The fill passes 90% after about 3.7 intervals and gains a decimal every 3.3 after that. |
| `snapDuration` | `number` | `0.36` | Seconds the gap takes to snap shut once `done` arrives, on a spring that never overshoots. The ticks flash as it lands. |
| `ruler` | `boolean` | `true` | Past 90%, turn the end of the track into a magnifier: the gap is re-scaled to fill it, ticks stream out of the end point and the percentage gains decimals. Off, the bar just crawls. |
| `onComplete` | `() => void` |  | Called once the fill reaches the end after `done`, as the ticks flash. Use it to reveal the result. |
| `barClassName` | `string` | `"text-zinc-900 dark:text-zinc-100"` | Classes that set the bar's color (a `text-*` color). The track and the ruler's ticks are drawn from it. |
| `className` | `string` |  | Classes for the root. Its text size and color style the labels. |

## What to keep

To adapt it, change its props first. When you do edit `components/ui/zeno-progress.tsx`, keep these, which make it feel and behave right:

- One rAF loop draws the bar on a canvas and writes the percentage and `aria-valuenow` straight to the DOM, so a run never re-renders except when the step label changes.
- The gap is tracked as log10 and only ever halves, so the fill never reaches the end on its own; only `done` closes it, on a spring with `bounce: 0` that never overshoots.
- The zoom window freezes when `done` arrives, so the snap runs through the magnified gap.
- The percentage is floored and never reads 100% until the fill has landed.
- Reduced motion drops the ruler and zoom: past 90% the track breathes instead, `done` jumps the fill and labels only fade.

Its timing (durations, delays, springs and easings) is all in the `TIMING` block at the top of the file; the timing props default to it.

## Source

```tsx title="components/ui/zeno-progress.tsx"
"use client";

import * as React from "react";
import {
  AnimatePresence,
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
} from "motion/react";

import { cn } from "@/lib/utils";

/**
 * Keep:
 * - One rAF loop draws the bar on a canvas and writes the percentage and `aria-valuenow` straight
 *   to the DOM, so a run never re-renders except when the step label changes.
 * - The gap is tracked as log10 and only ever halves, so the fill never reaches the end on its
 *   own; only `done` closes it, on a spring with `bounce: 0` that never overshoots.
 * - The zoom window freezes when `done` arrives, so the snap runs through the magnified gap.
 * - The percentage is floored and never reads 100% until the fill has landed.
 * - Reduced motion drops the ruler and zoom: past 90% the track breathes instead, `done` jumps the
 *   fill and labels only fade.
 */
const TIMING = {
  /** Each halving of the gap, in ms (the `interval` prop). */
  interval: 1000,
  /** Halvings the fill takes to get up to speed from a standstill. */
  rampUp: 0.35,
  /** The gap closing when done arrives, over `snapDuration`. */
  snap: { type: "spring", bounce: 0 },
  /** How long the snap takes, in seconds (the `snapDuration` prop, the spring's visualDuration). */
  snapDuration: 0.36,
  /** The ticks flashing and sinking after the snap, in seconds. */
  clear: 0.62,
  /** Labels sliding. */
  roll: { type: "spring", visualDuration: 0.34, bounce: 0 },
  /** Labels fading under reduced motion, in seconds. */
  fade: { duration: 0.16 },
  /** The fill heading back for a new run, in seconds, and its curve. */
  rewind: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
  /** One breath of the reduced-motion gap, in seconds. */
  breathe: 2.8,
} as const;

/* ─────────────────────────────────────────────────────────
 * ZENO PROGRESS STORYBOARD
 *
 *  run       the fill closes half of what's left every
 *            `interval`, easing out of a standstill, so it
 *            reads as steady work early and a crawl late. It
 *            never reaches the end on its own
 *  ruler     past ~90% the gap grows a ruler: ticks rise out
 *            of the track across the last stretch
 *  zoom      the last stretch becomes a magnifier with a fixed
 *            width. The gap is re-scaled to fill most of it,
 *            so the ruler zooms in toward the end point at a
 *            steady rate: ticks stream out from the end, grow
 *            as they spread, a finer tier rises between them
 *            and the fill eats them as they pass its edge. The
 *            label gains a decimal every tenfold zoom
 *  done      the zoom holds, the fill runs through the gap to
 *            the end on a spring with no overshoot, the ticks
 *            flash once and sink, and the label rolls to the
 *            done text
 *  again     `done` back to false: the fill rewinds to the
 *            start and a new run begins
 *  reduced   no ruler or zoom. Past 90% the gap breathes
 *            slowly instead, and done is a plain jump
 * ───────────────────────────────────────────────────────── */

const BAR = {
  height: 6, // px track
  canvas: 22, // px drawing area: ticks reach above and below the track
  tick: [4, 18], // px tick height, finest to tallest
  minSpacing: 2.5, // px between ticks before a finer tier shows
  tallSpacing: 90, // px between ticks at which they reach full height
  eat: 18, // px past the fill's edge over which ticks are eaten
  lens: [0.24, 48, 160], // magnifier: share of the track, min px, max px
  gapShare: 0.55, // share of the magnifier the zoomed gap keeps
  rulerFrom: 0.12, // gap left when the ruler starts to rise
  rulerTo: 0.07, // gap left when it's fully up
  maxDecimals: 6,
} as const;

const LOG2 = Math.log10(2);
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const smoothstep = (a: number, b: number, v: number) => {
  const t = clamp((v - a) / (b - a), 0, 1);
  return t * t * (3 - 2 * t);
};

/** Percent left to go, shown at a precision that keeps two digits of it moving. */
function formatPercent(logGap: number, maxDecimals: number) {
  const decimals = clamp(Math.floor(-(logGap + 2)) + 2, 0, maxDecimals);
  const f = 10 ** decimals;
  let shown = Math.floor((100 - 100 * 10 ** logGap) * f) / f;
  // Floats round 99.99999999… up to 100; never claim the end early.
  if (shown >= 100) shown = 100 - 1 / f;
  return { text: `${shown.toFixed(decimals)}%`, decimals };
}

/** Halvings of the gap so far, easing out of a standstill. */
const halvingsAt = (ms: number, interval: number) => {
  const t = Math.max(0, ms / interval);
  return (t * t) / (t + TIMING.rampUp);
};

/** Width of the magnifier at the end of a track this wide. */
const lensOf = (width: number) => clamp(width * BAR.lens[0], BAR.lens[1], BAR.lens[2]);

/**
 * The window the lens shows, as log10 of a share of the bar: the lens at 1:1,
 * then the gap over `gapShare` of it, joined by a soft minimum so the zoom
 * eases in instead of starting with a kink.
 */
function zoomFor(gap: number, width: number, zooming: boolean) {
  const z0 = Math.log10(lensOf(width) / Math.max(width, 1));
  if (!zooming) return z0;
  const u = gap - Math.log10(BAR.gapShare) - z0;
  return z0 + u - Math.log10(1 + 10 ** (3 * u)) / 3;
}

type Geometry = { width: number; dpr: number };

/** Everything a frame needs, kept out of React so a run never re-renders. */
type Run = {
  /** performance.now() the run began; null until its first frame. */
  start: number | null;
  /** log10 of the gap and the zoom window at the moment `done` arrived. */
  doneGap: number | null;
  doneZoom: number;
  doneDecimals: number;
  /** When the fill reached the end, for the ticks' flash. */
  landed: number | null;
  completed: boolean;
  /** Ruler height when done arrived, so an early finish has no ticks to flash. */
  doneRuler: number;
  step: number;
  value: number;
  text: string;
};

const freshRun = (): Run => ({
  start: null,
  doneGap: null,
  doneZoom: 0,
  doneDecimals: 0,
  landed: null,
  completed: false,
  doneRuler: 0,
  step: 0,
  value: 0,
  text: "0%",
});

export interface ZenoProgressProps {
  /** The work is finished. The parent owns it: the bar only gets there when this turns true. */
  done?: boolean;
  /** Labels shown in turn as the fill moves. The last one holds through the crawl. */
  steps?: string[];
  /** Fixed label while running. Overrides `steps`. */
  label?: string;
  /** Label once done. */
  doneLabel?: string;
  /** Milliseconds for each halving of the gap. */
  interval?: number;
  /** Seconds the gap takes to snap shut once `done` arrives, on a spring that never overshoots. */
  snapDuration?: number;
  /** Zoom into the gap with a ruler past 90%. Off, the bar just crawls. */
  ruler?: boolean;
  /** Called once the fill reaches the end after `done`. */
  onComplete?: () => void;
  /** Classes that set the bar's color (a `text-*` color). The track and ticks are drawn from it. */
  barClassName?: string;
  className?: string;
}

export function ZenoProgress({
  done = false,
  steps,
  label,
  doneLabel = "Done",
  interval = TIMING.interval,
  snapDuration = TIMING.snapDuration,
  ruler = true,
  onComplete,
  barClassName,
  className,
}: ZenoProgressProps) {
  const reduced = useReducedMotion() ?? false;
  const rootRef = React.useRef<HTMLDivElement>(null);
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const percentRef = React.useRef<HTMLSpanElement>(null);

  const snap = useMotionValue(done ? 1 : 0);
  const rewind = useMotionValue(0);
  const [step, setStep] = React.useState(0);
  // A new run starts back on the first step.
  const [lastDone, setLastDone] = React.useState(done);
  if (lastDone !== done) {
    setLastDone(done);
    if (!done) setStep(0);
  }

  const stepCount = steps?.length ?? 0;
  const options = React.useRef({ interval, snapDuration, ruler, reduced, stepCount, onComplete });
  React.useEffect(() => {
    options.current = { interval, snapDuration, ruler, reduced, stepCount, onComplete };
  });

  const run = React.useRef<Run>(freshRun());
  const geometry = React.useRef<Geometry>({ width: 0, dpr: 1 });
  const color = React.useRef("#000");
  const frame = React.useRef(0);
  const wake = React.useRef<() => void>(() => {});
  const seenDone = React.useRef<boolean | null>(null);

  // The frame loop: reads the clock and the springs, draws the bar and writes the label.
  React.useEffect(() => {
    const canvas = canvasRef.current;
    const root = rootRef.current;
    const percent = percentRef.current;
    if (!canvas || !root || !percent) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    let colorAge = Infinity;
    const readColor = () => {
      // Passed to the canvas as-is: Tailwind's colors are oklch().
      color.current = getComputedStyle(canvas).color;
    };

    const draw = (now: number) => {
      frame.current = 0;
      const { interval, ruler, reduced, stepCount, onComplete } = options.current;
      const r = run.current;
      const { width: W, dpr } = geometry.current;
      if (r.start === null) r.start = now;

      // Gap left, in log10 so it can shrink past what a float can hold.
      const halvings = halvingsAt(now - r.start, interval);
      const liveGap = -halvings * LOG2;

      const lens = lensOf(W);
      const z0 = Math.log10(lens / Math.max(W, 1));
      const zooming = ruler && !reduced;

      const s = snap.get();
      let gap: number;
      let zoom: number;
      if (r.doneGap === null) {
        gap = liveGap;
        zoom = zoomFor(gap, W, zooming);
      } else {
        gap = s >= 1 ? -Infinity : r.doneGap + Math.log10(1 - s);
        zoom = r.doneZoom;
      }
      const g = 10 ** gap;
      const z = 10 ** zoom;

      // Fill edge: the main stretch shows [0, 1 - z], the lens shows [1 - z, 1].
      const split = W - lens;
      const edge = g < z ? split + lens * (1 - g / z) : ((1 - g) / Math.max(1 - z, 1e-9)) * split;
      const fill = Math.max(edge, rewind.get() * W);

      // The ruler rises with the fill and, after done, flashes and sinks.
      const liveRuler = zooming ? smoothstep(BAR.rulerFrom, BAR.rulerTo, 10 ** liveGap) : 0;
      const rulerUp = r.doneGap === null ? liveRuler : r.doneRuler;
      if (r.doneGap !== null && s > 0.985 && r.landed === null) r.landed = now;
      const clearing = r.landed === null ? 0 : clamp((now - r.landed) / 1000 / TIMING.clear, 0, 1);
      const flash = clearing < 0.22 ? clearing / 0.22 : 1 - (clearing - 0.22) / 0.78;
      const sink = 1 - clearing * clearing;

      // Label and value.
      let text: string;
      let value: number;
      if (r.doneGap !== null && (r.landed !== null || s >= 1)) {
        text = "100%";
        value = 100;
      } else {
        const max = reduced ? 0 : r.doneGap === null ? BAR.maxDecimals : r.doneDecimals;
        text = formatPercent(gap, max).text;
        value = Math.floor(100 * (1 - g));
      }
      if (text !== r.text) {
        r.text = text;
        percent.textContent = text;
      }
      if (value !== r.value) {
        r.value = value;
        root.setAttribute("aria-valuenow", String(value));
      }
      if (stepCount > 1 && r.doneGap === null) {
        const next = Math.min(stepCount - 1, Math.floor(halvings / (Math.log2(10) / (stepCount - 1))));
        if (next !== r.step) {
          r.step = next;
          setStep(next);
        }
      }
      if (r.landed !== null && !r.completed) {
        r.completed = true;
        onComplete?.();
      }

      // Draw.
      if (colorAge++ > 30) {
        readColor();
        colorAge = 0;
      }
      const H = BAR.canvas;
      const cy = H / 2;
      const T = BAR.height;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, W, H);
      if (W <= 0) return;

      const breathing = reduced && r.doneGap === null && 10 ** liveGap < 0.1;
      const breath = breathing ? 0.5 - 0.5 * Math.cos(((now - r.start) / 1000 / TIMING.breathe) * Math.PI * 2) : 0;
      ctx.save();
      ctx.beginPath();
      ctx.roundRect(0, cy - T / 2, W, T, T / 2);
      ctx.clip();
      ctx.fillStyle = color.current;
      ctx.globalAlpha = 0.12 + 0.16 * breath;
      ctx.fillRect(0, 0, W, H);
      ctx.globalAlpha = 1;
      ctx.fillRect(0, 0, fill, H);
      ctx.restore();

      const height = rulerUp * sink;
      if (height > 0.001) {
        const windowPx = lens / (z * 100); // px per percent inside the lens
        // Tiers like a ruler's: tenths, halves, wholes, then the next decade up.
        // A tick belongs to the coarsest tier it lands on, and its height and
        // opacity follow that tier's spacing, so every tick grows continuously.
        const decade = Math.floor(Math.log10(BAR.minSpacing / windowPx));
        const tiers: number[] = [];
        for (let level = decade; level <= decade + 3; level++) tiers.push(10 ** level, 5 * 10 ** level);
        const marks: { x: number; h: number; a: number }[] = [];
        tiers.forEach((unit, k) => {
          const spacing = unit * windowPx;
          if (spacing < BAR.minSpacing || spacing > lens) return;
          const every = k + 1 < tiers.length ? Math.round(tiers[k + 1] / unit) : Infinity;
          const grown = clamp(Math.log10(spacing / BAR.minSpacing) / Math.log10(BAR.tallSpacing / BAR.minSpacing), 0, 1);
          const h = BAR.tick[0] + (BAR.tick[1] - BAR.tick[0]) * grown;
          const a = smoothstep(BAR.minSpacing, BAR.minSpacing * 3, spacing);
          for (let j = 1; j * spacing <= lens + 0.5; j++) {
            if (j % every !== 0) marks.push({ x: W - j * spacing, h, a });
          }
        });
        marks.push({ x: W - 0.75, h: BAR.tick[1], a: 1 }); // the end post
        // The lens' left edge, cut into the fill once the zoom is under way.
        const zoomed = smoothstep(0, 0.3, z0 - zoom);

        ctx.fillStyle = color.current;
        for (const m of marks) {
          if (m.x < split - 0.5) continue;
          const eaten = m.x <= fill ? 1 - clamp((fill - m.x) / BAR.eat, 0, 1) : 1;
          // Over the fill only the ends that stick out of the track show.
          const alpha = m.a * Math.max(0.6 * eaten, flash);
          if (alpha <= 0.01) continue;
          const h = m.h * height;
          ctx.globalAlpha = alpha;
          ctx.fillRect(Math.round(m.x * dpr) / dpr - 0.5, cy - h / 2, 1, h);
        }
        if (zoomed > 0.01 && fill > split) {
          ctx.globalCompositeOperation = "destination-out";
          ctx.globalAlpha = zoomed * (1 - clearing);
          ctx.fillRect(Math.round(split * dpr) / dpr - 1, cy - T / 2, 1.5, T);
        }
        ctx.globalCompositeOperation = "source-over";
        ctx.globalAlpha = 1;
      }

      const settled = r.doneGap !== null && (r.landed !== null ? clearing >= 1 : false) && rewind.get() === 0;
      if (!settled) frame.current = requestAnimationFrame(draw);
    };

    wake.current = () => {
      if (!frame.current) frame.current = requestAnimationFrame(draw);
    };

    const observer = new ResizeObserver(([entry]) => {
      const dpr = window.devicePixelRatio || 1;
      const width = entry.contentRect.width;
      geometry.current = { width, dpr };
      canvas.width = Math.round(width * dpr);
      canvas.height = Math.round(BAR.canvas * dpr);
      colorAge = Infinity;
      wake.current();
    });
    observer.observe(canvas);
    wake.current();

    return () => {
      observer.disconnect();
      cancelAnimationFrame(frame.current);
      frame.current = 0;
      wake.current = () => {};
    };
  }, [snap, rewind]);

  // `done` drives the run: arriving snaps the gap shut, leaving starts a new run.
  React.useEffect(() => {
    const previous = seenDone.current;
    seenDone.current = done;
    const r = run.current;
    if (previous === done) return;

    if (done) {
      if (previous === null) {
        // Mounted finished: show the full bar, nothing to animate.
        Object.assign(r, { doneGap: -Infinity, landed: -Infinity, completed: true, text: "100%", value: 100 });
        snap.jump(1);
        if (percentRef.current) percentRef.current.textContent = "100%";
        wake.current();
        return;
      }
      const { interval, snapDuration, ruler, reduced } = options.current;
      const gap = r.start === null ? 0 : -halvingsAt(performance.now() - r.start, interval) * LOG2;
      r.doneZoom = zoomFor(gap, geometry.current.width, ruler && !reduced);
      r.doneGap = gap;
      r.doneDecimals = formatPercent(gap, reduced ? 0 : BAR.maxDecimals).decimals;
      r.doneRuler = ruler && !reduced ? smoothstep(BAR.rulerFrom, BAR.rulerTo, 10 ** gap) : 0;
      if (reduced) snap.jump(1);
      else animate(snap, 1, { ...TIMING.snap, visualDuration: snapDuration });
      wake.current();
      return;
    }

    // A new run. Rewind from wherever the last one left the fill.
    const from = previous === null ? 0 : r.doneGap === null ? 0 : 1;
    run.current = freshRun();
    snap.jump(0);
    if (percentRef.current) percentRef.current.textContent = "0%";
    rootRef.current?.setAttribute("aria-valuenow", "0");
    if (from > 0 && !options.current.reduced) {
      rewind.jump(from);
      animate(rewind, 0, TIMING.rewind);
    } else {
      rewind.jump(0);
    }
    wake.current();
  }, [done, snap, rewind]);

  const text = done ? doneLabel : (label ?? steps?.[Math.min(step, stepCount - 1)] ?? "Working");
  const slide = reduced ? { opacity: 0 } : { y: "100%", opacity: 0 };
  const leave = reduced ? { opacity: 0 } : { y: "-100%", opacity: 0 };

  return (
    <div
      ref={rootRef}
      role="progressbar"
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={done ? 100 : 0}
      aria-valuetext={text}
      aria-busy={!done}
      className={cn("w-full text-sm text-zinc-600 dark:text-zinc-400", className)}
    >
      <div className="flex items-baseline justify-between gap-4">
        <span className="relative h-[1lh] min-w-0 flex-1 overflow-hidden">
          <AnimatePresence initial={false} mode="popLayout">
            <motion.span
              key={text}
              className="block truncate"
              initial={slide}
              animate={{ y: 0, opacity: 1 }}
              exit={leave}
              transition={reduced ? TIMING.fade : TIMING.roll}
            >
              {text}
            </motion.span>
          </AnimatePresence>
        </span>
        <span ref={percentRef} aria-hidden className="shrink-0 font-medium tabular-nums text-zinc-900 dark:text-zinc-100">
          0%
        </span>
      </div>
      <canvas
        ref={canvasRef}
        aria-hidden
        className={cn("mt-1 block h-[22px] w-full text-zinc-900 dark:text-zinc-100", barClassName)}
      />
      <span className="sr-only" aria-live="polite">
        {done ? doneLabel : ""}
      </span>
    </div>
  );
}
```
