Every design system has a progress bar. It is a rounded rectangle that fills from left to right, and in most systems that is where the thinking stopped.
Which is odd, because it is one of the few components whose entire job is to communicate a number to someone who is waiting. That is a real design problem, and the answer everyone shipped is a rectangle.
I have been building a small set of progress and data components to see what happens if you take the rendering seriously and leave everything else alone.
Same semantics, different picture
The component exposes three renderers behind one API. A variant prop selects between a braided wave, an ordered-dither field and a layered dither surface. Nothing else changes.
That constraint is the whole design. Switching renderer must not change:
role="progressbar"on the elementaria-valuenow, present when determinate and absent when not- the value, the bounds, or how a screen reader announces any of it
A screen reader cannot tell which painter is active, and it should not be able to. The visual is a presentation choice layered on top of a component whose contract does not move. It is the same separation I route model work by: deciding and rendering are different jobs.
The indeterminate case is where this gets tested. An unknown-duration state has to drop aria-valuenow entirely — announcing a fake percentage is worse than announcing nothing — while the renderer keeps animating. The two concerns have to be genuinely separate, or you end up with a wave that looks busy and a screen reader claiming 0%.
The numbers, because otherwise this is decoration
A component that renders continuous animation on a canvas invites a fair question: what does it cost? So the repository carries a standing bundle audit and a browser benchmark, and they run on every change.
| Check | Result |
|---|---|
| Component JS | 7,834 bytes gzip |
| Component CSS | 2,575 bytes gzip |
| Total | 10,409 bytes gzip |
| 50 wave instances | 120 FPS mean |
| 50 dither instances | 120 FPS mean |
| 50 dither instances, assembly effect | 120 FPS mean |
| 50 indeterminate instances | 120 FPS mean |
One detail about that benchmark matters more than the number. It keeps all fifty instances visible and hovers one of them while sampling, so the measured frame budget includes the active geometry morph. A benchmark that measures fifty idle components tells you almost nothing; the expensive frame is the one where something is changing.
React is the only runtime dependency the installed component carries.
Two things that stop it costing more than it should
Offscreen instances pause. A component animating below the fold is burning frames for nobody. Phase and indeterminate animation stop when the instance is not visible — which is what makes fifty simultaneous instances a benchmark rather than a warning.
Reduced Motion is a tested path, not a comment. There is a real difference between honouring prefers-reduced-motion and having a test that fails when you stop honouring it. The repository has the second kind.
Source-owned rather than a dependency
It installs through the shadcn registry, which means the files land in your repository rather than behind a version number:
npx shadcn@latest add https://example.com/r/wave-progress.json
The registry is not published yet, so that URL is illustrative rather than something you can run today. I have come round to this model for anything visual. A progress bar is exactly the kind of component a team wants to tweak — the wave amplitude is wrong for their brand, the milestone markers need different copy — and doing that against an npm package means either a fork or a config surface that grows forever.
Owning the source means the tweak is a diff in your own repo. The cost is that you do not get updates for free, which for a component this size is a trade I would take.
What I would not claim
- I do not know if it helps anyone wait. The premise is that a more legible progress indicator makes waiting less unpleasant. That is an assumption. I have not tested it with users, and a 120 FPS benchmark says nothing about whether the thing is actually better to look at.
- Three renderers is a maintenance surface. Every one of them has to keep the ARIA contract, survive Reduced Motion, and not regress the geometry tests. That is a real ongoing cost for a visual choice.
- The numbers are local. Measured on my machine, re-run on each change, and they will move with host load. They are a floor to reason about, not a guarantee.
Next
What generalises beyond this component:
- Changing how something looks should not change what it announces. If the renderer can move the semantics, the abstraction is in the wrong place.
- Benchmark the expensive frame. Fifty idle instances is not a measurement.
- Reduced Motion needs a failing test, not a media query and good intentions.
- For visual components, source ownership beats a config surface that grows forever.
Next I want the thing I just said I do not have: some evidence about whether a more legible indicator changes how long a wait feels. That is a user question, not a benchmark, and I have not worked out how to ask it honestly yet.
If you have replaced a default progress component with something of your own, I would like to know what pushed you to — and whether anyone noticed.
Figures are from the repository's standing bundle audit and browser benchmark, measured locally and re-run on each change. They will vary with host load.
Building something similar?
I write about setups I actually use. If you're working on something comparable, I'd be curious what your workflow looks like.