--primitiv-progress-height--primitiv-progress-radius--primitiv-progress-track-bg--primitiv-progress-fill-bg--primitiv-progress-maxA 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.
Preview
import { Progress, ProgressIndicator } from "@/components/ui/progress";
<Progress size="md" intent="primary" value={60} aria-label="60% complete"> <ProgressIndicator /></Progress>import { Progress } from "@primitiv-ui/react";
<Progress.Root value={60} aria-label="60% complete"> <Progress.Indicator /></Progress.Root>Density is set by a data-density ancestor — the Context system, not a Progress prop.
npx primitiv add progresspnpm dlx primitiv add progressyarn dlx primitiv add progressbunx primitiv add progressImport
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
<Progress value={60} aria-label="60% complete"> <ProgressIndicator /></Progress><Progress.Root value={60} aria-label="60% complete"> <Progress.Indicator /></Progress.Root>Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| asChild | boolean | false | headless | Render 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)}%` | headless | Produces the human-readable aria-valuetext. Receives the resolved
value and max. Defaults to a rounded percentage (e.g. "42%").
Not called while indeterminate. |
| max | number | 100 | headless | Upper bound of value. Must be a positive, finite number, or Root throws
during render. |
| value | number | null | null | headless | Current 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" | md | styled | Track height; data-density scales each size further. |
| intent | "primary" | "secondary" | "danger" | primary | styled | Fill colour, matching Figma's Intent axis. The track is neutral in every intent — only the fill changes. |
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| asChild | boolean | false | headless | Render the consumer's own element instead of the native <div>, merging
the data-* hooks onto it via Slot. |
--primitiv-progress-height--primitiv-progress-radius--primitiv-progress-track-bg--primitiv-progress-fill-bg--primitiv-progress-maxclassName: .primitiv-progress
| Attribute | Value | When |
|---|---|---|
data-value | <number> | the current `value` — readable from CSS without a custom property |
data-max | <number> | the `max` the value is measured against |
data-state | indeterminate | loading | complete | indeterminate / loading / complete |
| Attribute | Value | When |
|---|---|---|
data-value | <number> | the current `value` — readable from CSS without a custom property |
data-max | <number> | the `max` the value is measured against |
data-state | indeterminate | loading | complete | indeterminate / loading / complete |
className: .primitiv-progress__indicator
| Attribute | Value | When |
|---|---|---|
data-state | indeterminate | loading | complete | indeterminate / loading / complete |
| Attribute | Value | When |
|---|---|---|
data-state | indeterminate | loading | complete | indeterminate / loading / complete |
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.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.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.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.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.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.
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>import { Progress } from "@primitiv-ui/react";import { Stack } from "@/components/ui/stack";
<Stack gap="sm"> {/* determinate — reports 40% */} <Progress.Root value={40} aria-label="40% complete"> <Progress.Indicator /> </Progress.Root>
{/* indeterminate — value omitted, NOT value={0} */} <Progress.Root aria-label="Loading, progress unknown"> <Progress.Indicator /> </Progress.Root>
{/* complete */} <Progress.Root value={100} aria-label="Complete"> <Progress.Indicator /> </Progress.Root></Stack>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.
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>import { useEffect, useState } from "react";import { Progress } from "@primitiv-ui/react";
const [value, setValue] = useState(12);useEffect(() => { const id = setInterval(() => setValue((v) => (v >= 100 ? 0 : v + 4)), 400); return () => clearInterval(id);}, []);
<Progress.Root value={value} aria-label="Uploading files"> <Progress.Indicator /></Progress.Root>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.
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>import { Progress } from "@primitiv-ui/react";
<Progress.Root value={7} max={12} getValueLabel={(value, max) => `Step ${value} of ${max}`} aria-label="Setup progress"> <Progress.Indicator /></Progress.Root>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.
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>import { Progress } from "@primitiv-ui/react";import { Stack } from "@/components/ui/stack";
<Stack gap="lg"> <Progress.Root value={60} aria-label="60% complete"> <Progress.Indicator /> </Progress.Root> <Progress.Root value={60} aria-label="60% complete"> <Progress.Indicator /> </Progress.Root> <Progress.Root value={60} aria-label="60% complete"> <Progress.Indicator /> </Progress.Root></Stack>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.
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>import { Progress } from "@primitiv-ui/react";import { Stack } from "@/components/ui/stack";
<div data-density="comfortable"> <Stack gap="lg"> <Progress.Root value={60} aria-label="60% complete"> <Progress.Indicator /> </Progress.Root> <Progress.Root value={60} aria-label="60% complete"> <Progress.Indicator /> </Progress.Root> <Progress.Root value={60} aria-label="60% complete"> <Progress.Indicator /> </Progress.Root> <Progress.Root value={60} aria-label="60% complete"> <Progress.Indicator /> </Progress.Root> <Progress.Root value={60} aria-label="60% complete"> <Progress.Indicator /> </Progress.Root> </Stack></div>