← ui

progress

ui

how far along work is: a bar, the count and the time left

npx shadcn add @ag/progress
Already installed? Preview an update
npx shadcn add @ag/progress --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-10
  • New: material: plain, a groove of the page's foreground; glass, a channel of the material's glass, the fill and the words in the text's own colour.
Updated 2026-10-10 · release notes

shelf data & status

uses material

used by components

the demo above, verbatim

progress.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Progress, type ProgressState } from "@/registry/base-nova/ui/progress"

// #region work
// A model looking at 35 frames, about a second each: running, stopped and resumed
// where it was, done. The numbers are made up for this page; the states are the real
// ones a piece of work goes through.
const FRAMES = 35
const MS_PER_FRAME = 1100
// #endregion

function Work() {
  const [done, setDone] = React.useState(0)
  const [state, setState] = React.useState<ProgressState>("paused")
  React.useEffect(() => {
    if (state !== "running") return
    const timer = window.setInterval(
      () =>
        setDone((d) => {
          if (d + 1 >= FRAMES) setState("done")
          return Math.min(FRAMES, d + 1)
        }),
      350,
    )
    return () => window.clearInterval(timer)
  }, [state])
  return (
    <Progress
      label="judging frames"
      done={done}
      of={FRAMES}
      unit="frame"
      eta={(FRAMES - done) * MS_PER_FRAME}
      state={state}
      action={
        state === "running" ? (
          <Button
            size="sm"
            variant="secondary"
            corners="pill"
            onClick={() => setState("paused")}
          >
            stop
          </Button>
        ) : state === "done" ? (
          <Button
            size="sm"
            variant="secondary"
            corners="pill"
            onClick={() => {
              setDone(0)
              setState("running")
            }}
          >
            again
          </Button>
        ) : (
          <Button
            size="sm"
            variant="secondary"
            corners="pill"
            onClick={() => setState("running")}
          >
            {done > 0 ? "resume" : "start"}
          </Button>
        )
      }
    />
  )
}

export default function ProgressDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="work"
        glance
        title="stop it, go on from where it was"
        with="work"
      >
        <Work />
      </Sample>
      <Sample name="states" title="every state">
        <div className="flex flex-col gap-5">
          <Progress
            label="looking at a film"
            done={812}
            of={5400}
            unit="frame"
            eta={1_490_000}
          />
          <Progress
            label="judging frames"
            done={12}
            of={35}
            unit="frame"
            state="paused"
          />
          <Progress
            label="judging frames"
            done={35}
            of={35}
            unit="frame"
            state="done"
          />
          <Progress
            label="judging frames"
            done={20}
            of={35}
            unit="frame"
            state="failed"
          />
          <Progress label="asking the model" done={0} of={null} />
        </div>
      </Sample>
      <Sample name="glass" title="in glass">
        <MaterialProvider material="glass">
          <div className="flex flex-col gap-5 rounded-2xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-10 text-white">
            <Progress
              label="looking at a film"
              done={812}
              of={5400}
              unit="frame"
              eta={1_490_000}
            />
            <Progress
              label="judging frames"
              done={12}
              of={35}
              unit="frame"
              state="paused"
            />
          </div>
        </MaterialProvider>
      </Sample>
    </div>
  )
}

the source, every file an install writes

progress.tsx
"use client"

// How far along a piece of work is, said three ways at once: a bar, the count (12 of
// 35 frames) and the time left (33s left). For work that takes long enough to watch: a
// model looking at a film, an upload, an import, a render. Data in: how much is done
// of how much (or not known yet), its unit, the time left (ms, like every time here)
// when someone has estimated it, and what state it is in.
//
//   running   the fill eases forward, a soft sheen passing over it while it moves
//   paused    stopped where it is, striped and dimmed: it can go on from here
//   done      full, still
//   failed    in the bad tone, where it stopped
// Not known yet how much there is (`of` null): a short stretch sweeps the track.
// An `action` sits at the row's end: stop, resume, retry, whatever the work allows.
// A role="progressbar" with its values for a screen reader; nothing moves under
// reduced motion. Two materials (`material`): plain, a groove of the page's
// foreground; glass, a channel of the material's glass.

import type * as React from "react"
import { cn } from "@/lib/utils"
import {
  type Material,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import "./progress.css"

export type ProgressState = "running" | "paused" | "done" | "failed"

export interface ProgressProps {
  /** How much is done, in `unit`s. */
  done: number
  /** How much there is in all, or null while it is not known. */
  of: number | null
  /** What is counted, singular: "frame", "file". Plural by an s. */
  unit?: string
  /** Time left, ms, when it is estimated. */
  eta?: number | null
  state?: ProgressState
  /** What the work is, said at the row's start; also its accessible name. */
  label: React.ReactNode
  /** At the row's end: a stop, a resume. */
  action?: React.ReactNode
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
  className?: string
}

/** Plain: the page's foreground at its tiers, a groove of it. Glass: a channel of the
 *  material's glass, the fill and the words in the text's own colour, so it reads on
 *  whatever the glass stands on. */
const FACE: Record<
  Material,
  {
    label: string
    count: string
    track: string
    fill: string
    paused: string
    sweep: string
  }
> = {
  plain: {
    label: "text-foreground/85",
    count: "text-foreground/60",
    track: "h-1.5 bg-foreground/10",
    fill: "bg-foreground/75",
    paused: "bg-foreground/40",
    sweep: "bg-foreground/55",
  },
  glass: {
    label: "text-current/85",
    count: "text-current/60",
    track: cn(
      glassVariants({ shape: "surface" }),
      "h-2 [--glass-blur:4px] [--glass-radius:4px]",
    ),
    fill: "bg-current/80",
    paused: "bg-current/45",
    sweep: "bg-current/55",
  },
}

/** A time left, ms, as a person says it: 33s, 2m 10s, 1h 4m. */
export function timeLeft(ms: number): string {
  const s = Math.max(0, Math.ceil(ms / 1000))
  if (s < 60) return `${s}s`
  if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
  return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
}

const STATE_WORD: Record<ProgressState, string | null> = {
  running: null,
  paused: "paused",
  done: "done",
  failed: "stopped",
}

export function Progress({
  done,
  of,
  unit,
  eta,
  state = "running",
  label,
  action,
  material,
  scheme,
  className,
}: ProgressProps) {
  const m = useMaterial(material)
  // Over a picture it is read by the part of it under the progress.
  const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme)
  const face = FACE[m]
  const known = of !== null && of > 0
  const share = known ? Math.min(1, Math.max(0, done / of)) : 0
  const plural = (n: number) => (unit ? `${unit}${n === 1 ? "" : "s"}` : "")
  const count = known
    ? `${done}/${of} ${plural(of)}`
    : `${done} ${plural(done)}`
  const word = STATE_WORD[state]
  return (
    <div
      ref={here}
      data-slot="progress"
      data-state={state}
      data-material={m}
      className={cn("flex flex-col gap-1.5", schemeOf(s), className)}
    >
      <div className="flex items-center gap-3 text-sm">
        <span className={cn("min-w-0 flex-1 truncate", face.label)}>
          {label}
        </span>
        <span
          className={cn("shrink-0 font-mono text-xs tabular-nums", face.count)}
        >
          {count}
          {state === "running" && eta != null && ` · ${timeLeft(eta)} left`}
          {word && ` · ${word}`}
        </span>
        {action}
      </div>
      <div
        role="progressbar"
        aria-valuemin={0}
        aria-valuemax={known ? (of as number) : undefined}
        aria-valuenow={known ? done : undefined}
        aria-valuetext={`${count}${eta != null ? `, ${timeLeft(eta)} left` : ""}${word ? `, ${word}` : ""}`}
        aria-label={typeof label === "string" ? label : undefined}
        className={cn("relative overflow-hidden rounded-full", face.track)}
      >
        {known ? (
          <div
            data-slot="progress-fill"
            className={cn(
              "absolute inset-y-0 left-0 rounded-full transition-[width] duration-500 ease-out motion-reduce:transition-none",
              state === "failed" ? "bg-tone-bad" : face.fill,
              state === "paused" && face.paused,
            )}
            style={{ width: `${share * 100}%` }}
          />
        ) : (
          state === "running" && (
            <div
              data-slot="progress-sweep"
              className={cn(
                "absolute inset-y-0 w-1/4 rounded-full",
                face.sweep,
              )}
            />
          )
        )}
      </div>
    </div>
  )
}
progress.css
/* progress: what moves, keyed on what the DOM says it is (data-slot, data-state).
   Running, a sheen passes over the fill; paused, the fill is striped; not yet known how
   much there is, a stretch sweeps the track. The motion is declared only where motion
   is welcome, so reduced motion never has to win a specificity contest to stop it. */

@keyframes progress-sheen {
  from {
    background-position: -150% 0;
  }
  to {
    background-position: 250% 0;
  }
}

@keyframes progress-sweep {
  from {
    left: -25%;
  }
  to {
    left: 100%;
  }
}

[data-slot="progress"][data-state="paused"] [data-slot="progress-fill"] {
  background-image: repeating-linear-gradient(
    -45deg,
    transparent 0 3px,
    color-mix(in oklab, var(--background) 45%, transparent) 3px 6px
  );
}

/* Still, a sweep sits at the track's start. */
[data-slot="progress-sweep"] {
  left: 0;
}

@media (prefers-reduced-motion: no-preference) {
  [data-slot="progress"][data-state="running"] [data-slot="progress-fill"] {
    background-image: linear-gradient(
      100deg,
      transparent 30%,
      color-mix(in oklab, var(--background) 35%, transparent) 50%,
      transparent 70%
    );
    background-size: 60% 100%;
    background-repeat: no-repeat;
    animation: progress-sheen 1.6s ease-in-out infinite;
  }

  [data-slot="progress-sweep"] {
    animation: progress-sweep 1.4s ease-in-out infinite;
  }
}