ControlsProgress

A bar that glides on transform when it knows, and sweeps honestly when it doesn't.

Preview
Uploading photos…13%

Waiting for the server…

The thinking

A progress bar is a promise, and most of them lie. I built this one to refuse the two lies it's usually asked to tell. When there is no real percentage, value={null} drives a sweep instead of a number creeping toward 90 it'll never reach — faking the count buys a calmer first second and repays it in distrust. And the determinate bar never crawls backward: any regression drops the transition and admits the new number at once, because a bar inching down is a slow lie. The load-bearing decision is that the indicator moves on translateX, never width — it's laid out full-size and slid into view, so a tick costs the compositor a transform rather than the layout engine a reflow. That one choice is what lets honesty stay cheap enough to keep.

Interface design

The whole component is two shapes. The track is h-2 w-full rounded-full bg-input; the indicator inside it is bg-primary, the loudest fill in the system, because the one number on screen should be the one you came for. The track's overflow-hidden is doing the real work — the indicator is full-size and merely slides, so the track's clipping is what makes a transform read as a fill. The two modes get two different indicators: determinate is w-full flex-1, and indeterminate is a w-2/5 band — wide enough to read as work, narrow enough that nobody mistakes it for 40 percent done. No percentage text, no stripes, no gradient. The bar states one fact and stops.

Interaction

The indicator is full-width and slides into place on translateX, transitioned duration-300 ease-swift — transform, never width, so a tick costs compositing instead of layout and updates glide instead of jump. The intent-reading here is directional: a rising value is work happening, so it glides; a falling value is a correction, and I don't animate corrections — the transition-none snap admits the new number at once rather than narrating states that never happened. The sweep is animate-progress-sweep, a pure CSS keyframe from the tailwind config: 1.5s ease-in-out, looping, from translateX(-100%) to translateX(350%) — the 40% band fully exits one side before it re-enters the other, so the loop has no visible seam. And because it's compositor-driven, the browser pauses it offscreen for free.

Sound design

This bar is silent, and not because it opts out — the global SoundLayer speaks only for elements carrying an interactive role, and role="progressbar" isn't one, so it never needed a data-silent escape hatch. Sound in this system lands with the consequence of a click, and nobody clicks a progress bar; it reports, it doesn't act. Silence is the honest register for status.

Empathy

Radix wires the semantics: role="progressbar" with aria-valuemin 0, aria-valuemax from max, and — only when the value is a number — aria-valuenow plus an aria-valuetext of the rounded percent. The indeterminate bar omits both, so assistive tech hears busy-with-no-estimate instead of a fake percentage. The bar ships no accessible name of its own; naming it is the caller's job, and the demo passes aria-label on both instances. Reduced motion is honored entirely in CSS: motion-reduce:animate-none holds the sweep as a still 40% band — “working” stated plainly, without the pacing — and motion-reduce:transition-none makes determinate updates snap instead of glide. There is no keyboard story and no disabled state because there is nothing to operate. The bar is output.
progress.tsx
"use client";

import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";

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

export interface ProgressProps
	extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
	ref?: React.Ref<React.ComponentRef<typeof ProgressPrimitive.Root>>;
}

const Progress = ({ ref, className, value, max = 100, ...props }: ProgressProps) => {
	const indeterminate = value == null;
	const percent = indeterminate ? 0 : (value / max) * 100;

	// forward updates glide; regressions snap. a bar crawling backward is
	// a lie told slowly, so any decrease drops the transition for that frame.
	const previousPercent = React.useRef(percent);
	const regressed = !indeterminate && percent < previousPercent.current;
	React.useEffect(() => {
		previousPercent.current = percent;
	}, [percent]);

	return (
		<ProgressPrimitive.Root
			ref={ref}
			value={value}
			max={max}
			className={cn(
				"relative h-2 w-full overflow-hidden rounded-full bg-input",
				className
			)}
			{...props}
		>
			{indeterminate ? (
				// the sweep is pure CSS: compositor-driven, paused offscreen by
				// the browser; reduced motion gets a still 40% bar — "working"
				// without the sweep
				<ProgressPrimitive.Indicator className="h-full w-2/5 animate-progress-sweep rounded-full bg-primary motion-reduce:animate-none" />
			) : (
				// transform, never width: the indicator is full-size and slides
				// into view, so updates cost compositing only
				<ProgressPrimitive.Indicator
					className={cn(
						"h-full w-full flex-1 bg-primary transition-transform duration-300 ease-swift motion-reduce:transition-none",
						regressed && "transition-none"
					)}
					style={{ transform: `translateX(-${100 - percent}%)` }}
				/>
			)}
		</ProgressPrimitive.Root>
	);
};
Progress.displayName = ProgressPrimitive.Root.displayName;

export { Progress };