Skullet

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.

Install

$ 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 →

From your component

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.

Click to reload
<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.

Plain children

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>

Your own 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.

Hints

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.

Memory

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.

Reveal modes

A reveal is the moment content replaces its skeleton. The provider’s reveal sets how it looks.

smart
The default. Instant for a fast load, a crossfade for something small, a sequence for the rest.
together
Crossfades the whole skeleton into the content.
sequence
Brings each piece in where its skeleton was, top to bottom.
sweep
Uncovers the content behind the shimmer’s front.
Click to reload
Reveal
Response
<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

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.

Loading gallery
Click to reload

Changing size

A skeleton drawn from old data might not match the new data’s shape. The reveal eases to the new height rather than snapping.

Click to reload

Your own reveal

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.
enter
Where each piece starts, on top of the fade. Only what paints: filter, translate, scale, rotate, transform and clipPath.
wipe
A mask across the whole reveal, drawn each frame from progress 0 to 1. The outgoing skeleton gets its inverse unless you pass out, and effect runs on the incoming pane.
delay
In ms, before the whole reveal starts.
duration
In ms. 0 is instant.
hold
How long a skeleton waits before it shows, so fast data never shows one.
choose
Decides as content lands, from how long the skeleton was up, recent input, refetches and size. What it returns wins, and the fields beside it fill the rest.

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

Suspense

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.

Loading quote
Click to suspend again
Fallback
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.

Theming

<SkulletProvider
  mode="system"
  theme={{
    animation: "shimmer",
    speed: "slow",
    text: {
      top: "ascender",
      bottom: "descender"
    }
  }}
>
  <App />
</SkulletProvider>
Colour
Preview
Base
Highlight
Shape
Radius
Text top
Text bottom
Motion
Animation
Speed
Reveal
Enter
light, dark
The skeleton’s fill and highlight, per colour scheme.
animation
shimmer, group-shimmer, pulse or none. Every skeleton on the page keeps one phase, however late it mounts.
speed
slow or fast.
radius
How round each line of text is drawn, as any CSS length.
reveal
How content replaces its skeleton. Set on the provider, not the theme; see reveals.
text
How much of each line of text is drawn: from its ascender, 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.

Server rendering

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";

Accessibility

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.

Debugging

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.

Loading article
Click to reload

Data fetching

TanStack Query and SWR

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>

Nuxt

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>

SvelteKit

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>

Web component

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.

Testing

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" });

Troubleshooting

The page jumps on reveal
Turn on debugging to see where the skeleton came from and how far the content moved. Most often the sample is shorter or longer than real data: give it typical lengths.
An image moves when it loads
It had no size until it loaded, so its skeleton had no room for it. Give it a width and height, or an aspect-ratio.
Something is drawn the wrong way
Mark it with a hint: skullet-block for one block, skullet-ignore for none, or in React, give a component its own skeleton.
Three grey lines and a warning
There was nothing to measure. Pass sample data, or an id so the shape is remembered. For one placeholder box, use <SkeletonBox>.
Lines shift as a web font loads
Skeletons measure again once fonts load, so the first frame uses the fallback font. Preload the font to measure it from the start.
A Content Security Policy blocks the script
Pass your nonce to SkulletProvider; it goes on the inline script and stylesheets.

API

<Skeleton />

dataT | null | undefinedLoading while empty; passed to children once set.
sampleTStands in for data to draw the first skeleton.
loadingbooleanShows the skeleton, even over data.
idstringRemembers the shape under this key.
labelstringRead to screen readers while loading.
skeletonReactNodeShown instead of the measured skeleton.
emptybooleanShows emptyMessage in place of the content.
emptyMessageReactNodeShown while empty; the content if left out.
errorReactNodeShown in place of the content.
onReveal() => voidCalled once content has come in over a skeleton.

<SkeletonSuspense />

skeletonReactNodeThe content, rendered from sample data.
idstringRemembers the shape under this key.
labelstringRead to screen readers while suspended.
onReveal() => voidCalled once content has come in over the skeleton.

<SkulletProvider />

reveal"smart" | "sequence" | "together" | "sweep" | RevealSpecHow content replaces its skeleton.
themeSkulletThemeColours, animation, speed, radius and text.
mode"light" | "dark" | "system"Which colours to use.
textSkulletTextHow much of each line of text is drawn; overrides the theme’s.
noncestringFor a Content Security Policy.
debugbooleanOutlines every reveal and whether it moved.

<SkeletonBox />

widthnumber | string60% by default.
heightnumber | string1em by default.
radiusnumber | stringThe theme’s radius by default.

new Reveal()

elementHTMLElementWhere the skeleton and content go.
idstringRemembers the shape under this key.
revealRevealModeHow content replaces its skeleton.
labelstringRead to screen readers while loading.
onReveal() => voidCalled once content has come in over a skeleton.
load(content?)Node | stringShows a skeleton of the content, or of the shape last shown.
show(content)Node | stringReveals the content.
destroy()Stops measuring and remembering.

<skullet-reveal>

idattributeRemembers the shape under this key.
loadingattributeStarts with its children as a skeleton.
revealattributeHow content replaces its skeleton.
labelattributeRead to screen readers while loading.
load(content?)Node | stringShows a skeleton.
show(content)Node | stringReveals the content.
revealeventContent has come in over a skeleton.

Functions

forgetShapes()Clears every remembered shape, as on sign-out.
createSkeletonBox(options)HTMLSpanElementA 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 })stringA theme as CSS rules.
useReveal()RevealModeIn 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.

Vue
The default slot with { data }, then skeleton, empty and error with { error }. onReveal is a reveal event.
Svelte
children(data), skeleton, emptyMessage and errorMessage(error) snippets. onReveal is onreveal, and data can be a promise.

How it works

Skullet doesn’t guess at sizes. It lets the browser lay out the real content, then reads the result.

  • Layout by the browser. Your content renders in place from the closest data there is, with its text made transparent and its media hidden. Every size and line break is the browser’s own.
  • Lines from the DOM. Each text node is measured with Range.getClientRects(), which returns one box per line it wraps to. Images, controls and painted boxes such as buttons and chips are measured whole.
  • Heights from the font. A line box is taller than its letters, so each bar is trimmed to the font’s own metrics, read once per font with canvas measureText: ascender to descender by default.
  • Drawn on top. The bars are absolutely positioned over the unseen content, in a shadow root the page’s CSS can’t reach. Every bar shares one shimmer, timed by the document clock and its place on the page.
  • Kept current. A 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.

More by Lochie

If you like this, you might also like these.