@iskeletor/core
The measurement engine behind iskeletor.
Give it an Element; get back JSON describing what the browser actually laid
out, and DOM that stands in for it. No dependencies, no framework imports, no
globals beyond the element's own document — which is what makes a React, Vue or
Angular adapter a thin layer on top rather than a rewrite.
Most React apps want @iskeletor/react
instead. Reach for this package directly when you have no framework, or when you
are building an adapter for one.
npm install @iskeletor/core
Documentation and a live demo — switch the component on the page and watch the skeleton be re-measured in your browser.
Quick start
import { measureElement, renderSkeleton } from "@iskeletor/core";
const layout = measureElement(document.querySelector("#card"));
document.querySelector("#placeholder").append(renderSkeleton(layout));
skeletonFor(element) does both in one call.
A container with no box of its own — display: contents, the way a wrapper
stays out of the layout — is measured as the area its children occupy, so you
can measure live content without a wrapper that changes it.
Measuring content that is already on the page
Content on screen is not finished the moment it mounts. Images arrive late, and a component that swaps its own placeholder for the real thing once an image loads changes shape afterwards; measure into that window and the skeleton stands in for a half-loaded card rather than the finished one.
import { waitForContent, measureElement } from "@iskeletor/core";
await waitForContent(card); // images, webfonts, then two steady frames
const layout = measureElement(card);
Bounded, like every other wait here: one image that never loads must not mean no measurement at all.
Measuring something that is not on screen yet
import { measureWithSandbox } from "@iskeletor/core";
const layout = await measureWithSandbox((container) => {
container.innerHTML = template;
});
The sandbox mounts into a hidden, off-screen host, waits for layout and
webfonts, measures, and always tears itself down. It hides with opacity, never
visibility — which is inherited, and would make every measured child report
itself as hidden.
Pass a viewport width to get a real nested viewport (an iframe), so CSS media
queries resolve at that width:
import { measureResponsive, DEFAULT_BREAKPOINTS } from "@iskeletor/core";
const maps = await measureResponsive(mount, DEFAULT_BREAKPOINTS);
// { mobile: {...}, tablet: {...}, desktop: {...} }
Every wait inside the sandbox is bounded. Animation frames never fire in a backgrounded tab and webfonts can stall, and neither should leave a caller waiting forever for a measurement.
The layout map
{
"version": 1,
"width": 340, "height": 373,
"children": [
{
"type": "container", "x": 0, "y": 0, "width": 340, "height": 373,
"radius": "12px",
"children": [
{ "type": "image", "x": 1, "y": 1, "width": 338, "height": 180, "radius": "" },
{
"type": "text", "x": 16, "y": 197, "width": 306, "height": 60,
"lines": 3, "lineHeight": 20,
"lineBoxes": [
{ "offset": 0, "width": 1 },
{ "offset": 0, "width": 0.98 },
{ "offset": 0, "width": 0.41 }
]
}
]
}
]
}
It is plain JSON on purpose: it can be cached, persisted, diffed, or generated
ahead of time by a headless browser. LayoutCache serialises with toJSON()
and reloads with hydrate(), and subscribe() reports entries as they arrive —
so a placeholder that is waiting on a measurement taken elsewhere on the page
can pick it up instead of sitting on its fallback for the rest of the session.
Node types are text, image, button, circle, rect and container.
Coordinates are relative to the parent node, so a subtree can be moved without
recomputing anything.
Repeats. Structurally identical siblings that are evenly spaced fold into
one node carrying repeat: { count, strideX, strideY }. A run that wraps folds
too, and carries columns and rowStride alongside them - so a grid of seven
cards three across is one node, not one per row. repeatOffset(repeat, i)
returns where the i-th repetition sits, whichever shape was recorded.
A layout that describes neither a line nor a grid - ragged rows, a centred last row - is left expanded rather than having positions invented for it.
Text. Lines are measured with Range.getClientRects(), which is the only
way to learn how a string actually wrapped. lineBoxes records each line's
extent as a ratio of the block, so a short heading in a wide container is drawn
as a short bar - and centred text stays centred.
Overriding the guesses
The classifier is a heuristic. When it gets something wrong, say so in the markup:
<div data-skeleton-type="circle">JD</div>
<div data-skeleton-ignore>debug panel</div>
data-skeleton-type accepts any node type. data-skeleton-ignore (and
aria-hidden="true") removes the element from the map entirely.
Theming
renderSkeleton(layout, {
baseColor: "#e6e8eb",
highlightColor: "#f4f6f8",
duration: 1.4, // seconds per shimmer sweep
animate: true, // honours prefers-reduced-motion regardless
lineFill: 0.72, // how much of a line box a text bar fills
fluid: false, // percentage widths instead of pinned pixels
});
Options are applied as CSS custom properties on the generated root, so several skeletons can be themed independently while sharing one stylesheet.
API
| Export | Purpose |
|---|---|
measureElement(el, options?) |
Walk a live element into a layout map. |
renderSkeleton(layout, options?) |
Build placeholder DOM. |
renderSkeletonToString(layout, options?) |
Same, as markup. |
skeletonFor(el, options?) |
Measure and render in one call. |
createSandbox(options?) |
Off-screen host to mount into. |
measureWithSandbox(mount, options?) |
Mount, settle, measure, clean up. |
measureResponsive(mount, breakpoints, options?) |
One map per breakpoint. |
waitForContent(el, options?) |
Wait until on-screen content is worth measuring. |
LayoutCache, globalLayoutCache |
LRU cache keyed by id, props and viewport. |
pickBreakpoint(width, breakpoints?) |
Widest breakpoint that fits. |
repeatOffset(repeat, i) |
Where the i-th repetition sits. |
ensureStyles(document), SKELETON_CSS |
Stylesheet installation. |