iskeletor.

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
Packages
@iskeletor/react
@iskeletor/core
Dependencies
None in core
Peer
react ≥ 18
Verified on
Next 15.5, Next 16.3
Size
9.5 kB gzipped
both packages
Licence
MIT
Measure

This is the library running, not a picture of it

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.

Live component
Generated skeleton
Shimmer
Sweep 1.5s
Line fill 0.72
Radius 3px
Walk
Max depth 12
Min size 4px
Layout
Simulated width full

Narrow the panel and the text rewraps, so the measurement — and the skeleton — changes with it.

nodes cached 0
Why

Hand-written skeletons are a copy of your layout that nobody keeps updated

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.

Static analysis can't help

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.

The browser already knows

It computes that layout on every frame. Render the component off-screen and getBoundingClientRect() hands you the exact answer for free.

Measure once, reuse all session

The result is plain JSON, cached against the component, its props and the breakpoint. The second loading state costs nothing.

Start

Wrap the component you already have

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.

Patterns

Three situations, and what each one needs

The data is already there

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>

The data has not arrived yet

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>

Or skip the sample and measure the real thing

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>

A list, where the row count matters

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>
Cold load

How many rows, when the data has not arrived?

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.

idle 0.0s
sample · 4 rows measured

Twenty-four rows arrive; the skeleton shows four. The measurement carries the count, and the measurement was taken from the sample.

Next.js

App Router works without a directive in your file

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 />} />;
}

Why hydration stays clean

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.

API

Reference

<AutoSkeleton> — the only component most projects need.

PropDefaultWhat it does
loadingfalseShow the skeleton instead of the children.
childrenThe real content. Measured whenever it is on screen.
sampleWhat to measure when the children cannot render yet.
fallbacknullShown until a measurement exists, and during SSR.
idcomponent nameCache identity. Set it if minification makes the name unstable.
cacheglobalLayoutCachePass your own LayoutCache to scope or clear measurements.
breakpoints390 / 768 / 1280Measurements are keyed per breakpoint and retaken when one is crossed.
premeasuretrueMeasure in the background while the real content is visible.
measure"sample""sample" renders a stand-in off-screen; "live" walks the children in place.
wrapperWraps the sample before measuring — to pin a locale or a theme that is not the live one.
themebaseColor, highlightColor, duration, animate, lineFill, fluid.
walkmaxDepth, minSize, detectRepeats, collapseWrappers.
onMeasureReceives 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.

OptionDefaultWhat it does
baseColor#e6e8ebResting colour of a placeholder block.
highlightColor#f4f6f8Colour of the highlight that travels across it.
duration1.4Seconds for one shimmer sweep.
animatetrueSweep, or draw flat blocks. prefers-reduced-motion wins over this either way.
lineFill0.72How 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.
defaultRadius4pxRadius for nodes that reported none. A measured radius always wins, so this only reaches the boxes that had none.
fluidfalseWrite 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.
classNameClass 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.

OptionDefaultWhat it does
maxDepth12Stop descending past this depth. Deep trees add noise, not fidelity.
minSize4Ignore elements smaller than this on either axis, which drops hairlines and spacers.
detectRepeatstrueFold 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.
repeatTolerance4Pixels of slack when deciding whether two siblings match.
collapseWrapperstrueDrop 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.

ExportReturnsWhat it does
measureElementSkeletonLayoutWalk a live element into a layout map.
renderSkeletonHTMLElementBuild the placeholder DOM from a map.
renderSkeletonToStringstringSame output as markup, for SSR or a build step.
skeletonForHTMLElementMeasure and render in one call.
measureWithSandboxPromise<Layout>Mount off-screen, settle, measure, clean up.
measureResponsiveRecord<string, Layout>One map per breakpoint, in a real nested viewport.
LayoutCacheclassLRU store keyed by id, props and viewport.
Control

When the guess is wrong, say so in the markup

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

Theming

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 }}
>
Core

No framework required

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

Status

What works, and what does not yet

  • WorkingMeasurement, rendering, session cache, repeat detection, breakpoint-aware remeasuring, SSR-safe fallbacks, theming, manual overrides.
  • WorkingSamples measure through a portal, so components that depend on application context measure like any other — and content already on screen can be measured in place instead.
  • Tested96 unit tests — 53 on the core engine, 43 on the React adapter — plus headless-browser verification on Next 15 and Next 16, a clean install from the registry, and a real third-party application.
  • LimitA repeat folds only when its positions can be reproduced exactly — as a line, or as a grid with columns and a row pitch. Ragged layouts stay expanded, which costs map size but never accuracy.
  • LimitA component that renders nothing measurable is never cached, and its fallback stays up — better than pinning an empty skeleton in place for the session.
  • NextVue and Angular adapters, persisting the cache to storage, and build-time measurement with a headless browser so SSR can ship a real skeleton.