Progress
pnpm dlx shadcn@latest add …/r/progress.jsonAn accessible React progress bar and ring for Next.js and Tailwind CSS that show how far along a task is, with an indeterminate state.
- Bar
- Ring
- Indeterminate
- Indeterminate
import { Progress } from "@/components/ui/progress"
export function Upload({ percent }: { percent: number }) {
return <Progress value={percent} aria-label="Upload" />
}"use client";
import { Button } from "@/components/ui/button";
import { Progress, ProgressRing, ProgressValue } from "@/components/ui/progress";
import { PlayIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
/** Pretend upload: where it's got to at each step */
const steps = [0, 12, 27, 41, 58, 72, 86, 100];
/*
* A bar and a ring at the same value, and both while indeterminate, named underneath (each row lines its
* names up at the bottom). Run steps the value from 0 to 100 every 0.7s, and the fill, the arc and the number
* rise continuously through the steps rather than in parts.
*/
export default function ProgressOverview() {
const [value, setValue] = useState(64);
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
const stop = () => {
if (timer.current) clearInterval(timer.current);
timer.current = null;
};
// Leaving the page mid-run stops the steps
useEffect(
() => () => {
if (timer.current) clearInterval(timer.current);
},
[],
);
const run = () => {
stop();
let step = 0;
setValue(steps[0] ?? 0);
timer.current = setInterval(() => {
step += 1;
const next = steps[step];
if (next === undefined) stop();
else setValue(next);
}, 700);
};
return (
<div className="flex w-full flex-col gap-6 self-stretch">
<div className="-mt-6 -mr-6 flex items-center justify-end">
<Button variant="outline" size="sm" onClick={run}>
<PlayIcon aria-hidden />
Run
</Button>
</div>
<ul className="mx-auto grid w-full max-w-md flex-1 grid-cols-2 content-center items-end justify-items-center gap-x-10 gap-y-8">
<li className="flex w-full flex-col items-center gap-2">
<div className="flex w-full items-center gap-3">
<Progress value={value} aria-label="Upload" />
<ProgressValue value={value} className="w-10 justify-end text-xs text-muted-foreground" />
</div>
<span className="text-xs text-muted-foreground">Bar</span>
</li>
<li className="flex flex-col items-center gap-2">
<ProgressRing value={value} size="lg" showValue aria-label="Upload" />
<span className="text-xs text-muted-foreground">Ring</span>
</li>
<li className="flex w-full flex-col items-center gap-2">
<div className="flex h-4 w-full items-center">
<Progress aria-label="Loading" />
</div>
<span className="text-xs text-muted-foreground">Indeterminate</span>
</li>
<li className="flex flex-col items-center gap-2">
<ProgressRing size="lg" aria-label="Loading" />
<span className="text-xs text-muted-foreground">Indeterminate</span>
</li>
</ul>
<div aria-hidden className="-mb-6 h-(--size-control-sm)" />
</div>
);
}Installation
Install the dependencies.
$ pnpm add class-variance-authority motion radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Copy the source into your project.
"use client"; import { duration, ease } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { cva, type VariantProps } from "class-variance-authority"; import { animate, motion, type Transition, useMotionValue, useReducedMotion, useTransform, } from "motion/react"; import { Progress as ProgressPrimitive } from "radix-ui"; import { type ComponentProps, useEffect, useRef } from "react"; /* * Progress: how far along a task is, as a bar (Progress) or a ring (ProgressRing), both on Radix Progress * (role="progressbar" with the value, and no value while it's indeterminate). Owner's roadmap, 2026-10-16. * Give each a name with aria-label or aria-labelledby. * Motion (.agents/rules/motion.md): * - A new value: the bar's fill slides to it (translateX) and the ring's arc draws to it (pathLength). It rises * continuously, not in parts (owner, 2026-10-21): each change takes as long as the gap since the one before, * at an even pace, so updates that keep coming join into one smooth rise and the fill never stops to wait. * A change after a pause eases in on its own (duration.slow, ease.out). Never a spring: its overshoot would * show more than was done, and past 100 at the end. The first paint shows the value as it is. * - Indeterminate (`value` null or left out): a segment sweeps across the bar; an arc turns round the ring. * Both are transform loops, which motion hands to the Web Animations API (as Spinner does), so they keep * moving while the page is busy. With reduced motion they stop moving and pulse instead, like Spinner. * - ProgressValue, the percentage, counts through every whole number in step with the fill (12, 13, 14…), on * the same timing, so the two never disagree. A deliberate exception to "never a tween through every * number": here the number is the bar read aloud. */ /** Radix's rules: a max above 0 (else 100), a value from 0 to max (clamped here rather than refused) */ function percentOf(value: number | null | undefined, max: number | undefined) { if (value == null || Number.isNaN(value)) return null; const top = max !== undefined && max > 0 ? max : 100; return (Math.min(Math.max(value, 0), top) / top) * 100; } /** Updates closer together than this join into one continuous rise; further apart, each eases in on its own */ const STEADY = duration.slower * 2; /** * How a change to `target` moves: as long as the gap since the last change, at an even pace, while the changes * keep coming; else a quick ease. Progress, its ring and ProgressValue see the same changes at the same moments, * so they keep in step. */ function useFollow(target: number | null, reduce: boolean): Transition { const last = useRef<{ target: number | null; at: number; transition: Transition }>({ target, at: Number.NEGATIVE_INFINITY, transition: { duration: 0 }, }); if (target !== last.current.target) { const now = typeof performance === "undefined" ? 0 : performance.now(); const gap = (now - last.current.at) / 1000; const transition: Transition = reduce ? { duration: 0 } : gap <= STEADY ? { duration: gap, ease: "linear" } : { duration: duration.slow, ease: ease.out }; last.current = { target, at: now, transition }; } return last.current.transition; } /** The indeterminate loops: a calm sweep, and Spinner's pulse with reduced motion */ const sweep = { duration: duration.slower * 3, ease: ease.inOut, repeat: Number.POSITIVE_INFINITY }; const turn = { duration: duration.slower * 3, ease: "linear", repeat: Number.POSITIVE_INFINITY } as const; const pulse = { duration: duration.slower * 3, ease: ease.inOut, repeat: Number.POSITIVE_INFINITY }; export type ProgressProps = ComponentProps<typeof ProgressPrimitive.Root>; /** * A bar, built on Radix Progress. `value` from 0 to `max` (100); leave it out, or pass null, while there's * no telling how long the task will take. */ function Progress({ className, value, max, ...props }: ProgressProps) { const percent = percentOf(value, max); const top = max !== undefined && max > 0 ? max : 100; const reduce = useReducedMotion() ?? false; const follow = useFollow(percent, reduce); return ( <ProgressPrimitive.Root data-slot="progress" value={percent === null ? null : (percent / 100) * top} max={top} className={cn("relative h-2 w-full overflow-hidden rounded-full bg-primary/20", className)} {...props} > {percent === null ? ( <ProgressPrimitive.Indicator asChild> {/* A segment 40% of the track wide, from just off the start to just off the end (250% of itself) */} <motion.div key={reduce ? "pulse" : "sweep"} data-slot="progress-indicator" className={cn( "absolute inset-y-0 w-2/5 rounded-full bg-primary", reduce ? "left-1/2 -translate-x-1/2" : "left-0", )} animate={ reduce ? { opacity: [1, 0.4, 1] } : { transform: ["translateX(-100%)", "translateX(250%)"] } } transition={reduce ? pulse : sweep} /> </ProgressPrimitive.Indicator> ) : ( <ProgressPrimitive.Indicator asChild> <motion.div data-slot="progress-indicator" className="size-full rounded-full bg-primary" initial={false} animate={{ x: `-${100 - percent}%` }} transition={follow} /> </ProgressPrimitive.Indicator> )} </ProgressPrimitive.Root> ); } const progressRingVariants = cva("relative inline-flex shrink-0 items-center justify-center text-primary", { variants: { size: { sm: "size-(--size-icon-lg)", md: "size-(--size-control-lg)", lg: "size-16 text-sm", }, }, defaultVariants: { size: "md" }, }); /** Stroke widths on the 24-unit drawing, so the line is about 2.5, 3.3 and 4px at 20, 40 and 64px */ const strokes = { sm: 3, md: 2, lg: 1.5 } as const; export type ProgressRingProps = Omit<ComponentProps<typeof ProgressPrimitive.Root>, "children"> & VariantProps<typeof progressRingVariants> & { /** Shows the percentage in the middle, rolling as it changes (md and lg) */ showValue?: boolean; }; /** * A ring, built on Radix Progress: the same `value` and `max` as Progress, drawn as an arc that goes round * from the top. It takes the text colour (text-primary unless you set another). */ function ProgressRing({ className, value, max, size, showValue = false, ...props }: ProgressRingProps) { const percent = percentOf(value, max); const top = max !== undefined && max > 0 ? max : 100; const reduce = useReducedMotion() ?? false; const ring = size ?? "md"; const stroke = strokes[ring]; const radius = 12 - stroke / 2; const indeterminate = percent === null; const loop = !indeterminate ? "still" : reduce ? "pulse" : "turn"; const follow = useFollow(percent, reduce); return ( <ProgressPrimitive.Root data-slot="progress-ring" data-size={ring} value={percent === null ? null : (percent / 100) * top} max={top} className={cn(progressRingVariants({ size }), className)} {...props} > <ProgressPrimitive.Indicator asChild> {/* The drawing turns inside the still root, so nothing passed to the ring turns with it */} <motion.span key={loop} data-slot="progress-indicator" className="absolute inset-0 inline-flex" animate={ loop === "turn" ? { transform: ["rotate(0turn)", "rotate(1turn)"] } : loop === "pulse" ? { opacity: [1, 0.4, 1] } : undefined } transition={loop === "turn" ? turn : loop === "pulse" ? pulse : undefined} > <svg viewBox="0 0 24 24" fill="none" aria-hidden className="size-full"> <circle cx="12" cy="12" r={radius} stroke="currentColor" strokeWidth={stroke} opacity="0.2" /> {/* Starts at the top: the rotation is on a group, since a CSS transform would replace it */} <g transform="rotate(-90 12 12)"> <motion.circle cx="12" cy="12" r={radius} stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" initial={false} // At 0 the round cap would still show a dot, so the arc fades out with its length animate={{ pathLength: indeterminate ? 0.25 : percent / 100, opacity: indeterminate || percent > 0 ? 1 : 0, }} transition={follow} /> </g> </svg> </motion.span> </ProgressPrimitive.Indicator> {showValue && percent !== null && ring !== "sm" ? ( // Positioned, so it paints above the drawing rather than under it <ProgressValue value={percent} className={cn("relative", ring === "md" && "text-xs")} /> ) : null} </ProgressPrimitive.Root> ); } export type ProgressValueProps = Omit<ComponentProps<"span">, "children"> & { /** The same value and max as the Progress it labels */ value?: number | null; max?: number; }; /** * The percentage as text, for beside a bar or inside a ring. It counts to a new value in step with the fill. * Hidden from screen readers: the progressbar already says its value. */ function ProgressValue({ value, max, className, ...props }: ProgressValueProps) { const percent = percentOf(value, max); const reduce = useReducedMotion() ?? false; const follow = useFollow(percent, reduce); const count = useMotionValue(percent ?? 0); const shown = useTransform(count, (current) => Math.round(current)); useEffect(() => { if (percent === null) return; const counting = animate(count, percent, follow); return () => counting.stop(); }, [percent, follow, count]); if (percent === null) return null; return ( <span aria-hidden data-slot="progress-value" className={cn("inline-flex font-medium tabular-nums", className)} {...props} > <motion.span>{shown}</motion.span> <span>%</span> </span> ); } export { Progress, ProgressRing, ProgressValue, progressRingVariants };Update the import paths to match your project.
Examples
With a label
Name the bar with a visible label (aria-labelledby) and describe it with a hint. ProgressValue shows the percentage beside the label.
1.7 MB of 3.8 MB
import { Progress, ProgressValue } from "@/components/ui/progress";
import { useId } from "react";
/*
* A visible label names the bar (aria-labelledby), and a hint under it says what the number means. The
* percentage beside the label is for sighted readers; the bar already tells screen readers its value.
*/
export default function ProgressWithLabel() {
const id = useId();
const value = 45;
return (
<div className="grid w-full max-w-xs gap-2">
<div className="flex items-center justify-between gap-4 text-sm">
<span id={`${id}-label`} className="font-medium">
Uploading report.pdf
</span>
<ProgressValue value={value} className="text-muted-foreground" />
</div>
<Progress value={value} aria-labelledby={`${id}-label`} aria-describedby={`${id}-hint`} />
<p id={`${id}-hint`} className="text-xs text-muted-foreground">
1.7 MB of 3.8 MB
</p>
</div>
);
}Ring sizes
Small sits beside text like an icon; medium and large have room for the value.
- SyncingSmall
- Medium
- Large
import { ProgressRing } from "@/components/ui/progress";
/* The ring's three sizes, named underneath: small sits beside text like an icon; medium and large fit the value */
export default function ProgressRingSizes() {
return (
<ul className="flex flex-wrap items-end justify-center gap-x-10 gap-y-6">
<li className="flex flex-col items-center gap-2">
<span className="flex items-center gap-2 text-sm">
<ProgressRing value={30} size="sm" aria-label="Syncing" />
Syncing
</span>
<span className="text-xs text-muted-foreground">Small</span>
</li>
<li className="flex flex-col items-center gap-2">
<ProgressRing value={60} showValue aria-label="Storage used" />
<span className="text-xs text-muted-foreground">Medium</span>
</li>
<li className="flex flex-col items-center gap-2">
<ProgressRing value={90} size="lg" showValue aria-label="Course completed" />
<span className="text-xs text-muted-foreground">Large</span>
</li>
</ul>
);
}API reference
Progress
A bar, built on Radix Progress (role="progressbar"). Give it a name with aria-label or aria-labelledby. Also accepts every native div prop.
| Prop | Type | Default | Description |
|---|---|---|---|
value? | number | null | – | How far along, from 0 to max. Leave it out, or pass null, while there's no telling: a segment sweeps across instead. |
max? | number | 100 | The value that means done. |
getValueLabel? | (value: number, max: number) => string | "64%" | What screen readers say for the value. |
ProgressRing
The same as a ring: an arc that goes round from the top. It takes the text colour (text-primary unless you set another).
| Prop | Type | Default | Description |
|---|---|---|---|
value? | number | null | – | How far along, from 0 to max. Without it, an arc turns round the ring. |
max? | number | 100 | The value that means done. |
size? | "sm" | "md" | "lg" | "md" | 20, 40 or 64px. Small fits beside text like an icon. |
showValue? | boolean | false | Shows the percentage in the middle, counting as it changes. Medium and large only. |
ProgressValue
The percentage as text, for beside a bar. It counts to a new value in step with the fill. Hidden from screen readers, since the progressbar already says its value.
| Prop | Type | Default | Description |
|---|---|---|---|
value? | number | null | – | The same value as the Progress it labels. |
max? | number | 100 | The same max. |
Related components
An accessible React spinner for Next.js and Tailwind CSS: one line going round an infinity that fills it once the task is done.
- Loading
An accessible React loading button for Next.js that shows loading, success and error states for async actions, without changing width.
- Changes saved
An accessible React toast for Next.js: notifications that slide in, stack like a deck and swipe away, with a toast() function and Radix Toast parts.