Skip to content

Progress

One native progress element with its painting replaced. value and max are not props of this component — they ride the element, which is also what draws the fill and announces the number. Leave value out and the bar is indeterminate, because the platform already has that state.

  • Stable
  • React 19
  • Zero JavaScript
  • One DOM node
Code
<Progress value={40} max={100} aria-label="Uploading photos" />
<progress class="wayra-progress" value="40" max="100" aria-label="Uploading photos"></progress>

Two props of its own — color and size — and everything else comes from ComponentProps<"progress">. There is no variant, no shape and no width: a bar is one shape, and it always fills the box you put it in.

value and max are the element’s attributes and reach it untouched. The browser works out the geometry, and it reports the same numbers to assistive technology without a single aria-* attribute from us.

Code
<Progress value={0} max={100} aria-label="0 percent" />
<Progress value={33} max={100} aria-label="33 percent" />
<Progress value={66} max={100} aria-label="66 percent" />
<Progress value={100} max={100} aria-label="100 percent" />
<progress class="wayra-progress" value="0" max="100" aria-label="0 percent"></progress>
<progress class="wayra-progress" value="33" max="100" aria-label="33 percent"></progress>
<progress class="wayra-progress" value="66" max="100" aria-label="66 percent"></progress>
<progress class="wayra-progress" value="100" max="100" aria-label="100 percent"></progress>

max is free too, so a bar that counts files rather than percentages needs no arithmetic: value={7} max={12}.

Omit value. That is the whole API — there is no loading flag, because :indeterminate is a state the element already has and the stylesheet keys on it directly.

Code
<Progress aria-label="Preparing export" />
<progress class="wayra-progress" aria-label="Preparing export"></progress>

A band of the fill colour crosses the track in one direction, from the inline start to the inline end, then starts again. Each pass begins with the band just off the start edge and ends with it just off the far edge, so the restart happens where nobody sees it. In a right-to-left page the band runs right to left.

One pass takes 1.8 seconds (0.56 Hz). A slow swing back and forth, around one cycle every five seconds (0.2 Hz), is the kind of motion that bothers people who are sensitive to it, so the band never turns around and never moves that slowly.

It reads as work in progress the way a pulsing full-length bar — which reads as a warning light — does not.

The band is the fill itself. An indeterminate bar has no value, so the fill collapses to nothing, but it is still there and still styleable: hand it a width and it comes back. That is why the band carries the same pill ends as a determinate fill, and why it can travel on translate, which the compositor handles without repainting a frame.

Code
<Progress value={60} max={100} color="primary" aria-label="primary" />
<Progress value={60} max={100} color="secondary" aria-label="secondary" />
<Progress value={60} max={100} color="accent" aria-label="accent" />
<Progress value={60} max={100} color="neutral" aria-label="neutral" />
<Progress value={60} max={100} color="info" aria-label="info" />
<Progress value={60} max={100} color="success" aria-label="success" />
<Progress value={60} max={100} color="warning" aria-label="warning" />
<Progress value={60} max={100} color="error" aria-label="error" />
<progress class="wayra-progress" value="60" max="100" data-color="primary" aria-label="primary"></progress>
<progress class="wayra-progress" value="60" max="100" data-color="secondary" aria-label="secondary"></progress>
<progress class="wayra-progress" value="60" max="100" data-color="accent" aria-label="accent"></progress>
<progress class="wayra-progress" value="60" max="100" data-color="neutral" aria-label="neutral"></progress>
<progress class="wayra-progress" value="60" max="100" data-color="info" aria-label="info"></progress>
<progress class="wayra-progress" value="60" max="100" data-color="success" aria-label="success"></progress>
<progress class="wayra-progress" value="60" max="100" data-color="warning" aria-label="warning"></progress>
<progress class="wayra-progress" value="60" max="100" data-color="error" aria-label="error"></progress>

The fill is the role at full strength and the track is base-300 in every one of them. That pair is what carries the value, so WCAG 1.4.11 asks 3:1 of it. Every role is fitted to 4.5:1 against base-100, and in the default theme base-300 sits 1.27:1 from base-100 in the light half and 1.46:1 in the dark one, so a role that keeps its 4.5:1 keeps at least 3:1 over the track — with little to spare in the dark half.

The Storybook Colors story asserts the colour chain rather than assuming it: it compares the resolved --wayra-progress-color on each bar against that role’s own token, so a broken data-color chain fails the build instead of quietly painting eight identical bars.

Code
<Progress value={60} max={100} size="xs" aria-label="xs" />
<Progress value={60} max={100} size="sm" aria-label="sm" />
<Progress value={60} max={100} size="md" aria-label="md" />
<Progress value={60} max={100} size="lg" aria-label="lg" />
<Progress value={60} max={100} size="xl" aria-label="xl" />
<progress class="wayra-progress" value="60" max="100" data-size="xs" aria-label="xs"></progress>
<progress class="wayra-progress" value="60" max="100" data-size="sm" aria-label="sm"></progress>
<progress class="wayra-progress" value="60" max="100" data-size="md" aria-label="md"></progress>
<progress class="wayra-progress" value="60" max="100" data-size="lg" aria-label="lg"></progress>
<progress class="wayra-progress" value="60" max="100" data-size="xl" aria-label="xl"></progress>
Size Thickness
xs 4px
sm 5px
md 6px
lg 8px
xl 10px

One number moves, and it is the only one there is to move: a bar has no text to scale with it. The ladder comes off --wayra-size-selector, the same unit the badges and the selection controls use, so one token override rescales all of them together.

Two components, not one prop. The bar is a native <progress>, where value, max and the indeterminate state all come free from the element; a ring has to be a <div role="progressbar"> with every one of those written by hand, because a <progress> cannot be turned into an arc across browsers.

Nothing but a colour rule is shared between them — the fill is the role, the track is base-300, stated separately in each stylesheet — so RadialProgress is its own component rather than a shape prop that would leave half the props meaningless on either side.

ProgressProps extends ComponentProps<"progress"> minus color.

Prop Type Default Description
color WayraColor "primary" The fill. All eight roles, on a base-300 track.
size "xs" | "sm" | "md" | "lg" | "xl" "md" Bar thickness, off --wayra-size-selector.
unstyled boolean false Drops the class, keeps the data attributes.

value, max, id, className and ref come free from the native element, which is all any of this needs — there is no state to own and no handler to attach.

The class and the data attributes are public API. This markup is styled with no React in sight:

<progress
class="wayra-progress"
data-color="success"
data-size="lg"
value="40"
max="100"
aria-label="Uploading photos"
></progress>
  • A name is mandatory. Pass aria-label or aria-labelledby. A bar has no text of its own, so without one it announces a number attached to nothing — and the axe gate in Storybook fails any story that forgets.
  • Name the work, not the number. The element already reports the value; the name is there to say what is progressing.
  • The value announces itself. <progress> maps to role="progressbar" with aria-valuenow and aria-valuemax derived from its own attributes. Adding them by hand only creates something that can fall out of sync.
  • It is not focusable and takes no place in the tab order. It reports; it never receives.
  • Indeterminate is announced as such. With no value the element exposes no aria-valuenow, which is what tells assistive technology the length is unknown. A visually hidden status message is worth adding when the wait is long.
  • Reduced motion stops the sweep, not the bar. Under prefers-reduced-motion the animation is dropped and the fill takes the whole track instead, so the state still reads — a parked band is indistinguishable from a determinate bar stopped at that number. The determinate fill stops animating its length there too, and jumps to the new value.
  • Contrast: the fill against the track has to clear 3:1 (WCAG 1.4.11). Every role is fitted to 4.5:1 against base-100, and the base-300 track sits 1.27:1 (light) and 1.46:1 (dark) from it, which leaves at least 3:1.
  • Windows high contrast re-points the pair at Highlight over Canvas and draws the track’s extent with a CanvasText outline, since the forced palette would otherwise flatten fill and track into the same colour.

Do

Name what is progressing. The element announces the number on its own, so the name carries the subject.

Don't

A bar with no accessible name. It announces “40%” of nothing.

Do

Leave value out while the length of the work is unknown. The sweep says work is happening without claiming a number.

Don't

A made-up 90% that will sit there until the work finishes. A number nobody can trust is worse than no number.