Skip to content
goodtabs UI
BrowseBrowse components

Progress bar

A determinate or indeterminate bar with the real progressbar role.

npx shadcn@latest add https://ui.goodtabs.co/r/progress-bar.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/progress-bar

Uploading

A label, a mono percentage and a 4px track.

Indeterminate

Building the registry

No value, so a segment sweeps across. Still under reduced motion.

Stacked steps

Typecheck

Lint

Screenshots

Deploying

Several bars in a column, one with its label hidden.

Usage

import { ProgressBar } from "@/components/ui/progress-bar"

<ProgressBar label="Uploading" value={64} />

Props

PropTypeDefaultDescription
labelstringAccessible name and visible label.
valuenumberCurrent value. Leave out for indeterminate.
maxnumber100The value that means done.
hideLabelbooleanfalseKeep the label for screen readers only.

Source

progress-bar/progress-bar.tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

type ProgressBarProps = Omit<React.ComponentProps<"div">, "children" | "role"> & {
  /** Accessible name, and the visible label unless `hideLabel` is set. */
  label: string;
  /** Current value. Leave undefined for an indeterminate bar. */
  value?: number;
  /** The value that means done. */
  max?: number;
  /** Hide the visible label and keep it for screen readers. */
  hideLabel?: boolean;
};

/**
 * A progress bar with the real progressbar role. With a value it is determinate and
 * shows the percentage in mono. Without one it sweeps a segment across the track, and
 * holds still under reduced motion.
 */
export function ProgressBar({ label, value, max = 100, hideLabel, className, ...props }: ProgressBarProps) {
  const reduce = useReducedMotion();
  const determinate = value !== undefined;
  const clamped = determinate ? Math.min(Math.max(value, 0), max) : 0;
  const percent = determinate && max > 0 ? (clamped / max) * 100 : 0;

  return (
    <div className={cn("flex w-full flex-col gap-1.5", className)} {...props}>
      <div className="flex items-baseline justify-between gap-3">
        <p className={cn("m-0 truncate text-sm text-foreground", hideLabel && "sr-only")}>{label}</p>
        {determinate && (
          <p aria-hidden="true" className="m-0 font-mono text-xs text-muted-foreground tabular-nums">
            {Math.round(percent)}%
          </p>
        )}
      </div>
      <div
        role="progressbar"
        aria-label={label}
        aria-valuemin={determinate ? 0 : undefined}
        aria-valuemax={determinate ? max : undefined}
        aria-valuenow={determinate ? clamped : undefined}
        aria-valuetext={determinate ? `${Math.round(percent)}%` : "Loading"}
        className="relative h-1 w-full overflow-hidden rounded-full bg-muted"
      >
        {determinate ? (
          <div
            data-slot="progress-bar-fill"
            className="h-full rounded-full bg-foreground transition-[width] duration-200 ease-out motion-reduce:transition-none"
            style={{ width: `${percent}%` }}
          />
        ) : (
          <motion.div
            data-slot="progress-bar-sweep"
            className="absolute inset-y-0 left-0 w-2/5 rounded-full bg-foreground"
            initial={{ x: reduce ? "75%" : "-100%" }}
            animate={reduce ? undefined : { x: ["-100%", "250%"] }}
            transition={{ duration: 1.4, ease: [0.4, 0, 0.2, 1], repeat: Infinity }}
          />
        )}
      </div>
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/progress-bar/prompt.md, or the Copy AI prompt button above.

When to use

  • Uploads, builds and imports with a known amount of work
  • Work that is running but has no measurable end, using the indeterminate form

When not to use

  • A goal with a count and a footnote: use progress-card
  • A reading against a range: use meter, since it is not progress

Agent notes

  • It is a client component because the indeterminate sweep uses motion
  • Omit aria-valuenow when value is undefined; the component does this for you
  • The sweep animates transform only and holds a static segment under reduced motion
  • Value is clamped to 0 and max; the fill width transition is 200ms