auto-skelly
v1.0.0
Published
Framework-agnostic skeleton loading placeholders. Auto-generates animated skeletons from your existing markup to improve perceived load time.
Maintainers
Readme
auto-skelly
Framework-agnostic skeleton loading placeholders, generated automatically from your existing markup.

Why
Users perceive pages as faster when something meaningful appears immediately, even before real data has arrived. Skeleton screens — animated placeholders shaped like the content that's loading — reduce perceived latency and avoid the layout jank of spinners or blank space. Auto Skelly generates those placeholders directly from your markup, so you don't hand-build a skeleton for every view.
Install
npm i auto-skellyOr drop it in with a script tag — data-skelly-auto skellifies the page automatically on DOMContentLoaded:
<script src="https://unpkg.com/auto-skelly" data-skelly-auto></script>Quick start
<div class="skelly-text">Order #1204 — Delivered</div>
<img class="skelly-image" src="/receipt.png" alt="Receipt" />
<button class="skelly-button">View details</button>
<script type="module">
import { AutoSkelly } from "auto-skelly";
const skelly = new AutoSkelly();
await skelly.while(fetchOrder());
</script>while(promise) is the shortest correct way to wrap an async load: it applies the skeleton, awaits promise, and always removes it again — even if the promise rejects — resolving (or rethrowing) with the promise's own value.
For anything that isn't a single promise (event-driven loading, multiple in-flight requests), call apply()/remove() directly:
const skelly = new AutoSkelly();
skelly.apply();
fetchOrder().then(() => {
skelly.remove(); // originals reappear exactly as they were
});Originals are hidden, not destroyed — remove() (or while()'s automatic cleanup) brings back the real content untouched.
API reference
new AutoSkelly(options?)
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| color | string | "auto" | Placeholder background color. "auto" writes no inline custom property, so the injected stylesheet controls color — including a prefers-color-scheme: dark default. |
| highlightColor | string | "auto" | Highlight color for the shimmer sweep. Same "auto" behavior as color. |
| animation | "pulse" \| "extraPulse" \| "gradient" \| "shimmer" \| "none" | "pulse" | Placeholder animation. |
| root | ParentNode | document | Root to search/restore within by default. |
| delay | number | 0 | Milliseconds to wait before painting placeholders. 0 is synchronous. |
| minDuration | number | 0 | Minimum milliseconds a painted placeholder stays visible before an early remove() can take it away. |
| fade | number | 0 | Milliseconds to fade a placeholder's opacity to 0 before restoring the original element. 0 swaps instantly. |
| observe | boolean | false | Watch root with a MutationObserver and skellify matching elements added after apply(). |
Methods
apply(root?: ParentNode): () => void— Finds matching elements underroot(default: the instance's configured root, ordocument), replaces each with a sized placeholder, and hides the original in place. Returns an idempotent disposer scoped to this call — calling it removes only what this call skellified, and calling it more than once is a no-op.remove(root?: ParentNode): void— Restores original elements and removes their placeholders, honoringminDuration/fade. Passrootto restore only elements contained within it; omit it to restore everything this instance currently has applied.while<T>(promise: Promise<T>, root?: ParentNode): Promise<T>— Applies, awaitspromise, then always removes — even if it rejects — and resolves (or rethrows) with the promise's own result.toggle(on: boolean, root?: ParentNode): void— Sugar foron ? apply(root) : remove(root).setTheme(theme: { color?: string; highlightColor?: string; animation?: SkellyAnimation }): void— Updates color/highlight color/animation on every placeholder this instance currently has applied. Per-elementdata-skelly-*overrides still take precedence. Pass"auto"to hand a color back to the stylesheet.active— Read-only getter.truewhile a paint is pending (mid-delay) or currently painted;falseonce removal has committed, even mid-fade.
Shape classes
.skelly-text— a bar sized to the measured line-height; content taller than two line-heights becomes stacked bars, with a shorter final line..skelly-image— a rectangle sized to the element, falling back to an<img>'swidth/heightattributes, then a 16:9 aspect ratio..skelly-circle— a circle sized to the larger of the element's measured width/height..skelly-button— a rectangle sized to the element's outer width/height.
Sizing also copies each element's computed border-radius (falling back to 5px for text/buttons, 50% for circles, 0 for images) and margins.
Any element can also declare its shape with data-skelly-shape="text" | "image" | "circle" | "button" instead of (or alongside) these classes — handy when you can't add a class. See the next section for the full list of data-skelly-* attributes.
Per-element overrides (data-skelly-*)
Any matching element can override the instance-wide options, or the measured/default sizing, with data-skelly-* attributes:
| Attribute | Description |
| --- | --- |
| data-skelly-shape | "text" \| "image" \| "circle" \| "button" — declare the shape without a skelly-* class. |
| data-skelly-width | Placeholder width (any CSS length, e.g. "200px"). |
| data-skelly-height | Placeholder height. |
| data-skelly-radius | Placeholder border-radius. |
| data-skelly-lines | Integer ≥ 1. On .skelly-text, forces the number of stacked bars, overriding the height-based multiline detection. |
| data-skelly-animation | "pulse" \| "extraPulse" \| "gradient" \| "shimmer" \| "none" — overrides the instance animation for this element. |
| data-skelly-color | Overrides the instance/CSS color for this element. Accepts "auto". |
| data-skelly-highlight-color | Overrides the shimmer highlight color for this element. Accepts "auto". |
| data-skelly-count | Integer > 1. Clones this element's placeholder N times. |
| data-skelly-ignore | Present (any value) — skip this element entirely; it's left untouched, no placeholder is inserted. |
Precedence, most to least specific: data-skelly-* attribute → constructor option (where one exists, e.g. color, animation) → value measured from the DOM → the shape's built-in default. Not every property passes through every tier — lines and count, for example, have no constructor-level equivalent.
Skellifying content that doesn't exist yet
data-skelly-lines also works on an empty element — the common case in SPA/React apps, where the real content hasn't been fetched into the DOM yet and there's nothing to measure:
<div class="skelly-text" data-skelly-lines="3"></div>With no rect to measure, an unforced placeholder would fall back to a single 1em bar. Forcing lines builds the full multi-bar skeleton (with the usual shorter final line) up front.
Standing in for a list
data-skelly-count repeats one template element's placeholder N times, so you don't need N real elements in the DOM to skellify a list:
<div class="skelly-text" data-skelly-count="5"></div>This inserts 5 placeholder bars before the single template element, then hides the template as usual.
Theming
Dark mode works out of the box. color and highlightColor default to "auto", which writes no inline custom property at all — the injected stylesheet governs appearance, including a prefers-color-scheme: dark default for both. Set an explicit color to opt out.
Three ways to control appearance, in increasing order of granularity:
- Constructor options — set
color/highlightColor/animationonce, at construction. setTheme({ color?, highlightColor?, animation? })— change color/highlight color/animation on an already-applied instance; every visible placeholder updates immediately. Pass"auto"to return a color to CSS control.- CSS custom properties — set these on any ancestor (or
:root) to override appearance without touching JS:--skelly-color— placeholder background color.--skelly-highlight-color— theshimmersweep's highlight color.--skelly-duration— animation duration (default varies by animation:2sforpulse/extraPulse,5sforgradient,1.5sforshimmer).--skelly-gradient— thegradientanimation'sbackground-image.
Per-element data-skelly-* attributes take precedence over both constructor options and setTheme() — see Per-element overrides above.
Animations:
pulse(default) — opacity fade.extraPulse— scale + box-shadow pulse.gradient— animated diagonal gradient sweep, themed via--skelly-gradient.shimmer— a light sweep across the placeholder, themed via--skelly-highlight-color.none— static placeholder, no animation.
Timing: delay, minDuration, and fade
All three default to 0, so out of the box apply()/remove() are synchronous — no delay, no fade, identical to not setting them at all.
delay— wait this many ms before painting placeholders. If the real content is ready (you callremove(), or thewhile()promise settles) before the delay elapses, no placeholder is ever painted — fast loads never flash a skeleton.minDuration— once a placeholder has painted, keep it visible for at least this many ms even ifremove()is called earlier. Stops a skeleton from flickering in and immediately back out on a load that turns out to be fast.fade— once removal actually happens (after anyminDurationwait), fade the placeholder's opacity to 0 over this many ms before restoring the original element, instead of swapping instantly.
A good starting point:
const skelly = new AutoSkelly({ delay: 200, minDuration: 400 });Loads under 200ms show no skeleton at all; any skeleton that does appear stays for at least 400ms, long enough to read as intentional rather than a flicker.
Watching for new elements: observe
By default, apply() scans once. Pass observe: true to also watch root with a MutationObserver and automatically skellify any matching elements added later — useful for infinite-scroll lists or content injected after the initial render:
const skelly = new AutoSkelly({ observe: true });
const dispose = skelly.apply(listEl);
// Elements matching .skelly-* added under listEl after this point
// are picked up automatically.Call the returned disposer (or remove()) to stop watching and restore everything the call painted.
Framework recipes
React
apply() returns an idempotent disposer scoped to that call, which is exactly what a React effect wants back — it fires on unmount and restores the originals automatically:
import { useEffect, useRef } from "react";
import { AutoSkelly } from "auto-skelly";
function OrderCard({ children }) {
const rootRef = useRef(null);
const skelly = useRef(new AutoSkelly());
useEffect(() => skelly.current.apply(rootRef.current), []);
return <div ref={rootRef}>{children}</div>;
}Vue 3
<script setup>
import { ref, onMounted, watchEffect } from "vue";
import { AutoSkelly } from "auto-skelly";
const rootEl = ref(null);
const isLoading = ref(true);
const skelly = new AutoSkelly();
onMounted(() => {
watchEffect(() => {
isLoading.value ? skelly.apply(rootEl.value) : skelly.remove(rootEl.value);
});
});
</script>
<template><div ref="rootEl"><!-- skelly-* markup --></div></template>Plain script tag
<!-- Auto-applies on DOMContentLoaded -->
<script src="https://unpkg.com/auto-skelly" data-skelly-auto></script>Without data-skelly-auto, the script still exposes window.AutoSkelly for manual control:
<script src="https://unpkg.com/auto-skelly"></script>
<script>new window.AutoSkelly().apply();</script>Accessibility
- Placeholders are marked
aria-hidden="true". - The parent of any skellified element gets
aria-busy="true"for as long as it has active children, cleared automatically once they're all restored. - Animations only run under
prefers-reduced-motion: no-preference; placeholders render static otherwise.
Migrating from v0
v0 was a jQuery-based, unpublished prototype distributed by copying autoskelly.js/autoskelly.css into your project. v1 is a zero-dependency TypeScript rewrite published to npm.
| v0 | v1 |
| --- | --- |
| setSkelly(color, animation) | new AutoSkelly({ color, animation }).apply() |
| changeSkellyColor(color) | setTheme({ color }) |
| changeSkellyAnimation("standard") | setTheme({ animation: "pulse" }) |
| changeSkellyAnimation("bigPulse") | setTheme({ animation: "extraPulse" }) |
| activateSkelly(true) / activateSkelly(false) | apply() / remove() |
The most important behavioral change: v0 permanently destroyed skellified elements via jQuery's replaceWith. v1 hides originals and restores them exactly on remove() — skellification is now reversible.
Contributing
See CONTRIBUTING.md.
License
MIT © Alex Gordienko
