diff --git a/docs/app/components/demos/progress-demo.tsx b/docs/app/components/demos/progress-demo.tsx
new file mode 100644
index 00000000..443c6aca
--- /dev/null
+++ b/docs/app/components/demos/progress-demo.tsx
@@ -0,0 +1,447 @@
+import { useCallback, useEffect, useReducer, useRef, useState } from "react";
+import { useRafLoop, useReducedMotion } from "./lib";
+import type { DemoProps } from "./types";
+
+/*
+ * Progress-streaming demo — the heavy-processing scenario. A big upload is handed
+ * off with .delay(); the endpoint returns 202 in milliseconds while a worker
+ * crunches the file chunk by chunk, calling progress.update() per chunk so the
+ * caller's UI shows a live % bar. SVG scene + RAF simulation.
+ */
+
+const W = 940;
+const H = 270;
+const RESPONSE_MS = 3; // the instant hand-off — the whole point of .delay()
+const CHUNK_MS = 180; // wall time the worker spends per chunk
+
+interface SizeDef {
+ n: number;
+ label: string;
+}
+const SIZES: Record<"small" | "large", SizeDef> = {
+ small: { n: 16, label: "12 MB" },
+ large: { n: 40, label: "60 MB" },
+};
+
+interface FlowNode {
+ x: number;
+ w: number;
+ title: string;
+ sub: string;
+}
+const NODE_Y = 24;
+const NODE_H = 54;
+const NODE_W = 190;
+const NODES: FlowNode[] = [
+ { x: 24, w: NODE_W, title: "POST /process", sub: "your endpoint" },
+ { x: 375, w: NODE_W, title: "taskito queue", sub: "buffers the job" },
+ { x: 726, w: NODE_W, title: "worker", sub: "" },
+];
+
+// Chunk grid geometry — laid out left-to-right, wrapping at COLS_MAX per row.
+const GRID_X = 24;
+const GRID_Y = 202;
+const GRID_W = W - 48;
+const COLS_MAX = 20;
+const CELL_H = 16;
+const CELL_GAP = 6;
+function gridCell(i: number, n: number) {
+ const cols = Math.min(n, COLS_MAX);
+ const cellW = (GRID_W - (cols - 1) * CELL_GAP) / cols;
+ const col = i % cols;
+ const row = Math.floor(i / cols);
+ return {
+ x: GRID_X + col * (cellW + CELL_GAP),
+ y: GRID_Y + row * (CELL_H + CELL_GAP),
+ w: cellW,
+ };
+}
+
+export default function ProgressDemo(_props: DemoProps) {
+ const reduced = useReducedMotion();
+ const [size, setSize] = useState<"small" | "large">("large");
+ const [paused, setPaused] = useState(false);
+ const tRef = useRef(0); // virtual sim time (ms), advances only while running
+ const lastRef = useRef(0); // last wall-clock RAF stamp, for real dt
+ const [, repaint] = useReducer((n: number) => n + 1, 0);
+
+ const n = SIZES[size].n;
+ const total = n * CHUNK_MS;
+
+ const tick = useCallback(
+ (wall: number) => {
+ if (!lastRef.current) lastRef.current = wall;
+ const dt = Math.min(wall - lastRef.current, 100);
+ lastRef.current = wall;
+ tRef.current = Math.min(total, tRef.current + dt);
+ repaint();
+ },
+ [total],
+ );
+
+ // Reset to the start on size change (total changes with size); reduced motion
+ // shows the finished frame.
+ useEffect(() => {
+ tRef.current = reduced ? total : 0;
+ lastRef.current = 0;
+ repaint();
+ }, [reduced, total]);
+
+ const finished = tRef.current >= total;
+ useRafLoop(tick, !paused && !reduced && !finished);
+
+ const restart = () => {
+ // Under reduced motion the RAF loop never runs, so show the finished frame.
+ tRef.current = reduced ? total : 0;
+ lastRef.current = 0;
+ setPaused(false);
+ repaint();
+ };
+ const togglePause = () => {
+ // Drop the stale wall stamp so resume doesn't jump by the paused gap.
+ lastRef.current = 0;
+ setPaused((p) => !p);
+ };
+
+ const t = tRef.current;
+ const done = Math.min(n, Math.floor(t / CHUNK_MS));
+ const frac = finished ? 1 : (done + (t % CHUNK_MS) / CHUNK_MS) / n;
+ const pct = Math.round(frac * 100);
+ const elapsed = (t / 1000).toFixed(1);
+ const workerSub = finished
+ ? "result ready"
+ : t === 0
+ ? "idle"
+ : `chunk ${done + 1} / ${n}`;
+
+ return (
+
+
+
+
+ upload · handed off with .delay()
+
+
+ {/* biome-ignore lint/a11y/useSemanticElements: segmented toggle of aria-pressed buttons; a fieldset/legend would fight the inline-flex .seg styling */}
+