Designing for the wait
Loading states are the first thing people see, and on a slow connection the thing they see most.
Skullet draws a skeleton of exactly what is about to load, for React, Vue, Svelte and vanilla JavaScript. Your content is laid out unseen and traced, a bar for each line of text and a block for each image or control, so nothing moves when it arrives.
$ npm i skullet
import { Skeleton } from "skullet/react";
<Skeleton data={user} sample={SAMPLE_USER}>
{(user) => <Card user={user} />}
</Skeleton>Optional: Add a SkulletProvider to set a theme, a reveal mode or a CSP nonce for the whole app. Learn more →
The skeleton is your own component. Wrap it and pass a sample of its data: while data is null, your component renders the sample unseen, and that is measured in its place. There’s no second skeleton to write or keep in step, and your component only ever receives real data.
<Skeleton data={user} sample={SAMPLE_USER}>
{(user) => <Profile user={user} />}
</Skeleton>In development, content without a sample logs one to paste: your data’s keys and lengths, with every value replaced.
Toggle loading yourself and the children are measured as they are. Empty text gets a typical line until a reveal remembers the real shape.
<Skeleton loading={!user}>
<h2>{user?.name}</h2>
<p>{user?.bio}</p>
</Skeleton>Some things don’t measure well: a logo, a chart, a canvas. In React, give the component a static .Skeleton and Skullet draws that instead.
import { SkeletonBox } from "skullet/react";
Avatar.Skeleton = () => <SkeletonBox width={40} height={40} radius="50%" />;Build your skeleton from Skullet’s exported <SkeletonBox> rather than a grey box of your own. Each bar joins the page’s one shimmer, in step with every other skeleton however late it mounts, and follows the theme’s colours, animation and reduced motion.
Measuring gets most markup right. Where it doesn’t, mark the element: skullet-block draws it as one block, and skullet-ignore leaves it out.
<div skullet-block="">…</div> <span skullet-ignore="">New</span>
An image with no size is flat until it loads, so its skeleton has no room for it. Give images a width and height, or an aspect-ratio.
A reveal remembers the shape it showed, so its next skeleton is exact. Within a visit it keeps the real content; across visits, only the layout, in localStorage. Pass an id to key it, or Skullet keys it by the page and the markup.
<Skeleton id="inbox" data={messages}>
{(messages) => <Inbox messages={messages} />}
</Skeleton>Shapes stay in localStorage after sign-out, so the next person on the device would see the outline of the last one’s inbox. Call forgetShapes() on sign-out to clear them.
A reveal is the moment content replaces its skeleton. The provider’s reveal sets how it looks.
smarttogethersequencesweep<SkulletProvider reveal="smart"> <App /> </SkulletProvider>
Under smart, data that lands within 150ms shows at once with no skeleton. Set the response to fast to see it.
Images never hold a reveal back. Each one still loading keeps its own skeleton until it has decoded, then fades in. It works with any <img>, next/image included, with no wrapper to swap in.
A skeleton drawn from old data might not match the new data’s shape. The reveal eases to the new height rather than snapping.
Every built-in is a RevealSpec exported from skullet. Spread one and change what you need.
import { sequence } from "skullet";
<SkulletProvider reveal={{ ...sequence, enter: { filter: "blur(6px)" } }}>
<App />
</SkulletProvider>order"together", or a function from each piece to its delay in ms. A piece knows its box, its row, the whole reveal’s box, whether it’s smaller text and how late its reveal landed.enterfilter, translate, scale, rotate, transform and clipPath.wipeout, and effect runs on the incoming pane.delaydurationholdchooseA wipe needs only the incoming mask:
import type { RevealSpec } from "skullet";
const wipeDown: RevealSpec = {
wipe: {
mask: (progress) => {
const at = progress * 120;
return `linear-gradient(#000 ${at - 20}%, transparent ${at}%)`;
},
},
duration: 400,
};
<SkulletProvider reveal={wipeDown}>A reveal of your own still can’t move the page. Images wait behind their own skeleton, a wipe that drops frames finishes as a crossfade, delays and durations stop at 1s, and reduced motion is always instant.
SkeletonSuspense is a Suspense boundary with a measured skeleton as its fallback. Pass the content rendered from sample data, or only an id to draw the shape it last showed.
import { SkeletonSuspense } from "skullet/react";
<SkeletonSuspense skeleton={<Quote quote={SAMPLE_QUOTE} />}>
<LoadedQuote />
</SkeletonSuspense>Vue’s wraps its own <Suspense>: the content that suspends in the default slot, the sample in #skeleton.
<SkeletonSuspense>
<LoadedQuote />
<template #skeleton>
<Quote :quote="SAMPLE_QUOTE" />
</template>
</SkeletonSuspense>In Svelte, pass the promise itself as data. It loads until the promise settles, then shows its value, or its error.
<SkulletProvider
mode="system"
theme={{
animation: "shimmer",
speed: "slow",
text: {
top: "ascender",
bottom: "descender"
}
}}
>
<App />
</SkulletProvider>light, darkanimationshimmer, group-shimmer, pulse or none. Every skeleton on the page keeps one phase, however late it mounts.speedslow or fast.radiusrevealtextascender, cap-height or x-height as the top, down to its baseline or descender as the bottom.Each option is also a custom property, like --skullet-base, so any part of a page can set its own.
Wrap an async server component in SkeletonSuspense, in Next.js or any React Server Components setup. The skeleton is in the streamed HTML, measured before first paint, so it holds the content’s exact space until the content arrives.
import { SkeletonSuspense } from "skullet/react";
async function LoadedProfile() {
const user = await getUser();
return <Profile user={user} />;
}
export default function Page() {
return (
<SkeletonSuspense skeleton={<Profile user={SAMPLE_USER} />}>
<LoadedProfile />
</SkeletonSuspense>
);
}Skip the below step on React 19 or later: the stylesheet is imported for you.
Server HTML paints before any JavaScript runs. An inline script measures each skeleton before that first paint, so nothing changes on hydration. Under a SkulletProvider, the script goes out once a page; without one, each skeleton carries its own copy. The stylesheet has the same problem: import it once, so the server’s HTML is styled before any script runs.
import "skullet/styles.css";
A loading reveal is aria-busy, and its skeleton is hidden from screen readers and inert. A label is read in its place. Under prefers-reduced-motion, skeletons hold still and every reveal is instant.
To move focus once content has arrived, use onReveal.
A skeleton that doesn’t match its content makes the page jump when it reveals. The debug overlay finds those: it outlines every reveal, blue while it loads, with where its skeleton came from, then green if nothing moved or red with how far it did. Turn it on with debug on the provider, or add ?skullet-debug to the URL.
To outline one part of the page instead, put skullet-debug on any element around it. Add skullet-xray to see what was measured: the hidden content shows through the skeleton while it loads.
With TanStack Query or SWR, pass data as it comes. It’s empty until the first response, so the skeleton shows only then; a background refetch keeps the content on screen.
const { data } = useQuery({ queryKey: ["user", id], queryFn });
<Skeleton id="profile" data={data} sample={SAMPLE_USER}>
{(user) => <Profile user={user} />}
</Skeleton>useLazyFetch doesn’t block navigation, so the page shows at once with its skeleton.
<script setup>
import { Skeleton } from "skullet/vue";
const { data } = useLazyFetch("/api/user");
</script>
<template>
<Skeleton :data="data" :sample="SAMPLE_USER">
<template #default="{ data: user }">
<Profile :user="user" />
</template>
</Skeleton>
</template>Return the promise from load without awaiting it, so it streams, and pass it straight to data.
<script>
import { Skeleton } from "skullet/svelte";
// +page.server.ts returns { user: getUser() }
let { data } = $props();
</script>
<Skeleton data={data.user} sample={SAMPLE_USER}>
{#snippet children(user)}
<Profile {user} />
{/snippet}
</Skeleton>For plain HTML, or a framework without a wrapper, import skullet/element once. With loading, the children are measured into a skeleton; call show() with the content.
<skullet-reveal id="profile" loading label="Loading profile">
<div class="card">…sample markup…</div>
</skullet-reveal>
<script type="module">
import "skullet/element";
const reveal = document.getElementById("profile");
reveal.show(await renderProfile());
</script>Its id doubles as the key for its remembered shape. Import skullet/styles.css in the page’s head too, so a skeleton waiting for the script stays blank rather than showing sample content.
Skullet runs in jsdom and happy-dom. Neither has layout, so there’s nothing to measure and every reveal is instant, as under reduced motion. Content is in the document as soon as its data is.
While loading, the sample’s text is in the document too, inside a skeleton that’s aria-hidden. Queries by role skip it; queries by text don’t.
render(<Profile />);
// Not findByText: the sample's name is in the skeleton until then
await screen.findByRole("heading", { name: "Ada Lovelace" });sample is shorter or longer than real data: give it typical lengths.width and height, or an aspect-ratio.skullet-block for one block, skullet-ignore for none, or in React, give a component its own skeleton.sample data, or an id so the shape is remembered. For one placeholder box, use <SkeletonBox>.nonce to SkulletProvider; it goes on the inline script and stylesheets.<Skeleton />data | T | null | undefined | Loading while empty; passed to children once set. |
sample | T | Stands in for data to draw the first skeleton. |
loading | boolean | Shows the skeleton, even over data. |
id | string | Remembers the shape under this key. |
label | string | Read to screen readers while loading. |
skeleton | ReactNode | Shown instead of the measured skeleton. |
empty | boolean | Shows emptyMessage in place of the content. |
emptyMessage | ReactNode | Shown while empty; the content if left out. |
error | ReactNode | Shown in place of the content. |
onReveal | () => void | Called once content has come in over a skeleton. |
<SkeletonSuspense />skeleton | ReactNode | The content, rendered from sample data. |
id | string | Remembers the shape under this key. |
label | string | Read to screen readers while suspended. |
onReveal | () => void | Called once content has come in over the skeleton. |
<SkulletProvider />reveal | "smart" | "sequence" | "together" | "sweep" | RevealSpec | How content replaces its skeleton. |
theme | SkulletTheme | Colours, animation, speed, radius and text. |
mode | "light" | "dark" | "system" | Which colours to use. |
text | SkulletText | How much of each line of text is drawn; overrides the theme’s. |
nonce | string | For a Content Security Policy. |
debug | boolean | Outlines every reveal and whether it moved. |
<SkeletonBox />width | number | string | 60% by default. |
height | number | string | 1em by default. |
radius | number | string | The theme’s radius by default. |
new Reveal()element | HTMLElement | Where the skeleton and content go. |
id | string | Remembers the shape under this key. |
reveal | RevealMode | How content replaces its skeleton. |
label | string | Read to screen readers while loading. |
onReveal | () => void | Called once content has come in over a skeleton. |
load(content?) | Node | string | Shows a skeleton of the content, or of the shape last shown. |
show(content) | Node | string | Reveals the content. |
destroy() | Stops measuring and remembering. |
<skullet-reveal>id | attribute | Remembers the shape under this key. |
loading | attribute | Starts with its children as a skeleton. |
reveal | attribute | How content replaces its skeleton. |
label | attribute | Read to screen readers while loading. |
load(content?) | Node | string | Shows a skeleton. |
show(content) | Node | string | Reveals the content. |
reveal | event | Content has come in over a skeleton. |
forgetShapes() | Clears every remembered shape, as on sign-out. | |
createSkeletonBox(options) | HTMLSpanElement | A SkeletonBox for vanilla JavaScript. |
setDebug(on) | Turns the debug overlay on for the whole page. | |
applyTheme(theme, mode) | Sets a theme for the whole page. | |
themeVariables(theme, mode) | Record<string, string> | A theme as custom properties, for one element’s style. |
skulletCSS({ theme, mode, text, selector }) | string | A theme as CSS rules. |
useReveal() | RevealMode | In React, the reveal the provider sets. |
In Vue and Svelte, <Skeleton> takes the same props; what React passes as nodes comes as slots and snippets.
default slot with { data }, then skeleton, empty and error with { error }. onReveal is a reveal event.children(data), skeleton, emptyMessage and errorMessage(error) snippets. onReveal is onreveal, and data can be a promise.Skullet doesn’t guess at sizes. It lets the browser lay out the real content, then reads the result.
Range.getClientRects(), which returns one box per line it wraps to. Images, controls and painted boxes such as buttons and chips are measured whole.measureText: ascender to descender by default.ResizeObserver, a MutationObserver, image loads and font loads trigger a re-measure. On the server, an inline script measures before the first paint.The unseen content comes from the closest data Skullet has: the data on screen, the data last shown under the same id, a shape remembered from an earlier visit, then your sample.
If you like this, you might also like these.