Skip to contentVibraUI
Feedback & status

Progress

A thin bar for how far along a task is, with an optional label and value.

Vibra's indicator transitions its width alone, on --duration-base and the standard ease, where shadcn's transitions all; the fill is the accent through --primary on a 4px muted track. Put ProgressLabel and ProgressValue inside it to name it and print the percent, or name it with aria-label or aria-labelledby when its name is elsewhere, such as a table row's header. A bar whose size is not known yet takes value={null}: where shadcn draws an empty track, which reads as 0%, Vibra draws a segment two-fifths of the track wide that slides the reading way, and holds it still in the middle of the track — where it cannot be read as an amount — for a reader who has asked for less motion, by the OS setting or a [data-motion="reduced"] above. The slide's keyframes come with the component, hoisted into the head once. Colour a bar by its state with a class on the indicator, and say the state in words beside it.

Install

npx shadcn@latest add @vibra/progress

Needs the @vibra registry in your components.json — set it up once.

Examples

Counted in its own units

The range is the queue of 318 invoices, so the bar prints and announces "142 of 318", with the time left as its description.

Indeterminate

value={null} while the size is unknown: a segment slides, and holds still mid-track under either reduced-motion switch.

Coloured by threshold

Amber from 80% of the limit, red past it — and the band is said in words with its own icon, never by colour alone.

With a target

The bar runs to the stretch goal, so it can pass the target, which is a mark across the track named under it.

In steps

Four setup steps as the range, so the bar fills a quarter at a time; the list says each step's state, and the next is current.

With a status

An import from upload to checked rows: the bar goes indeterminate while rows are checked, and a status line speaks once per phase.

Done, or stopped

A finished bar hands over the file; a stopped one keeps the place it reached, turns red and says why, with a Retry that names the file.

In a table

One bar per project, labelled by its row header and the column title, with the percent in a fixed width so the bars line up.

Right to left

In Arabic the fill grows from the right, the value is in the page's own digits, and the indeterminate segment slides right to left.

Props

PropTypeDefaultDescription
valuenumber | null—How far along, between min and max; null while the size of the work is not known yet, which draws the sliding segment.
min / maxnumber0 / 100The range. Count in the task's own units — 318 invoices, four steps — rather than converting to a percent.
getAriaValueText(formattedValue: string, value: number | null) => string—What a screen reader hears for the value: the units ("142 of 318 sent") or the state ("Counting rows") rather than a bare percent.
locale / formatIntl.LocalesArgument / Intl.NumberFormatOptions—How the value is formatted for ProgressValue and the default value text — an Arabic page's own digits, say.
ProgressValue.children(formattedValue: string | null, value: number | null) => React.ReactNode—Prints something other than the percent: "142 of 318", "$184.2K".

Dependencies

Source

components/ui/progress.tsx
"use client"

import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@/lib/utils"

function Progress({
  className,
  children,
  value,
  ...props
}: ProgressPrimitive.Root.Props) {
  return (
    <ProgressPrimitive.Root
      value={value}
      data-slot="progress"
      className={cn("flex flex-wrap gap-3", className)}
      {...props}
    >
      {children}
      <ProgressTrack>
        <ProgressIndicator />
      </ProgressTrack>
    </ProgressPrimitive.Root>
  )
}

function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
  return (
    <ProgressPrimitive.Track
      className={cn(
        "relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
        className
      )}
      data-slot="progress-track"
      {...props}
    />
  )
}

/**
 * The slide a bar with no value makes: a segment two-fifths of the track wide,
 * centred, travels from wholly before the track's start to wholly past its end.
 * The keyframes come with the component — React hoists the sheet into the head
 * once, however many bars there are — so nothing is added to your CSS.
 */
const INDETERMINATE_KEYFRAMES =
  "@keyframes progress-indeterminate{from{translate:-175% 0}to{translate:175% 0}}"

function ProgressIndicator({
  className,
  ...props
}: ProgressPrimitive.Indicator.Props) {
  return (
    <>
      <style href="vibra-progress-keyframes" precedence="default">
        {INDETERMINATE_KEYFRAMES}
      </style>
      <ProgressPrimitive.Indicator
        data-slot="progress-indicator"
        className={(state) =>
          cn(
            "h-full bg-primary transition-[width] duration-(--duration-base) ease-(--ease-standard)",
            // No value (`value={null}`): a segment rather than a fill. It rests
            // in the middle of the track, where it cannot be read as an amount
            // — an amount always starts at the inline start — and slides the
            // reading way only while the reader has not asked for less motion,
            // by the OS setting or a [data-motion="reduced"] above. A loop
            // takes no duration token, so it has to not run instead.
            state.status === "indeterminate" &&
              "mx-auto w-2/5 motion-safe:not-in-data-[motion=reduced]:animate-[progress-indeterminate_1.5s_ease-in-out_infinite] rtl:[animation-direction:reverse]",
            typeof className === "function" ? className(state) : className
          )
        }
        {...props}
      />
    </>
  )
}

function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
  return (
    <ProgressPrimitive.Label
      className={cn("text-sm font-medium", className)}
      data-slot="progress-label"
      {...props}
    />
  )
}

// Pushed to the end of the row on the inline axis, so a right-to-left bar
// prints its value at the far (left) end rather than against the label.
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
  return (
    <ProgressPrimitive.Value
      className={cn(
        "ms-auto text-sm text-muted-foreground tabular-nums",
        className
      )}
      data-slot="progress-value"
      {...props}
    />
  )
}

export {
  Progress,
  ProgressTrack,
  ProgressIndicator,
  ProgressLabel,
  ProgressValue,
}