npm.io
0.2.1 • Published 2h ago

@iskeletor/react

Licence
MIT
Version
0.2.1
Deps
1
Size
100 kB
Vulns
0
Weekly
0

@iskeletor/react

Skeleton loading 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.

import { AutoSkeleton } from "@iskeletor/react";

<AutoSkeleton loading={isLoading}>
  <ProductCard product={product} />
</AutoSkeleton>

That is the whole integration. ProductCard stays untouched and knows nothing about iskeletor.

npm i @iskeletor/react

Documentation and a live demo — switch the component on the page and watch the skeleton be re-measured in your browser.


Why not just write the skeleton

You write h-4 w-32 to stand in for a title. Then the title wraps to 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 the JSX would not help: it can tell you there is a <div>, not that the div is 338 pixels wide. That number is the cascade, the fonts and the viewport resolved together — and the browser already computes it on every frame. So we ask the browser.

Three situations

The data is already there. Nothing to configure. The component is measured in the background while it is visible, keyed to its props, so the next loading state is exact and instant.

<AutoSkeleton loading={isRefreshing}>
  <ProductCard product={product} />
</AutoSkeleton>

The data has not arrived yet. On a cold load there is no real element to measure. Hand it a sample — the same component with placeholder data — and it measures that instead, so the first paint of a first visit is already exact.

<AutoSkeleton loading={!product} sample={<ProductCard product={PLACEHOLDER} />}>
  {product ? <ProductCard product={product} /> : null}
</AutoSkeleton>

The sample is rendered off-screen through a portal, from inside your tree, so every provider above it still applies. A product card that reads a redux store, the Next router and a locale measures exactly where it stands. (A second React root — the obvious implementation — cannot do this: context does not cross roots, and in a real application that rules out most components.)

A list, where the row count matters. Identical, evenly spaced siblings fold into one node carrying a count and a stride. You never pass a count prop, because the measurement already knows.

<AutoSkeleton loading={loading}>
  <CommentList comments={comments} />   {/* 3 rows in, 3 rows out */}
</AutoSkeleton>

Measuring the real thing instead of a sample

measure="live" walks the children where they already are. Nothing is rendered a second time, so there is no sample to keep in step with the component and no question about context — it is the live tree.

<AutoSkeleton measure="live" id="product-card" loading={loading}>
  <ProductCard product={product} />
</AutoSkeleton>

The trade is timing: a layout only exists once the real content has been on screen, so the first load still shows fallback and every load after it is exact. Pass an id, because the measurement no longer belongs to one set of props — every card in a list shares the entry.

Measurement waits for the content to settle first: images decoded, webfonts loaded, geometry steady for two frames. Without that you measure a card that is still loading its own picture, and the skeleton stands in for the loading state rather than the finished one. Every wait is bounded.

Skeletons already on screen do not miss out. When one part of a page is still fetching while another has just measured the same component, the waiting skeleton picks the layout up as it lands.

Next.js App Router

The package carries its own "use client" boundary, so it imports 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 />} />;
}

Hydration stays clean by construction. 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.

Verified against Next 15.5 and Next 16.3 with React 19.

Props

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.

Fixing a wrong guess

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:

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

<AutoSkeleton
  loading={loading}
  theme={{ baseColor: "#1b2326", highlightColor: "#2f3c41", duration: 1.8 }}
>

The shimmer respects prefers-reduced-motion whatever you pass.

Also exported

  • SkeletonView — render a layout map you already have.
  • useBreakpoint() — the current breakpoint name; null until mounted.
  • measureReactElement(element, options) — measure an element in a detached root, for cases with no tree to portal out of. No context reaches it.
  • usePortalMeasurement() — the portal measurement itself, if you are building something other than AutoSkeleton on top of it.
  • The core types and cache utilities, so an app never needs a direct dependency on @iskeletor/core.

Good to know

  • The skeleton is inert: aria-hidden, no pointer events, no focusable nodes.
  • A component that renders nothing measurable is never cached, and its fallback stays up — better than pinning an empty skeleton in place for the session.
  • A sample that throws is caught by an error boundary around the measurement: the page keeps running, the fallback stays up, and the failure is a warning in development rather than an application crash.
  • Repeats fold in both shapes: a list folds along its stride, and a grid folds with the columns and row pitch as well. A layout that is neither - ragged rows, an off-centre last row - stays expanded, because a fold has to reproduce the measured positions exactly or not happen at all.

Licence

MIT

Keywords