Skip to content

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.

progress/upload.tsx
Uploading render.mov
x
Uploading render.movx
Idle

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
ProgressThe 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.
ProgressCircleThe 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.
SpinnerThe wait is short and indeterminate, and a percentage would be noise. It is a smaller component with no value to report.

Examples

Anatomy

progress/basic.tsx
Uploading footage
x

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

progress/indeterminate.tsx
Preparing export
x
Preparing exportx

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

progress/sizes.tsx
sm4px
x
md6px
x
lg10px
x

size 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.

progress/circle-sizes.tsx
sm2.5remx
md3.5remx
lg5remx

The 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

progress/tones.tsx
Syncing
x
Indexing
x
Restore complete
x
Storage used
x
Quota exceeded
x
Background cleanup
x

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.

progress/circle.tsx
Downloadingx
Transcodingx
Publishedx

Formatting the value

progress/formatting.tsx
Downloading update
x
Onboarding stepsx
Announced as stepsx

Three separate things can be said about one value, and they are separate props because they have different audiences:

  • format is an Intl.NumberFormatOptions bag applied to the clamped value. It drives the visible readout and the default aria-valuetext together, so the bar can fill from min to max while both say "412 MB".
  • Value's function child replaces only what is painted. Use it for something no number formatter produces — 3/5, Working….
  • getAriaValueText replaces 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

progress/reduced-motion.tsx
Full motion
Working
x
Workingx
prefers-reduced-motion
Working
x
Workingx

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

progress/theming.tsx
Hairline, square
x
Chunky, inset rail
x
Heavy, butt capsx
Hairline, no railx

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

Theming tokens for Progress
PropertyControlsDefault
--forte-progress-lengthLength 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-thicknessRail thickness, which the fill inherits. size retunes it: 4px for sm, 10px for lg.6px
--forte-progress-radiusRail 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-gapGap between the label/value row and the rail.var(--forte-space-2)
--forte-progress-track-bgRail fill — the part that has not happened yet. Stays neutral in every tone.var(--forte-color-border-muted)
--forte-progress-indicator-bgThe 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-colorColour of the label.var(--forte-color-foreground)
--forte-progress-value-colorColour of the readout.var(--forte-color-foreground-muted)
--forte-progress-label-sizeFont size of the label and the readout together. Retuned by size.var(--forte-font-size-2)
--forte-progress-durationHow 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-durationOne 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-shuttleHow 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-sizeThe 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

Theming tokens for ProgressCircle
PropertyControlsDefault
--forte-progress-circle-sizeOuter 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-thicknessStroke 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-capCap on both ends of the arc. butt squares them off.round
--forte-progress-circle-gapGap between the ring and a label underneath it.var(--forte-space-2)
--forte-progress-circle-railThe rail behind the arc — the part that has not happened yet. Stays neutral in every tone.var(--forte-color-border-muted)
--forte-progress-circle-colorThe arc itself. Swapped per tone.var(--forte-color-primary)
--forte-progress-circle-value-colorColour of the centred readout.var(--forte-color-foreground)
--forte-progress-circle-value-sizeFont size of the readout. Retuned by size.var(--forte-font-size-2)
--forte-progress-circle-label-colorColour of the label under the ring.var(--forte-color-foreground-muted)
--forte-progress-circle-label-sizeFont size of that label. Retuned by size.var(--forte-font-size-2)
--forte-progress-circle-durationHow 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-durationOne 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-arcLength 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.

Props for ProgressRoot
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
sizeProgressSizemdThickness 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.
toneProgressToneprimaryWhich 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

Props for ProgressLabel
PropTypeDefaultDescription
classNamestringAdditional 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.

Props for ProgressValue
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Progress.Track

Props for ProgressTrack
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Progress.Indicator

Props for ProgressIndicator
PropTypeDefaultDescription
classNamestringAdditional 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.

Props for ProgressCircleRoot
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
sizeProgressSizemdDiameter 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.
toneProgressToneprimaryWhich 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.

Props for ProgressCircleTrack
PropTypeDefaultDescription
classNamestringAdditional 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.

Props for ProgressCircleIndicator
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

ProgressCircle.Value

Props for ProgressCircleValue
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

ProgressCircle.Label

Props for ProgressCircleLabel
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.