Components
Wavy progress
Progress drawn as a travelling wave: the done part ripples while work is happening, the rest is a flat track that ends in a stop mark, and the wave lies flat once the work is finished. As a bar or a ring, with an indeterminate sweep when the amount is unknown.
pnpm dlx shadcn@latest add @caliper/wavy-progressWhen to use
Use it for
- Uploads, exports and installs where a little life tells people the work has not stalled.
- Media apps and friendly products where a flat bar feels too clinical.
Skip it when
- Dense tables of jobs; use `Progress`, which stays quiet in a column.
- Values that only move every few seconds; the wave suggests steady activity.
Decisions
Why it is built this way, so you know what to keep when you change your copy.
Behaviour
- Flat when done
- The wave stands for work happening. At 100% it lies flat, so a finished bar reads as finished without a colour change.
- Reduced motion
- The wave is replaced by a flat bar or ring; the indeterminate sweep still moves, slower, because it is how the control says it is busy.
Styling
- A mask, not a drawing
- The wave is a repeating mask over a plain fill, so its wavelength stays the same at any width and the fill is still one transition.
Installation
pnpm dlx shadcn@latest add @caliper/wavy-progress
Adds its 2 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.
Usage
This is the code behind the preview above.
import { useEffect, useState, type ReactNode } from "react" import { Button } from "@/components/ui/button" import { WavyProgress } from "@/components/ui/wavy-progress" function Panel({ title, context, className = "max-w-md", children }: { title: string; context: string; className?: string; children: ReactNode }) { return ( <section className={`grid w-full gap-5 rounded-overlay border border-border bg-background p-6 shadow-resting max-sm:p-4 ${className}`}> <div className="grid gap-1"> <p className="font-medium">{title}</p> <p className="text-sm text-muted-foreground">{context}</p> </div> {children} </section> ); } export function WavyProgressDemo() { const [run, setRun] = useState(0); const [value, setValue] = useState(0); useEffect(() => { let current = 0; const timer = window.setInterval(() => { current = Math.min(100, current + 2 + Math.random() * 5); setValue(current); if (current >= 100) window.clearInterval(timer); }, 220); return () => window.clearInterval(timer); }, [run]); return ( <Panel title="Exporting “Q3 review.mov”" context="1.4 GB · 4K · H.265"> <WavyProgress value={value} label="Export" showValue /> <div className="flex flex-wrap items-center gap-4"> <WavyProgress variant="circular" value={value} label="Export" showValue size={52} /> <WavyProgress variant="circular" label="Preparing thumbnails" size={36} /> <p className="min-w-0 flex-1 text-sm text-muted-foreground">{value >= 100 ? "Saved to Exports" : "Preparing thumbnails…"}</p> <Button size="sm" variant="secondary" disabled={value < 100} onClick={() => { setValue(0); setRun((count) => count + 1); }} > Export again </Button> </div> <WavyProgress label="Waiting for the server" /> </Panel> ); }
API reference
WavyProgress
component
Also accepts the native props of its root element and a ref.
Accessibility
It is built on native elements and keeps aria-hidden in step with state.
No custom key handling; Tab and the native activation keys do the work.
Motion
It declares no animation in its component source. Any transition comes from its stylesheet, which uses the duration tokens that collapse to instant under prefers-reduced-motion.