Loading
A wait is not a spinner. Seven rungs, one light, one timing contract.
Most systems ship a spinner and call waiting solved. Lumen treats a wait as a question — how much of this screen do I not know yet? — and the answer picks the state. Seven rungs, all made of the same light, all over one timing contract.
The one line
Waiting looks like light moving through living tissue — and the fastest waits look like nothing at all.
The ladder
Read it top to bottom: each rung knows less than the one above it. Reach for the highest rung you can honestly stand on — a skeleton where you knew the percentage is a wasted answer, and a percentage you invented is a lie.
| Rung | What you know | Reach for |
|---|---|---|
| Nothing | it resolves inside --wait-delay | render nothing |
| Fraction | how far along it is | Progress |
| Stale | you already have an answer on screen | LoadingOverlay |
| Shape | the layout of what's coming | Skeleton · SkeletonText · SkeletonGroup |
| Control | one action is working | Spinner · <Button loading> |
| Channel | neither shape nor duration | Beam · RouteProgress |
| Presence | the wait owns the screen | Loader |
The timing contract
Timing is tokens, not folklore — the same three numbers in CSS and in TypeScript, checked by the truth script so they can't drift apart.
| Token | Value | The rule |
|---|---|---|
--wait-delay | 180ms | Under this, a wait is not a wait. Show nothing — a spinner that flashes for 90ms reads as a glitch, not as progress. |
--wait-min | 420ms | Once a loading state is admitted, it holds this long. A response landing at 200ms must not make the screen strobe. |
--wait-long | 6s | Past this the wait is abnormal. Don't get quieter — get honest: escalate the copy, and offer the out if there is one. |
One hook enforces all three, and every component that owns a whole wait calls it internally:
import { usePending } from 'lumen/react';
const { pending, long } = usePending(fetcher.state !== 'idle');
if (!pending) return <Results data={data} />;
return <SkeletonGroup label={long ? 'Still working…' : 'Loading results'}>…</SkeletonGroup>;Run the same request at three speeds. The 80ms one never renders a loading state at all — that is the contract doing its job, not a bug:
Resting heart rate
54 bpm — down 3 from your 30-day median, and the lowest reading since the Tuesday you started the iron protocol.
Who owns the clock
RouteProgress and LoadingOverlay apply the contract for you — they own a whole wait. The
primitives (Skeleton, Spinner, Beam, Loader) never time themselves: they render when you say so,
so the contract lives in exactly one place instead of seven.
The material — one channel, four views
Every loading state is a view of the same channel — one lumen: light and
cells through the body's vessels, ⊙ transillumination, the same read as the Solar gradient and the
Living Lens. And every view runs on the one tempo (--vital-tempo,
1.25s) — the light travels on the same pulse the cells beat to.
| View | The channel, … | Tempo |
|---|---|---|
| Beam | …straight on — light running the vessel | ×1 |
| Orbit | …looped — the beam bent into a ring; the head is the light-front | ×0.75 |
| Sweep | …diffused — light passing behind tissue | ×1.5 |
| Loader | …inhabited — cells riding the pulse (the six rhythms) | ×1 · triple meter |
Day's light is a warm white highlight; Night's is an amber ember (--sheen) — light in the dark, never
a grey flash. Every one of them has a resting pose: under prefers-reduced-motion the light stops
travelling but the surface still reads as pending. Nothing ever becomes a blank hole.
The rules
Skeletons must tell the truth about shape. A skeleton is a promise that the answer looks like this.
If you don't know the layout, that promise is a lie — drop to the channel rung (Beam) instead. And
match the real thing: a 3-line skeleton in front of a 12-line answer is worse than no skeleton, because
the page jumps the moment it lands.
First load gets skeletons; every load after gets a veil. Replacing real data with grey boxes on every
refresh destroys information the user already had. LoadingOverlay keeps the stale answer readable
underneath. The exception is a refresh that reshapes the layout completely — then you're back to shapes.
Resting heart rate
54 bpm — down 3 from your 30-day median, and the lowest reading since the Tuesday you started the iron protocol.
Keep the label. A button that swaps its words for a spinner makes the user re-read it to find out
what they just did. <Button loading> keeps the label and adds the spinner beside it.
Never fake a percentage. Progress takes value={null} for exactly this reason: an indeterminate bar
is honest, an invented one isn't.
One loading state per region. A skeleton inside a veil inside a page bar is three answers to one question. Pick the rung and commit.
Design the ending too. A wait that vanishes mid-beat wastes its own payoff. Wrap the swap in
Arrival: the loader lands, the light draws
home, and the answer rises on the exhale — one settle-beat each way.
Resting heart rate
54 bpm — down 3 from your 30-day median, and the lowest reading since the Tuesday you started the iron protocol.
Voice
Name the thing you're waiting on. "Loading" is a status; "Loading your biograph" is an answer.
| Instead of | Say |
|---|---|
| Loading… | Loading your biograph |
| Please wait | Syncing Oura — about 10 seconds |
| (silence at 30s) | Still working. This is slower than usual. |
| Error: timeout | Couldn't reach Oura. Retry |
Past --wait-long, escalate once and stop. Repeated reassurance reads as panic.
Accessibility
- One announcement per region, not per shape.
Skeletonisaria-hiddenby contract;SkeletonGroupcarries therole="status"and the polite live region. That's why the group'slabelis required. aria-busyon the thing that's busy — the button, the region, the overlay wrapper.Button loadingandLoadingOverlayset it for you.- Indeterminate means no
aria-valuenow.Progresswithvalue={null}omits it, so assistive tech says "busy" instead of reading a number nobody computed. - Decorative spinners stay out of the tree.
SpinnerandBeamarearia-hiddenunless you give them alabel— inside an already-announced control, a second announcement is noise. - Reduced motion is a resting pose, never a removal. Every scale keeps a still, legible state.
Recipes
Route-scale navigation (React Router v7) — the page you're leaving stays legible; no white flash:
import { useNavigation } from 'react-router';
import { RouteProgress } from 'lumen/react';
export function Layout({ children }) {
const nav = useNavigation();
return (
<>
<RouteProgress active={nav.state !== 'idle'} />
{children}
</>
);
}A region whose shape you know — Arrival owns the ending, so the answer rises in instead of
snapping:
const { pending, long } = usePending(isLoading);
return (
<Arrival
pending={pending}
placeholder={
<SkeletonGroup label={long ? 'Still working…' : 'Loading your plan'}>
<Skeleton shape="line" width="40%" height={11} />
<SkeletonText lines={3} className="mt-3" />
</SkeletonGroup>
}
>
<Plan data={data} />
</Arrival>
);A refresh over content you already showed:
<LoadingOverlay active={revalidating} label="Refreshing your timeline">
<Timeline events={events} />
</LoadingOverlay>An action:
<Button loading={submitting}>Log this reading</Button>→ The components, with every state rendered · Animation — the motion families · The loader zoo