## Progress Bar — Async Indeterminate handing over to determinate. Docs: https://www.interior.dev/docs/progress-bar Reference: https://www.interior.dev/reference/progress-bar License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/progress-bar.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { ProgressBar } from "@/components/interior/progress-bar"; export function AssetUpload({ file }: { file: File }) { const [sent, setSent] = useState(null); async function upload() { setSent(null); const xhr = new XMLHttpRequest(); xhr.upload.addEventListener("progress", (e) => { if (e.lengthComputable) setSent((e.loaded / e.total) * 100); }); xhr.addEventListener("load", () => setSent(100)); xhr.open("POST", "/api/assets"); xhr.send(file); } return (
); } ``` ### Props - `value` (`number | null`) The measured amount. `null` means the total is not known yet and the bar runs its indeterminate crawl. - `max` (`number`) — default: `100` Upper bound for `value`. Also the reported `aria-valuemax`. - `segments` (`number`) — default: `24` How many cells the bar is divided into. Progress is quantized to these, so the bar and the readout can never disagree. - `ceiling` (`number`) — default: `0.35` The fraction the indeterminate crawl converges on, clamped to 0.05–0.95. Keep it low so the real value rarely lands behind it. - `crawl` (`number`) — default: `2200` Time constant of the crawl in milliseconds. It reaches ~63% of `ceiling` after this long, then flattens. - `label` (`string`) — default: `"Progress"` Names the bar for sighted users and, through `aria-labelledby`, for screen readers. - `pendingLabel` (`string`) — default: `"Working"` Readout shown while `value` is `null`, in the same grid cell as the percentage. - `completeLabel` (`string`) — default: `"Complete"` Announced once through a polite live region when the bar fills. - `className` (`string`) — default: `""` Appended last, so any layout or width class from the caller wins. ### Behavior notes - The bar is monotone: the position reached while the total was unknown becomes the floor, so the handover from indeterminate to determinate can never snap backwards or restart at zero. - The indeterminate phase is not an idle loop. It is an exponential crawl toward a low ceiling that converges, cancels its own frame loop, and leaves nothing running once it has flattened. - Progress is quantized to whole cells before anything is drawn, so React re-renders once per lit cell rather than once per frame, and the percentage in the readout is always the exact quantity the bar is showing. - The percentage and the pending label occupy the same grid cell and every cell of the track is present from the first paint, so nothing on the row moves as the state changes. - Under `prefers-reduced-motion` the crawl is skipped rather than faked: the bar stays honest at its last known amount, the label still says work is happening, and real values land without a spring. - While the total is unknown the element carries `role="progressbar"` with no `aria-valuenow`, which is the ARIA spelling of indeterminate; the completion message reaches a screen reader once, not on every step. ### Source (`components/interior/progress-bar.tsx`) ```tsx "use client"; import type { AriaAttributes } from "react"; import { useId } from "react"; import { motion, useReducedMotion } from "motion/react"; const FILL = { type: "spring", stiffness: 210, damping: 34, mass: 0.9 } as const; const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; const INSTANT = { duration: 0 } as const; export type ProgressBarProps = { value: number | null; max?: number; label?: string; pendingLabel?: string; completeLabel?: string; className?: string; }; export function ProgressBar({ value, max = 100, label = "Progress", pendingLabel = "Working", completeLabel = "Complete", className = "", }: ProgressBarProps) { const reduced = useReducedMotion(); const labelId = useId(); const indeterminate = value === null; const fraction = value === null || max <= 0 ? 0 : Math.min(1, Math.max(0, value / max)); const percent = Math.round(fraction * 100); const complete = !indeterminate && fraction >= 1; const measured: AriaAttributes = indeterminate ? {} : { "aria-valuenow": Math.round(fraction * max * 100) / 100, "aria-valuetext": `${percent}%`, }; return (
{label} {pendingLabel} {percent}%
{indeterminate && !reduced ? ( ) : null}
{complete ? completeLabel : indeterminate ? pendingLabel : ""}
); } ```