Loader and Skeleton

Two components for content that is still loading. Loader.Root is a native <progress> with no value, and Skeleton.Root a placeholder div. Server Components can import them from @tounsoo/todae/loader and @tounsoo/todae/skeleton.

On this page

Loader

Loader.Root keeps the progressbar role, so screen readers announce it as a progress bar, and it needs a name like any progress: wrap it in a <label> with the text, or give it aria-label. By default, as variant="spinner", it is drawn as a ring with one arc of fill that turns, and it holds still when the user asks for reduced motion. It takes no value: a wait with a known end is a Progress bar.

<label>
  <Loader.Root /> Loading invoices
</label>

<Loader.Root aria-label="Saving" />

Dots

variant="dots" draws three dots that rise in turn, starting from the side the text starts on, as a typing indicator does. Use it where someone or something is writing a reply, such as an assistant in a chat. It is the same <progress>, with the same role, so it needs a name too; under reduced motion the dots hold still.

<Loader.Root variant="dots" aria-label="Assistant is typing" />

Either variant draws in the text color, so it shows on any surface, a primary button included: set color to change it. Restyle it through loader.size (the dots are one and a half times as wide), loader.thickness, loader.track-tint (how much of the text color the track takes) and loader.motion.duration (one wave of the dots); loader.thickness and loader.track-tint only change the spinner.

Skeleton

Skeleton.Root stands in for content before it arrives. It is at least one line high, and elements inside it only give it a size: they are hidden. It pulses, and holds still under reduced motion.

<div>
  <VisuallyHidden.Root>Loading profile</VisuallyHidden.Root>
  <Stack.Root direction="row" aria-hidden="true">
    <Skeleton.Root style={{ inlineSize: '2.5rem', blockSize: '2.5rem', borderRadius: '50%' }} />
    <Stack.Root style={{ flex: 1 }}>
      <Skeleton.Root style={{ inlineSize: '40%' }} />
      <Skeleton.Root style={{ inlineSize: '70%' }} />
    </Stack.Root>
  </Stack.Root>
</div>
Loading profile

A skeleton means nothing to a screen reader, so hide it with aria-hidden and say what is loading in text. Restyle it through skeleton.bg, skeleton.radius, skeleton.motion.duration, skeleton.motion.easing and skeleton.pulse.opacity, the opacity the pulse fades to.

Accessible composition

Todae handles: Style only. Loader.Root stays a native <progress> with the progressbar role and never takes a value, so it is always announced as busy. It holds still under reduced motion. Skeleton.Root hides the elements inside it with visibility: hidden, which also hides them from assistive technology, but bare text directly inside it is only made transparent and is still read.

You supply: A name for each loader, from a <label> or aria-label, that says what is loading. aria-hidden on each skeleton, with the loading text next to it, such as in a VisuallyHidden, as above. When the content arrives, it replaces both.

Watch for: A loader with aria-hidden and no other text, so nothing says the area is busy. A loader in every row of a list, each read as a progress bar; one for the whole area reads better.

When to use

When: Spinner → a short wait for one action or a small area, such as a save or a search. Dots → a reply being written. Skeleton → the layout of a page or section is known before its content arrives.

When not: The wait has a known end → Progress with a value. The wait is long or can fail → say so in text, with a way to cancel or retry.

Props

Loader.Root props
NameTypeDescription
variant"spinner" | "dots"

spinner turns one arc of a ring; dots sends a wave through three dots, as a typing indicator does. Sets data-variant.

Default: "spinner"

aria-labelstring

Names the indicator when it is not inside a label with text. It keeps the progressbar role, so it needs a name.

Skeleton.Root props
NameTypeDescription
childrenReact.ReactNode

Elements that only give it a size; they are hidden.