Lumen
Content

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

RungWhat you knowReach for
Nothingit resolves inside --wait-delayrender nothing
Fractionhow far along it isProgress
Staleyou already have an answer on screenLoadingOverlay
Shapethe layout of what's comingSkeleton · SkeletonText · SkeletonGroup
Controlone action is workingSpinner · <Button loading>
Channelneither shape nor durationBeam · RouteProgress
Presencethe wait owns the screenLoader

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.

TokenValueThe rule
--wait-delay180msUnder this, a wait is not a wait. Show nothing — a spinner that flashes for 90ms reads as a glitch, not as progress.
--wait-min420msOnce a loading state is admitted, it holds this long. A response landing at 200ms must not make the screen strobe.
--wait-long6sPast 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:

Respond inidle
MK

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

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

halo.app/biograph
MK

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.

MK

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 ofSay
Loading…Loading your biograph
Please waitSyncing Oura — about 10 seconds
(silence at 30s)Still working. This is slower than usual.
Error: timeoutCouldn't reach Oura. Retry

Past --wait-long, escalate once and stop. Repeated reassurance reads as panic.

Accessibility

  • One announcement per region, not per shape. Skeleton is aria-hidden by contract; SkeletonGroup carries the role="status" and the polite live region. That's why the group's label is required.
  • aria-busy on the thing that's busy — the button, the region, the overlay wrapper. Button loading and LoadingOverlay set it for you.
  • Indeterminate means no aria-valuenow. Progress with value={null} omits it, so assistive tech says "busy" instead of reading a number nobody computed.
  • Decorative spinners stay out of the tree. Spinner and Beam are aria-hidden unless you give them a label — 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 knowArrival 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

On this page