Skip to content
Primitiv home
Framework
Consumption mode

Progress

stableSource Figma

A progress bar — a track with a fill that reports completion, or an animated indeterminate state when the ratio is unknown. intent picks the fill colour (primary/secondary/danger), matching the Figma set; the track stays neutral in every intent.

Playground

Density

Preview

Size
Intent
import { Progress, ProgressIndicator } from "@/components/ui/progress";
<Progress size="md" intent="primary" value={60} aria-label="60% complete">  <ProgressIndicator /></Progress>

Density is set by a data-density ancestor — the Context system, not a Progress prop.

Installation

npx primitiv add progress

Import

import { Progress } from "@/components/ui/progress";

Copied into your project as .primitiv-progress — you own the file afterwards, so upgrades are opt-in.

Headless mode installs the npm package instead: @primitiv-ui/react

Anatomy

<Progress value={60} aria-label="60% complete">  <ProgressIndicator /></Progress>

Props

Progress.Root

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the consumer's own element instead of the native <div>, merging the ARIA and data-* hooks onto it via Slot.
getValueLabel(value: number, max: number) => string(value, max) => `${Math.round((value / max) * 100)}%`headlessProduces the human-readable aria-valuetext. Receives the resolved value and max. Defaults to a rounded percentage (e.g. "42%"). Not called while indeterminate.
maxnumber100headlessUpper bound of value. Must be a positive, finite number, or Root throws during render.
valuenumber | nullnullheadlessCurrent progress, between 0 and max. Pass null (or omit) for an indeterminate progress bar whose completion ratio is unknown. Drives the resolved ProgressState and the aria-valuenow / data-value hooks. A non-null value outside 0..max throws.
size"xs" | "sm" | "md" | "lg" | "xl"mdstyledTrack height; data-density scales each size further.
intent"primary" | "secondary" | "danger"primarystyledFill colour, matching Figma's Intent axis. The track is neutral in every intent — only the fill changes.

Progress.Indicator

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the consumer's own element instead of the native <div>, merging the data-* hooks onto it via Slot.

Styling contract

--primitiv-progress-height--primitiv-progress-radius--primitiv-progress-track-bg--primitiv-progress-fill-bg--primitiv-progress-max

Data attributes

Progress

className: .primitiv-progress

AttributeValueWhen
data-value<number>the current `value` — readable from CSS without a custom property
data-max<number>the `max` the value is measured against
data-stateindeterminate | loading | completeindeterminate / loading / complete

ProgressIndicator

className: .primitiv-progress__indicator

AttributeValueWhen
data-stateindeterminate | loading | completeindeterminate / loading / complete

Accessibility

  • Always pass a name. role="progressbar" is set for you, but the name is not — an unnamed bar is announced as "progress bar" and nothing else. Use aria-label, or aria-labelledby pointing at visible text. Every example on this page does; the props table cannot tell you to, because the name arrives through a native attribute the component never declares.
  • The aria-value* family is derived and cannot be overridden — the props type omits aria-valuenow, aria-valuemin, aria-valuemax and aria-valuetext precisely so a consumer cannot set a number that disagrees with value. Change the announcement through getValueLabel, not through aria-valuetext.
  • An indeterminate bar reports no aria-valuenow, which is the correct ARIA for unknown progress — a bar that claimed 0 would be telling assistive technology that nothing has happened yet, which is a different statement. It is also why value is nullable rather than defaulting to zero.
  • getValueLabel is what makes a non-percentage scale legible. The default renders "58%" for value={7} max={12}, which is accurate and useless — "Step 7 of 12" is the sentence a screen-reader user needs.
  • Progress is not a live region. Nothing is announced as the value changes; the bar reports its current state when asked. If a completion genuinely needs announcing, put that message in your own live region (or an Alert) rather than expecting the bar to speak.
  • intent carries no meaning for assistive technology — danger and primary announce identically. If the colour is doing semantic work (a quota nearly exhausted), say it in the label.
  • Style off data-state (indeterminate / loading / complete) rather than deriving completion yourself. It is published on the Root by the headless layer, so it is right in both modes and cannot disagree with the ARIA beside it.

Examples

Value, and the absence of one (the headline)

value is number | null, and omitting it means indeterminate, not zero. A determinate bar reports aria-valuenow and a percentage through aria-valuetext, and resolves data-state to loading or complete; an indeterminate one reports neither, sets data-state="indeterminate", and animates to say so. That distinction is the whole reason value is nullable rather than defaulting to 0 — 0 is a known amount of progress, and null is not knowing.

Density
import { Progress, ProgressIndicator } from "@/components/ui/progress";import { Stack } from "@/components/ui/stack";
<Stack gap="sm">  {/* determinate — reports 40% */}  <Progress value={40} aria-label="40% complete">    <ProgressIndicator />  </Progress>
  {/* indeterminate — value omitted, NOT value={0} */}  <Progress aria-label="Loading, progress unknown">    <ProgressIndicator />  </Progress>
  {/* complete */}  <Progress value={100} aria-label="Complete">    <ProgressIndicator />  </Progress></Stack>

A value that changes

Nothing to wire up: pass a new value and the fill follows. The Root republishes data-value and data-max on every render — as attributes, not just custom properties, so CSS can read the number and a test can assert on it without computing a style. data-state flips to complete at value === max, which is the hook to key a finished treatment off rather than comparing numbers in your own code.

Density

value=12 · data-state is loading

import { useEffect, useState } from "react";import { Progress, ProgressIndicator } from "@/components/ui/progress";
const [value, setValue] = useState(12);useEffect(() => {  const id = setInterval(() => setValue((v) => (v >= 100 ? 0 : v + 4)), 400);  return () => clearInterval(id);}, []);
<Progress value={value} aria-label="Uploading files">  <ProgressIndicator /></Progress>

A scale other than percent

max moves the upper bound, so value can be a count rather than a percentage — 7 of 12 steps, 3 of 5 uploads. The visual fill is the ratio either way, but the announcement should not be: getValueLabel writes aria-valuetext, and its default is a rounded percentage, which reads as "58%" when the useful sentence is "7 of 12". Note it is not called at all while indeterminate — there is no value to describe. max must be positive and finite, and a non-null value outside 0..max throws rather than clamping, on the grounds that a bar silently pinned at 100% hides the bug that produced it.

Density
import { Progress, ProgressIndicator } from "@/components/ui/progress";
<Progress  value={7}  max={12}  getValueLabel={(value, max) => `Step ${value} of ${max}`}  aria-label="Setup progress">  <ProgressIndicator /></Progress>

Intent

Three fills. The track stays neutral in every intent — only the fill changes — so bars of different intents still read as the same control on one page. secondary is for progress that should not compete for attention (a background sync), and danger for progress toward a bad outcome, like a quota filling up. Intent is decoration: it carries no ARIA, so a danger bar announces exactly as a primary one does, and anything the colour is meant to convey needs saying in the label too.

Density
import { Progress, ProgressIndicator } from "@/components/ui/progress";import { Stack } from "@/components/ui/stack";
<Stack gap="lg">  <Progress intent="primary" value={60} aria-label="60% complete">    <ProgressIndicator />  </Progress>  <Progress intent="secondary" value={60} aria-label="60% complete">    <ProgressIndicator />  </Progress>  <Progress intent="danger" value={60} aria-label="60% complete">    <ProgressIndicator />  </Progress></Stack>

Sizes and density

Five track heights, each rescaling again with the nearest data-density ancestor. Size is the only thing that moves — the radius stays fully rounded at every height, so a bar never reads as a rectangle at xl.

Density
import { Progress, ProgressIndicator } from "@/components/ui/progress";import { Stack } from "@/components/ui/stack";
<div data-density="comfortable">  <Stack gap="lg">    <Progress size="xs" value={60} aria-label="60% complete">      <ProgressIndicator />    </Progress>    <Progress size="sm" value={60} aria-label="60% complete">      <ProgressIndicator />    </Progress>    <Progress size="md" value={60} aria-label="60% complete">      <ProgressIndicator />    </Progress>    <Progress size="lg" value={60} aria-label="60% complete">      <ProgressIndicator />    </Progress>    <Progress size="xl" value={60} aria-label="60% complete">      <ProgressIndicator />    </Progress>  </Stack></div>