Determinate bar with interruptible transform-based fill and tabular-nums label, plus an indeterminate sliding-gradient mode.
npx shadcn@latest add @paragon/progress-bar"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface ProgressBarProps extends React.ComponentProps<"div"> {
/** 0–max. Ignored when indeterminate. */
value?: number;
max?: number;
/** Sliding-gradient activity mode for unknown durations. */
indeterminate?: boolean;
/** Shows the percentage beside the bar. */
showValue?: boolean;
/** Accessible name for the progressbar. */
label?: string;
}
/**
* Determinate progress bar. The fill moves via transform with an ease-out
* transition, so retargets mid-flight are interruptible. Indeterminate mode
* slides a gradient segment on a linear loop; reduced motion swaps it for a
* static opacity pulse.
*/
export function ProgressBar({
value = 0,
max = 100,
indeterminate = false,
showValue = false,
label = "Progress",
className,
...props
}: ProgressBarProps) {
const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const pct = Math.min(100, Math.max(0, (value / max) * 100));
return (
<div className={cn("flex w-full items-center gap-3", className)} {...props}>
<style href={`paragon-progress-bar-${id}`} precedence="paragon">{`
@keyframes progress-slide-${id} {
from { transform: translateX(-100%); }
to { transform: translateX(250%); }
}
@keyframes progress-pulse-${id} {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
[data-progress-indeterminate="${id}"] {
animation: progress-slide-${id} 1.2s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
[data-progress-indeterminate="${id}"] {
animation: progress-pulse-${id} 2s ease-in-out infinite;
transform: none;
width: 100%;
background: var(--color-primary);
}
}
`}</style>
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={indeterminate ? undefined : Math.round(value)}
className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-secondary"
>
{indeterminate ? (
<div
data-progress-indeterminate={id}
aria-hidden
className="absolute inset-y-0 left-0 w-2/5 rounded-full bg-gradient-to-r from-primary/10 via-primary to-primary/10"
/>
) : (
<div
className="h-full w-full rounded-full bg-primary transition-transform duration-250 ease-[var(--ease-out)]"
style={{ transform: `translateX(-${100 - pct}%)` }}
/>
)}
</div>
{showValue && !indeterminate && (
<span className="w-9 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{Math.round(pct)}%
</span>
)}
</div>
);
}