Feedback

Progress Bar

Determinate bar with interruptible transform-based fill and tabular-nums label, plus an indeterminate sliding-gradient mode.

Install

npx shadcn@latest add @paragon/progress-bar

progress-bar.tsx

"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>
  );
}