Skip to content
txbrendan/ui

Progress

beta

A bar that shows how much of a task is done, with a label, an optional value, tones, sizes, and a state for when no one knows yet.

Preview

Texture

tone

Neutral
x
Brand
x
Danger
x
Success
x
Warning
x

size

Small
x
Medium
x
Large
x

states

Not started
x
Preparing your export
x
Checking files
x
Upload complete
x

format

Importing contacts
x
x

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/progress

Use it for

  • A task that takes more than a second or two and has a known end, like an upload or an import.
  • Pass value={null} while the amount is not known yet. The bar pulses until you pass a number.
  • Give it a label that says what is happening, like 'Uploading 3 photos'. Add showValue to show the percent.
  • Use tone='success' or tone='danger' only to match what the task means, like a quota that is nearly full.

Avoid it for

  • A value that is not a task, like disk space used or a password's strength. That needs role="meter", which Progress does not set.
  • Short waits under a second. Show nothing, or a spinner in the button that started it.
  • Content that is still loading on the page. Use a skeleton in its place.
  • Steps in a form. Use step dots or a list of steps.

Props

  • value

    Required. How much is done, from min to max. null shows the pulsing state for an unknown amount.

    number | null

  • label

    Visible text above the bar. Without it, TypeScript requires aria-label or aria-labelledby.

    ReactNode

  • showValue

    Shows the value as text at the end of the label row. It shows nothing while value is null.

    boolean · default false

  • tone

    Color of the filled part.

    "neutral" | "brand" | "danger" | "success" | "warning" · default "neutral"

  • size

    Bar height: 4, 8, or 12px at the default density. lg also uses larger label text.

    "sm" | "md" | "lg" · default "md"

  • min

    The value at the empty end.

    number · default 0

  • max

    The value at the full end. At max, the root gets data-complete.

    number · default 100

  • format

    How the value reads, as text and to screen readers. The default is a percent of the range.

    Intl.NumberFormatOptions

  • getAriaValueText

    Custom words for screen readers, like '3 of 10 files'.

    (formattedValue: string | null, value: number | null) => string

  • texture

    dither draws the filled part as dots, gradient fades it from faint at the start to full at the moving end, and grain speckles it. Hover fills gradient and grain in. A bar with no value keeps its stripes. Left out, the bar follows the Texture around it.

    "flat" | "dither" | "gradient" | "grain"

  • className

    Classes for the root. Later classes win, so className='max-w-xs' narrows it.

    string

Accessibility

  • Renders role="progressbar" with aria-valuenow, aria-valuemin, aria-valuemax, and aria-valuetext.
  • With value={null}, aria-valuenow is left out, which tells screen readers the amount is unknown.
  • TypeScript requires a visible label, aria-label, or aria-labelledby, so the bar always has a name.
  • In server HTML, only aria-label and aria-labelledby name the bar. The visible label is linked once JavaScript runs.
  • Screen readers do not announce each change. Say when the task ends, for example with a message in an aria-live region.
  • The fill uses the tone's color on the muted track. No contrast pair checks the fill, so the label and value carry the meaning.
  • The fill slides with --motion-duration-slow. The unknown state pulses at --motion-pulse-duration, which reduced motion and motion off set to 0s. Its pale fill, not the solid one, still shows it is not done.

Examples

Upload with a label and percent

<Progress value={40} label="Uploading 3 photos" showValue tone="brand" />

Amount not known yet

<Progress value={null} label="Preparing your export" />

No visible label

<Progress value={72} aria-label="Course progress" size="sm" />

Counting items instead of percent

<Progress
  value={3}
  max={10}
  label="Importing contacts"
  showValue
  format={{ style: "decimal" }}
/>

Driven by state

"use client";
import { useEffect, useState } from "react";

function Sync() {
  const [done, setDone] = useState(0);
  useEffect(() => {
    if (done >= 100) return;
    const timer = setTimeout(() => setDone((value) => Math.min(100, value + 10)), 400);
    return () => clearTimeout(timer);
  }, [done]);
  return <Progress value={done} label={done < 100 ? "Syncing" : "Synced"} showValue tone={done < 100 ? "brand" : "success"} />;
}

A gradient bar, full at the moving end

<Progress value={64} tone="brand" label="Uploading" showValue texture="gradient" />