Progress
FeedbackA determinate bar and a correct indeterminate one in the same component, because the difference between them is one attribute and getting it wrong misreports the job.
Preview
Importing bookings
50%
Controls
Notes
Indeterminate omits aria-valuenow entirely. That omission is the signal — progress is happening, the amount is unknown. A bar that animates while reporting aria-valuenow="0" tells a screen reader the job is stuck.
The bar is labelled by the visible caption through aria-labelledby rather than given a duplicate aria-label, which is one more string to drift out of step.
Under reduced motion the indeterminate bar breathes in place instead of travelling. It cannot simply stop: a still indeterminate bar reads as frozen, which is the opposite of what it means.
Determinate width is animated with a transition rather than a keyframe, so a value that arrives mid-animation moves from where the bar actually is instead of snapping back.
Source
ShowHide
"use client";
import { useId } from "react";
import { cn } from "@/lib/cn";
/**
* Determinate and indeterminate in one component, because the difference is
* one attribute and getting it wrong is a real lie to assistive tech.
*
* Indeterminate OMITS aria-valuenow entirely. That omission is the signal:
* "progress is happening, the amount is unknown". A bar that animates while
* reporting aria-valuenow="0" tells a screen reader the job is stuck.
*/
const css = `
@keyframes progress-slide {
from { translate: -60% 0; }
to { translate: 160% 0; }
}
@keyframes progress-breathe {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.85; }
}
.progress-indeterminate {
inline-size: 40%;
animation: progress-slide 1.4s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
/* It cannot simply stop — a still indeterminate bar reads as frozen. So it
breathes in place instead of travelling. */
@media (prefers-reduced-motion: reduce) {
.progress-indeterminate {
inline-size: 100%;
translate: 0;
animation: progress-breathe 2.4s ease-in-out infinite;
}
}
`;
const sizes = { sm: "h-1", md: "h-2" } as const;
export type ProgressSize = keyof typeof sizes;
export function Progress({
label,
value = 0,
indeterminate = false,
showValue = true,
size = "md",
className,
}: {
label: string;
/** 0–100. Ignored when indeterminate. */
value?: number;
indeterminate?: boolean;
showValue?: boolean;
size?: ProgressSize;
className?: string;
}) {
const id = useId();
const clamped = Math.min(100, Math.max(0, value));
return (
<div className={cn("w-full", className)}>
<style href="progress-bar" precedence="default">
{css}
</style>
<div className="flex items-baseline justify-between gap-4">
<p id={id} className="text-[13px] text-ink-muted">
{label}
</p>
{showValue && (
<p className="font-mono text-[11px] tabular-nums text-ink-faint">
{indeterminate ? "Working" : `${clamped}%`}
</p>
)}
</div>
<div
role="progressbar"
// Labelled by the caption above rather than given a duplicate
// aria-label that would drift from it.
aria-labelledby={id}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={indeterminate ? undefined : clamped}
className={cn(
"mt-2 overflow-hidden rounded-full bg-paper-sunken",
sizes[size],
)}
>
<div
className={cn(
"h-full rounded-full bg-accent",
indeterminate ? "progress-indeterminate" : "transition-[width] duration-500 ease-out",
)}
style={indeterminate ? undefined : { width: `${clamped}%` }}
/>
</div>
</div>
);
}
export default function Demo({
value = "60",
indeterminate = false,
showValue = true,
size = "md",
}: {
value?: string;
indeterminate?: boolean;
showValue?: boolean;
size?: ProgressSize;
}) {
return (
<div className="w-full max-w-sm">
<Progress
label={indeterminate ? "Reconciling bookings" : "Importing bookings"}
value={Number(value) || 0}
indeterminate={indeterminate}
showValue={showValue}
size={size}
/>
</div>
);
}
Next
SkeletonNext step
Tell me where the business is losing time.
No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.