Progress
How far a task has got, as an ink fill that slides — or a sweep, while the end is unknown.
The idea
Not a Meter, and the difference is the point. A meter reports a measurement that already exists, on the value scale, where the colour says whether that much is good. Progress reports something still happening: it only ever means done-so-far, so it is plain ink, and it has a state a measurement never has — running with no known end. The fill slides by a transform on expo-out and never springs, because a bar that overshoots claims, for a moment, more done than is.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Progress } from "mihirmodi-ui";
<Progress label="Uploading plates" value={percent} showLabel />
<Progress
label="Importing statements"
value={done}
max={8}
format={(v, max) => `${v} of ${max} files`}
showLabel
/>
// No value: the end is not known yet, so it sweeps.
<Progress label="Preparing the export" />Props
| Prop | Type | Notes |
|---|---|---|
| valuenumber | null | number | null | How far along. Leave it out while the end is not known, for the sweep. |
| maxnumber | number= 100 | What done is. |
| labelstring | string | Required: what is in progress. The accessible name. |
| showLabelboolean | boolean | Show the label above the bar, with the amount at the right. |
| format(value, max) => string | (value, max) => string | How the amount is written — “3 of 8 files”. Also read out as the value text. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { EASE_OUT_EXPO } from "../../lib/motion.js";
export type ProgressProps = {
/** How far along. Leave it out (or pass `null`) while the end is not known yet. */
value?: number | null;
max?: number;
/** The accessible name — what is in progress. Required: a bar alone does not say. */
label: string;
/** Show the label above the bar, with the amount at the right. */
showLabel?: boolean;
/** How the amount is written, beside the bar and to a screen reader. Defaults to a percentage. */
format?: (value: number, max: number) => string;
size?: "sm" | "md";
className?: string;
};
/**
* How far a task has got — an upload, an import, a build.
*
* It is not a `Meter`, and the difference is the point. A meter reports a
* measurement that already exists — storage used, a score, a share — so it is
* `role="meter"` and drawn on the value scale, where the colour at its tip says
* whether that much is good. Progress reports something still happening: it is
* `role="progressbar"`, it only ever means "done so far", and it is a plain ink
* fill, because a task half-finished is neither good nor bad, only unfinished.
* It also has a state a meter cannot: running with no known end, which a
* measurement never is.
*
* The fill is the whole track's width, slid in from the left by a transform
* rather than grown by its width, so moving it costs no layout and its rounded
* end keeps its shape at every value. It tweens on expo-out and never springs:
* a bar that overshoots claims, for a moment, more done than is. While the end
* is unknown a short segment sweeps across instead; under reduced motion it
* holds still and breathes in opacity, which says "working" without travel.
*/
export function Progress({ value = null, max = 100, label, showLabel = false, format, size = "sm", className = "" }: ProgressProps) {
const reduce = useReducedMotion();
const known = value !== null && Number.isFinite(value);
const v = known ? Math.max(0, Math.min(max, value as number)) : 0;
const pct = max > 0 ? (v / max) * 100 : 0;
const text = known ? (format ? format(v, max) : `${Math.round(pct)}%`) : undefined;
const h = size === "md" ? "h-2" : "h-1.5";
return (
<LazyMotion features={domAnimation} strict>
<div className={className}>
{showLabel && (
<div className="mb-2 flex items-baseline justify-between gap-3 text-[12px] leading-none">
<span className="truncate text-muted">{label}</span>
{text && <span className="shrink-0 tabular-nums text-gray-1000">{text}</span>}
</div>
)}
<div
role="progressbar"
aria-label={label}
aria-valuemin={known ? 0 : undefined}
aria-valuemax={known ? max : undefined}
aria-valuenow={known ? v : undefined}
aria-valuetext={text}
aria-busy={!known || undefined}
className={`relative w-full overflow-hidden rounded-full bg-gray-alpha-200 ${h}`}
>
{known ? (
<m.span
aria-hidden
className="absolute inset-0 rounded-full bg-ink"
initial={false}
animate={{ x: `${pct - 100}%` }}
transition={reduce ? { duration: 0 } : { duration: 0.6, ease: EASE_OUT_EXPO }}
/>
) : reduce ? (
<m.span
aria-hidden
className="absolute inset-0 rounded-full bg-ink"
initial={{ opacity: 0.2 }}
animate={{ opacity: [0.2, 0.45, 0.2] }}
transition={{ duration: 2, ease: "easeInOut", repeat: Infinity }}
/>
) : (
<m.span
aria-hidden
className="absolute inset-y-0 left-0 w-2/5 rounded-full bg-ink"
initial={{ x: "-100%" }}
animate={{ x: ["-100%", "250%"] }}
transition={{ duration: 1.3, ease: [0.65, 0, 0.35, 1], repeat: Infinity }}
/>
)}
</div>
</div>
</LazyMotion>
);
}
/**
* The motion presets the newer components share. The older ones (Dock,
* DropMenu, HighlightList) carry their own copies of the same numbers, which is
* deliberate: each of them documents why its curve is what it is, in place.
*/
/** One slide for anything that re-targets its position. */
export const SLIDE = { type: "spring", bounce: 0.2, duration: 0.4 } as const;
/** Something arriving that should land with a small settle. */
export const LIFT = { type: "spring", bounce: 0.28, duration: 0.5 } as const;
/** The disagreeing pair behind squash-and-stretch: see DropMenu. */
export const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
export const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;
/** Closing is quick and flat. A bounce on the way out reads as indecision. */
export const CLOSE = { duration: 0.13, ease: "easeIn" } as const;
/** Under reduced motion: a short plain fade, nothing that travels. */
export const FLAT = { duration: 0.15 } as const;
/** Expo-out, for anything that also drives a blur — it must not overshoot. */
export const EASE_OUT_EXPO = [0.16, 1, 0.3, 1] as const;
/** The house curve: overshoots a touch, for transforms only. */
export const EASE_GEIST = [0.175, 0.885, 0.32, 1.1] as const;
/** A block entering a list: fade and 4px. Opacity never springs. */
export function enter(reduce: boolean | null) {
return {
initial: reduce ? { opacity: 0 } : { opacity: 0, y: 4 },
animate: { opacity: 1, y: 0 },
transition: reduce ? FLAT : { duration: 0.2, ease: EASE_GEIST },
};
}