Skeleton screens that are measured, not written. Point it at a component; it renders that component off-screen, asks the browser what the layout came out as, and draws the placeholder from the answer.
npm i @iskeletor/react
npm i @iskeletor/core
The card on the left is ordinary markup. The skeleton on the right was produced from it a
moment ago by @iskeletor/core, which is embedded in this page. Switch the
component and the placeholder follows it — line counts, avatar shape, button widths and
all. Every option the library takes is wired to the panel underneath, so you can see what
each one actually changes before putting it in your code.
You write h-4 w-32 to stand in for a title. Then the title becomes two lines,
the avatar becomes a circle, someone adds a badge — and the skeleton keeps promising a
layout that no longer arrives. Every loading state is a duplicate of the component,
maintained by hand, drifting from the day it was written.
Parsing JSX tells you there is a <div>. It cannot tell you it is 338
pixels wide — that is the cascade, the fonts and the viewport, resolved together.
It computes that layout on every frame. Render the component off-screen and
getBoundingClientRect() hands you the exact answer for free.
The result is plain JSON, cached against the component, its props and the breakpoint. The second loading state costs nothing.
While the card is on screen it gets measured in the background. Every loading state after that is exact and instant — no skeleton markup anywhere in your codebase.
// app/episode.tsx
import { AutoSkeleton } from "@iskeletor/react";
<AutoSkeleton loading={isLoading}>
<EpisodeCard episode={episode} />
</AutoSkeleton>
That is the whole integration. EpisodeCard stays untouched and knows nothing
about iskeletor.
Nothing to configure. The component is measured while visible, and the measurement is keyed to its props, so a different episode measures separately.
<AutoSkeleton loading={isRefreshing}>
<EpisodeCard episode={episode} />
</AutoSkeleton>
On a cold load there is no real element to measure. Hand it a sample with
representative content — same component, placeholder data — and it measures that
instead, so the first paint of a first visit is already exact.
The sample is rendered off-screen through a portal, from inside your own tree, so every provider above it still applies. A card that reads a store, a router and a locale measures like any other. A second React root — the obvious implementation — cannot: context does not cross roots, and in a real application that rules out most components.
<AutoSkeleton
loading={!episode}
sample={<EpisodeCard episode={PLACEHOLDER} />}
>
{episode ? <EpisodeCard episode={episode} /> : null}
</AutoSkeleton>
measure="live" walks the children where they already are. Nothing is
rendered twice, so there is no stand-in to keep in step with the component. The trade is
timing: a layout only exists once the real content has been on screen, so the first load
shows fallback and every load after it is exact.
Either way the measurement waits for the content to settle — images decoded, webfonts loaded, geometry steady for two frames — because a card measured mid-load hands you a skeleton of the loading state rather than of the finished card. Every wait is bounded.
<AutoSkeleton measure="live" id="episode-card" loading={loading}>
<EpisodeCard episode={episode} />
</AutoSkeleton>
Identical, evenly spaced siblings fold into one node carrying a count and a stride. The
skeleton gets as many rows as the component actually rendered — you never pass a
count prop, because the measurement already knows.
<AutoSkeleton loading={loading}>
<CastList cast={cast} /> {/* 4 names in, 4 rows out */}
</AutoSkeleton>
A request for twenty-four products is out. Until it lands there is nothing to
measure, so sample stands in — and whatever it renders is what the
skeleton becomes. Four rows here, because four is what fits on screen, not because
four is what will arrive. Nothing anywhere takes a count.
Twenty-four rows arrive; the skeleton shows four. The measurement carries the count, and the measurement was taken from the sample.
The package carries its own "use client" boundary, so it can be imported
straight into a server component. In Next.js the boundary is declared by the file being
imported, not the file importing it.
// app/page.tsx — a server component, no "use client" needed
import { AutoSkeleton } from "@iskeletor/react";
export default function Page() {
return <AutoSkeleton loading sample={<Card data={SAMPLE} />} fallback={<Box />} />;
}
The server has no layout engine, so it cannot measure anything. Server render and first
client render both produce fallback — identical trees — and measuring only
begins in an effect, after hydration has already agreed. Any other ordering would have the
two renders disagree.
<AutoSkeleton> — the only component most projects need.
| Prop | Default | What it does |
|---|---|---|
| loading | false | Show the skeleton instead of the children. |
| children | — | The real content. Measured whenever it is on screen. |
| sample | — | What to measure when the children cannot render yet. |
| fallback | null | Shown until a measurement exists, and during SSR. |
| id | component name | Cache identity. Set it if minification makes the name unstable. |
| cache | globalLayoutCache | Pass your own LayoutCache to scope or clear measurements. |
| breakpoints | 390 / 768 / 1280 | Measurements are keyed per breakpoint and retaken when one is crossed. |
| premeasure | true | Measure in the background while the real content is visible. |
| measure | "sample" | "sample" renders a stand-in off-screen; "live" walks the children in place. |
| wrapper | — | Wraps the sample before measuring — to pin a locale or a theme that is not the live one. |
| theme | — | baseColor, highlightColor, duration, animate, lineFill, fluid. |
| walk | — | maxDepth, minSize, detectRepeats, collapseWrappers. |
| onMeasure | — | Receives the layout map — useful for persisting it. |
theme — passed straight to the renderer. Every value becomes a CSS custom property on the generated root, so two skeletons on one page can be themed independently while sharing a single stylesheet.
| Option | Default | What it does |
|---|---|---|
| baseColor | #e6e8eb | Resting colour of a placeholder block. |
| highlightColor | #f4f6f8 | Colour of the highlight that travels across it. |
| duration | 1.4 | Seconds for one shimmer sweep. |
| animate | true | Sweep, or draw flat blocks. prefers-reduced-motion wins over this either way. |
| lineFill | 0.72 | How much of a text line box the bar fills. The remainder becomes the gap between lines, which is what makes a paragraph read as text rather than a solid slab. |
| defaultRadius | 4px | Radius for nodes that reported none. A measured radius always wins, so this only reaches the boxes that had none. |
| fluid | false | Write horizontal geometry as percentages instead of measured pixels, so the skeleton stretches to whatever slot it lands in. Only matters when the slot is not the width the component was measured at. |
| className | — | Class name added to the generated root. |
walk — how much of the DOM ends up in the layout map. These change the measurement, so they are part of the cache key: turning one off re-measures rather than handing back the map taken under the old settings.
| Option | Default | What it does |
|---|---|---|
| maxDepth | 12 | Stop descending past this depth. Deep trees add noise, not fidelity. |
| minSize | 4 | Ignore elements smaller than this on either axis, which drops hairlines and spacers. |
| detectRepeats | true | Fold identical, evenly spaced siblings into one node carrying a count and a stride. This is what gives a list the right number of rows without a count prop. |
| repeatTolerance | 4 | Pixels of slack when deciding whether two siblings match. |
| collapseWrappers | true | Drop a container whose single child fills it — the grid cells and links a card arrives inside. Never changes a pixel; it removes a redundant node from the map. |
@iskeletor/core — the framework-free engine underneath.
| Export | Returns | What it does |
|---|---|---|
| measureElement | SkeletonLayout | Walk a live element into a layout map. |
| renderSkeleton | HTMLElement | Build the placeholder DOM from a map. |
| renderSkeletonToString | string | Same output as markup, for SSR or a build step. |
| skeletonFor | HTMLElement | Measure and render in one call. |
| measureWithSandbox | Promise<Layout> | Mount off-screen, settle, measure, clean up. |
| measureResponsive | Record<string, Layout> | One map per breakpoint, in a real nested viewport. |
| LayoutCache | class | LRU store keyed by id, props and viewport. |
Classification is a heuristic: tags, computed styles and shape decide whether something is text, an image, a button or a circle. It is right most of the time and wrong sometimes, so the override lives next to the element rather than in a config file.
<div data-skeleton-type="circle">JD</div> // initials, not a box
<div data-skeleton-ignore>debug panel</div> // leave it out entirely
Options become CSS custom properties on the generated root, so several skeletons can be
themed independently while sharing one stylesheet. The shimmer respects
prefers-reduced-motion whatever you pass.
<AutoSkeleton
loading={loading}
theme={{ baseColor: "#1b2326", highlightColor: "#2f3c41", duration: 1.8 }}
>
@iskeletor/core has no dependencies and no framework imports — it takes an
Element and gives back JSON and DOM. That is exactly what this page is using.
import { measureElement, renderSkeleton } from "@iskeletor/core";
const layout = measureElement(document.querySelector("#card"));
placeholder.append(renderSkeleton(layout));
A Vue or Angular adapter is a new package that mounts a component into the sandbox and hands the container over. Nothing in the core changes.