Progress
Use a progress indicator when a wait is long enough that the user wants to know
where it is going. If you know how far along the work is, say so — that is the
question a Spinner cannot answer. If you do not, pass value={null} and the
same markup becomes an indeterminate indicator, which says "running" without
pretending to know more than it does.
Base UI ships one Progress primitive and it draws a bar. This library ships two
components over it: Progress, the bar, and ProgressCircle, the ring.
They share the primitive's state machine, its accessibility contract, its part
names and its two axes, so everything below that is not about geometry applies
to both.
Import
import { Progress, ProgressCircle } from "@forte-ui/react";Which one
| Reach for it when | |
|---|---|
Progress | The full width is available and the value is worth comparing. A bar's length is comparable at a glance down a list of ten; a ring's angle is not. |
ProgressCircle | The width is not available — a card corner, a tile, a table cell, beside an avatar — or the indicator is the only thing in its box. |
Spinner | The wait is short and indeterminate, and a percentage would be noise. It is a smaller component with no value to report. |
Examples
Anatomy
Label and Value share the first row of the root's grid — label to the
inline start, value to the inline end — and Track spans both columns
underneath. Render only a Track and the root is a one-row grid: the gap
disappears with the row that was using it, so a bare bar needs no special case.
value is a required prop on purpose. There is no sensible default for "how far
along is this", and a bar that silently starts at zero is a bar that is lying
until its first update lands.
Indeterminate
value={null} is the entire switch, and the markup does not otherwise change —
which is the point. A task that starts out not knowing its total can start
reporting one without remounting: the sweeping segment settles into the real
value on the same transition the rest of the fill runs on, rather than
unmounting and flashing an empty rail. The realistic version of that is the demo
at the top of this page.
Value renders nothing while indeterminate. That is deliberate: there is no
number, and a "0%" that never moves is worse than no readout at all. Pass a
function child to put words there instead.
Sizes
sm4pxmd6pxlg10pxsize is thickness, not length. The bar fills its container in every size,
because a bar's length belongs to the layout it sits in rather than to the
component; --forte-progress-length is the knob for the cases where it does not.
The label row scales with it.
sm2.5remmd3.5remlg5remThe ring's stroke is measured in its own coordinate space rather than in pixels,
so it scales with the diameter and a sm ring is a smaller ring rather than a
thinner one. It is also proportionally a little heavier at sm, because a
stroke that reads well at 7% of 80px disappears at 40px.
Tones
tone swaps exactly one slot — the fill, or the arc. The rail behind it stays
neutral in every tone: it is the part that has not happened yet, and tinting
it would compete with the thing it exists to contrast against.
Formatting the value
Three separate things can be said about one value, and they are separate props because they have different audiences:
formatis anIntl.NumberFormatOptionsbag applied to the clamped value. It drives the visible readout and the defaultaria-valuetexttogether, so the bar can fill frommintomaxwhile both say "412 MB".Value's function child replaces only what is painted. Use it for something no number formatter produces —3/5,Working….getAriaValueTextreplaces only what is announced. This is the one that usually matters: "3 of 5 steps done" is a sentence, and "60%" is a fact about a bar rather than about the task.
Reduced motion
Under prefers-reduced-motion: reduce the indeterminate sweep stops travelling
and the ring stops rotating — the travel is gated on --forte-motion-ok, and the
rotation is --forte-spin-turn, which collapses to 0turn. A second animation
that is always running, and is a no-op at full motion because --forte-pulse-dip
is 1 there, becomes the visible cue as a slow opacity breathe. That is the
same arrangement Spinner's ring uses.
The part that is specific to progress: the segment also grows. The bar's sweeping segment widens to the whole rail and the ring's arc closes into a full circle. A 35% segment frozen against the start edge is pixel-for-pixel a determinate bar stuck at 35%, and looking determinate is the one thing an indeterminate indicator must never do.
The determinate fill is the other way round. It does transition, and its duration shortens to 120ms under reduced motion rather than disappearing, because a fill that teleports between values is harder to read rather than easier.
Theming
Accessibility
The root renders a <div role="progressbar"> carrying aria-valuemin,
aria-valuemax, aria-valuenow and aria-valuetext. While the value is
null there is no aria-valuenow at all, which is exactly how the ARIA spec
spells "indeterminate" — a 0 there would be a claim, not an absence.
Always give it a name. Progress.Label is wired to the root through
aria-labelledby, so it is the indicator's accessible name; without one, the
bar announces a bare percentage and leaves the listener to work out what it is a
percentage of. Where the surrounding page already says it in words, keep the
label and hide it:
<Progress.Label className="forte-visually-hidden">Uploading render.mov</Progress.Label>Value is aria-hidden, because the root already publishes the same number
through aria-valuenow and announcing it twice is worse than announcing it once.
Under forced colours the bar's rail is redrawn as Canvas with a CanvasText
outline and the fill becomes Highlight. The rail's boundary is an outline
rather than a border on purpose: a border would change the box, and the fill
sizes its cross axis with height: inherit.
The ring needs a different fix for the same problem. Forced colours remaps
color, background-color and border-color but leaves SVG fill and
stroke alone, so a literal stroke: var(--…) would keep its brand hue against
a system-coloured page. Both circles therefore paint with currentColor and
take their colour from color, which the UA does remap — and because that would
land both of them on CanvasText and flatten the ring into a plain circle, the
a11y layer names GrayText and Highlight separately.
There is nothing to focus and nothing to press, so neither component is in the
tab order and both take cursor: default per the pointer rules.
Theming
Every visual decision is a custom property declared on the component's own root.
Set them on the root itself — through className or an inline style — rather
than on an ancestor: the component declares its own defaults there, and an
element's own declaration beats an inherited value.
Progress
| Property | Controls | Default |
|---|---|---|
--forte-progress-length | Length of the bar. 100% by default — a progress bar is a status readout, and status readouts want the width of whatever they are reporting on. | 100% |
--forte-progress-thickness | Rail thickness, which the fill inherits. size retunes it: 4px for sm, 10px for lg. | 6px |
--forte-progress-radius | Rail and fill corner radius. Follows data-forte-radius, so a none preset squares the bar off — unlike a circle's roundness, a bar's is taste rather than meaning. | var(--forte-radius-pill) |
--forte-progress-gap | Gap between the label/value row and the rail. | var(--forte-space-2) |
--forte-progress-track-bg | Rail fill — the part that has not happened yet. Stays neutral in every tone. | var(--forte-color-border-muted) |
--forte-progress-indicator-bg | The fill itself. Swapped per tone. Consumed through the background shorthand rather than background-color, so a gradient is a legal value here as well as a colour — at the cost of the colour transition, which a gradient cannot have. | var(--forte-color-primary) |
--forte-progress-label-color | Colour of the label. | var(--forte-color-foreground) |
--forte-progress-value-color | Colour of the readout. | var(--forte-color-foreground-muted) |
--forte-progress-label-size | Font size of the label and the readout together. Retuned by size. | var(--forte-font-size-2) |
--forte-progress-duration | How long the fill takes to travel to a new value. Shortens under reduced motion rather than disappearing — a fill that teleports is harder to read, not easier. | var(--forte-duration-normal) |
--forte-progress-loop-duration | One full pass of the indeterminate segment. Never collapsed, in any motion state; see the note at the top of this file. | var(--forte-duration-loop-sweep) |
--forte-progress-shuttle | How much of the rail the indeterminate segment covers, as a fraction. It is a plain number rather than a length because the sweep divides by it to work out how far to travel. | 0.35 |
--forte-progress-shuttle-size | The segment's resolved width: --forte-progress-shuttle of the rail normally, the whole rail under reduced motion. | calc( ( var(--forte-progress-shuttle) + (1 - var(--forte-progress-shuttle)) * var(--forte-motion-off) ) * 100% ) |
ProgressCircle
| Property | Controls | Default |
|---|---|---|
--forte-progress-circle-size | Outer diameter of the ring. size retunes it: 2.5rem for sm, 5rem for lg. Everything inside is a fraction of the viewBox and follows on its own. | 3.5rem |
--forte-progress-circle-thickness | Stroke width, in viewBox units — 8 of the 100 the ring is across, not 8 device pixels. That is what makes it scale with the diameter. size retunes it: 10px for sm, 7px for lg, so a small ring stays legible without a large one looking heavy. | 8px |
--forte-progress-circle-cap | Cap on both ends of the arc. butt squares them off. | round |
--forte-progress-circle-gap | Gap between the ring and a label underneath it. | var(--forte-space-2) |
--forte-progress-circle-rail | The rail behind the arc — the part that has not happened yet. Stays neutral in every tone. | var(--forte-color-border-muted) |
--forte-progress-circle-color | The arc itself. Swapped per tone. | var(--forte-color-primary) |
--forte-progress-circle-value-color | Colour of the centred readout. | var(--forte-color-foreground) |
--forte-progress-circle-value-size | Font size of the readout. Retuned by size. | var(--forte-font-size-2) |
--forte-progress-circle-label-color | Colour of the label under the ring. | var(--forte-color-foreground-muted) |
--forte-progress-circle-label-size | Font size of that label. Retuned by size. | var(--forte-font-size-2) |
--forte-progress-circle-duration | How long the arc takes to travel to a new value. Shortens under reduced motion rather than disappearing. | var(--forte-duration-normal) |
--forte-progress-circle-loop-duration | One full rotation of the indeterminate arc. Never collapsed, in any motion state — a loop driven to 1ms is a WCAG 2.3.1 flashing hazard. | var(--forte-duration-loop-spin) |
--forte-progress-circle-arc | Length of the indeterminate arc, out of the 100 units pathLength normalises the circumference to. | 25 |
API reference
The tables list what each part adds on top of Base UI. Everything else passes straight through.
Progress.Root
Also takes Base UI's own root props unchanged: value (required; null
means indeterminate), min, max, format, locale, getAriaValueText,
aria-valuetext, render and style.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
size | ProgressSize | md | Thickness of the bar and the size of the label row — `4px`, `6px` or `10px` of track. The bar's *length* is not a size: it fills its container by default, and `--forte-progress-length` is the knob for anything else. |
tone | ProgressTone | primary | Which semantic colour set the fill draws from. The rail stays neutral in every tone — it is the part that has *not* happened yet, and colouring it would compete with the fill it exists to contrast against. |
Progress.Label
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Progress.Value
Its children may be a function receiving (formattedValue, value). While
indeterminate the first argument is the string "indeterminate" and the second
is null.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Progress.Track
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Progress.Indicator
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
ProgressCircle.Root
Takes the same Base UI root props as Progress.Root.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
size | ProgressSize | md | Diameter of the ring — `2.5rem`, `3.5rem` or `5rem`. The stroke is measured in the ring's own coordinate space rather than in pixels, so it scales with the diameter and a `sm` circle is a smaller ring rather than a thinner one. |
tone | ProgressTone | primary | Which semantic colour set the arc draws from. The rail behind it stays neutral in every tone. |
ProgressCircle.Track
Renders an <svg> rather than Base UI's <div>, so its ref is an
SVGSVGElement. render is not forwarded — replacing the <svg> would take
the 0 0 100 100 coordinate space every rule in the stylesheet is written
against with it, and the failure would be a ring that silently does not draw.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
ProgressCircle.Indicator
Renders a <circle>, so its ref is an SVGCircleElement. render is not
forwarded, for the same reason as Track.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
ProgressCircle.Value
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
ProgressCircle.Label
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |