Skeleton#

The shape of what is coming. One div around the real content, with a loading model that defaults to true and aria-busy while it holds. The children stay in the DOM in both states — a skeleton exists to hold the layout its content will occupy, so the design system paints over the content while loading and paints nothing once it has arrived.

Import#

TSX
import { Skeleton } from '@sigx/zero/skeleton';

Skeleton is a compound with a single member, Skeleton.Root; <Skeleton> is also callable directly. It is also re-exported from the @sigx/zero root, together with skeletonAnatomy.

Usage#

TSX
import { component } from 'sigx';
import { Skeleton } from '@sigx/zero/skeleton';

const Article = component(({ signal }) => {
    const state = signal({ pending: true, title: '', summary: '' });

    load().then((a) => {
        state.title = a.title;
        state.summary = a.summary;
        state.pending = false;
    });

    return () => (
        <Skeleton.Root model={() => state.pending}>
            <h2>{state.title || 'Loading a title of about this length'}</h2>
            <p>{state.summary || 'A summary paragraph that is roughly the size of the real one.'}</p>
        </Skeleton.Root>
    );
});

model={() => state.pending} binds the loading state both ways; the component never writes it itself, so in practice it reads state.pending and follows. Writing false flips the root to loaded and drops aria-busy. See Models.

Give the children their final shape while loading — placeholder text of about the right length, a fixed-size box where the image goes — because the children are the layout. If they were swapped out for a placeholder, the box would be the wrong size and the page would jump when the real thing arrived.

Uncontrolled#

TSX
<Skeleton.Root defaultLoading>
    <p>…</p>
</Skeleton.Root>

Leave the model off and the state lives inside the component, seeded by defaultLoading (which defaults to true). Since nothing inside the component ever changes it, an uncontrolled skeleton is a skeleton that stays loading — useful for a static placeholder row while a whole list is fetched, and for stories and tests. loadingChange fires whenever the state changes, either way.

Announcement#

Skeleton uses aria-busy rather than a live region: assistive technology is told this region is being updated, and told once it settles. A skeleton that announced itself would interrupt for something that is, by definition, not content yet.

Anatomy#

PartElementStatesFlagsNotes
rootdivloading | loadedaria-busy="true" while loading. Carries the variant axes.

One part, two states, and the children stay in the DOM through both. There is no hiddenIn, which is the difference from Avatar's swap: nothing is hidden in either state, so the two are told apart by paint — every design system has to make loading and loaded look different, and the design-system tooling's state-legibility guard says so if one does not. See The anatomy contract.

Props#

Skeleton.Root#

PropTypeDefaultDescription
modelbooleanTwo-way binding of the loading state.
defaultLoadingbooleantrueInitial state when uncontrolled.
loadingChangeevent (loading: boolean)Fires whenever the loading state changes.
color / size / variant / axes / modsdesign-system vocabularyThe variant axes, rendered as data-* on root.
classstringExtra classes on the root element.

There is no asChild: the root is a plain wrapper whose only job is to carry the state and hold the box.

In the shipped design systems#

Both @sigx/zero-basic and @sigx/zero-daisyui wire color (the eight recommended roles) and size (xsxl) on skeleton, so <Skeleton.Root color="neutral" size="sm"> is styled in both. Neither wires a variant or any mods on the scope; under a design system's /register import the variant prop is therefore absent. See Typed vocabulary.

A recipe paints the loading state as a fill over the children — a background on the root plus, conventionally, transparent ink so the placeholder text sizes the box without being read — and paints nothing in loaded. The shimmer that usually rides the fill is a looping animation, and a loop has to stop under prefers-reduced-motion: reduce, not speed up: give it a literal duration and switch it off in the media query. A --duration-* token is the wrong tool here — every declared duration token collapses to about zero under reduced motion, which turns a shimmer into a strobe.

CSS
[data-scope="skeleton"][data-part="root"][data-state="loading"] {
    animation: shimmer 1.6s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
    [data-scope="skeleton"][data-part="root"][data-state="loading"] {
        animation: none;
    }
}

The root's tokens are color, radius-box and size, so a skeleton rounds like the Card it usually stands in for.

Spinner and Progress signal that work is happening; Skeleton signals where the result will go.