react-starlight-skeleton
v0.1.0
Published
Auto-generated shimmering skeletons for React: measures your real child layout in a layout effect and compiles a pixel-perfect skeleton overlay. SSR-safe, zero runtime dependencies.
Maintainers
Readme
react-starlight-skeleton
Auto-generated shimmering skeletons for React. Instead of hand-building a fake
version of every screen, <StarlightSkeleton> renders your real UI
invisibly, measures it in a layout effect, and compiles a pixel-perfect overlay
of shimmering blocks that mirrors the actual layout — shapes, alignment,
distribution, corner radii and all.
- Zero runtime dependencies — React and ReactDOM are peer dependencies.
- Measures the real DOM — one component wraps any layout; no skeleton markup to maintain.
- Shimmer wave — a single injected
<style>tag (id-guarded, shared by all instances) drives a moving linear-gradient animation; respectsprefers-reduced-motion. - Responsive — re-measures on window
resizeand viaResizeObserver(guarded when unavailable). - SSR-safe — works with Next.js App Router (ships
"use client"), nouseLayoutEffectwarnings on the server, graceful fallback bars on the server-rendered first paint. - Fully typed — strict TypeScript, ESM + CJS builds, source maps,
.d.tsfor both formats.
Install
npm install react-starlight-skeleton
# or
pnpm add react-starlight-skeleton
# or
yarn add react-starlight-skeletonRequires react and react-dom ^18 || ^19.
Quickstart
Vite (or any client-rendered React app)
import { useEffect, useState } from "react";
import { StarlightSkeleton } from "react-starlight-skeleton";
export function ProfileCard() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetchUser().then(setUser);
}, []);
const view = user ?? PLACEHOLDER_USER; // representative placeholder content
return (
<StarlightSkeleton loading={user === null}>
<div className="card">
<img className="avatar" src={view.avatarUrl} alt="" />
<h3>{view.name}</h3>
<p>{view.bio}</p>
<button>Follow</button>
</div>
</StarlightSkeleton>
);
}While loading is true the card is rendered invisibly, measured, and each leaf
(the image, the heading, the paragraph, the button) gets its own shimmer block
at exactly its position and size. When loading finishes, the children render
as-is — the wrapper disappears entirely.
Next.js (App Router)
The package ships with the "use client" directive, so you can import it
directly inside your own client components:
"use client";
import { StarlightSkeleton } from "react-starlight-skeleton";
export function Dashboard({ data, loading }: DashboardProps) {
return (
<StarlightSkeleton loading={loading} baseColor="#1f2430" shimmerColor="#2c3345">
{/* your real dashboard layout */}
</StarlightSkeleton>
);
}On the server-rendered first paint there is no DOM to measure yet, so
fallbackRows generic bars render; the measured overlay takes over in the
layout effect after hydration, before the browser paints.
A complete runnable demo lives in
example/DashboardExample.tsx: a dashboard
(header, avatar + text rows, stat-card grid, activity feed) loading real async
data through a fake fetch, with a reload button to replay the skeleton.
How measurement works
- While
loadingis true, your children are still rendered — inside a container withvisibility: hiddenandaria-hidden, within aposition: relativewrapper markedaria-busy. - In a layout effect (before paint),
collectSkeletonBlocks(root)walks the hidden subtree and picks leaf visual elements:- elements with no child elements;
img,svg,button,input,textarea,select,video,canvasand friends — always one block, even with children inside;- elements with a direct text node (so
<p>Hi <strong>there</strong></p>becomes a single text bar, not two). display: none,visibility: hidden,[hidden]and zero-size nodes are skipped.
- Each leaf's
getBoundingClientRect()(relative to the wrapper) and computedborder-radiusbecome aSkeletonBlock, rendered as an absolutely positioned shimmer<div>in an overlay. - Measurement re-runs on window
resize, onResizeObservernotifications, and wheneverchildrenchange. - If measurement produces zero blocks (SSR first paint, empty children),
fallbackRowsgeneric bars render instead.
Because the skeleton is compiled from the real layout, the quality of the skeleton equals the quality of your placeholder content: render your layout with representative placeholder data while loading (see the example) and the skeleton will match the loaded UI block-for-block.
Props
<StarlightSkeleton>
| Prop | Type | Default | Description |
| ------------------- | ------------------ | ----------- | ---------------------------------------------------------------------------------------------------- |
| loading | boolean | — required | true shows the measured skeleton overlay; false renders children as-is. |
| children | ReactNode | — required | The real UI. Kept mounted (hidden) while loading so it can be measured. |
| shimmerColor | string | "#f6f7fb" | Highlight color the wave sweeps with. |
| baseColor | string | "#e8eaf0" | Resting color of the blocks. |
| animationDuration | number | 1.4 | Seconds per shimmer sweep. |
| borderRadius | number \| string | 8 | Radius for blocks whose source element has none, and for fallback bars. Numbers are pixels. |
| className | string | — | Class for the outer wrapper while loading. |
| style | CSSProperties | — | Styles merged onto the outer wrapper while loading. |
| fallbackRows | number | 3 | Generic bars shown when measurement yields zero blocks (SSR first paint, empty children). |
<Bone>
A single hand-placed shimmer block for composing skeletons manually.
| Prop | Type | Default |
| ------------------- | ------------------ | ----------- |
| width | number \| string | "100%" |
| height | number \| string | 16 |
| radius | number \| string | 8 |
| baseColor | string | "#e8eaf0" |
| shimmerColor | string | "#f6f7fb" |
| animationDuration | number | 1.4 |
| className | string | — |
| style | CSSProperties | — |
<Bone width={220} height={24} radius={6} />Also exported
collectSkeletonBlocks(root: HTMLElement): SkeletonBlock[]— the measuring walker, exported for testing and advanced use.ensureShimmerStyles(doc?),STYLE_ELEMENT_ID,SHIMMER_BLOCK_CLASS,WAVE_KEYFRAMES_NAME,shimmerGradient(base, shimmer).useIsomorphicLayoutEffect—useLayoutEffectin the browser,useEffecton the server.- Types:
StarlightSkeletonProps,SkeletonBlock,BoneProps, plus theDEFAULT_*constants.
SSR notes and limitations
- Server render: there is no DOM on the server, so nothing can be measured.
While
loadingis true, the server (and the client's first hydration render) outputs the hidden children plusfallbackRowsgeneric bars — markup is identical on both sides, so there is no hydration mismatch. The measured overlay replaces the bars in a layout effect before the first browser paint. - The children must render while loading. The skeleton mirrors whatever
layout the hidden children produce. If your components render
nulluntil data arrives, there is nothing to measure — feed them placeholder data (see the example) or accept the fallback bars. - Style injection happens once per
documentvia an id-guarded<style>tag created in an effect — never during server rendering. - Animations are disabled under
prefers-reduced-motion: reduce. - Blocks are absolutely positioned snapshots; layout changes are tracked via
resize/ResizeObserver, but continuous animations inside children are not followed frame-by-frame.
Why not hand-rolled skeletons?
Hand-written skeletons are a second implementation of every screen: they drift
the moment the real layout changes, they multiply across breakpoints, and they
are usually built from guessed pixel sizes. react-starlight-skeleton derives
the skeleton from the same JSX that renders the data, so:
- one source of truth — layout changes update the skeleton automatically;
- exact geometry — blocks come from
getBoundingClientRect, not guesses; - responsive for free — the hidden children reflow, the overlay re-measures.
Hand-rolled skeletons (or the <Bone> primitive) still make sense when you
cannot render your layout before data arrives, or when you want a deliberately
simplified placeholder.
License
MIT © 2026 Dinesh Kumar
