Skeleton loaders are one of those things that sound simple until you actually build them. You end up hand-coding a second layout with gray blocks that kind of matches the real one. Then the design changes, nobody updates the skeleton, and it drifts out of sync. Classic.
The idea
The DOM already knows what your UI looks like. Positions, sizes, border-radius, everything. So why are we manually recreating that as a skeleton?
phantom-ui wraps your content with <phantom-ui loading>, walks the DOM tree, measures every leaf element with getBoundingClientRect, and overlays animated shimmer blocks at the exact same coordinates. When loading ends, it fades out.
<phantom-ui loading>
<div class="card">
<img src={user?.avatar} width="48" height="48" />
<h3>{user?.name ?? "Placeholder Name"}</h3>
<p>{user?.bio ?? "A short bio goes here."}</p>
</div>
</phantom-ui>
No separate skeleton template. No config file. Your real component is the skeleton.
How it works under the hood
When the loading attribute is set, phantom-ui:
- Hides the slotted text with
-webkit-text-fill-color: transparentand images withopacity: 0 - Walks the slotted DOM tree recursively
- Skips containers and captures leaf elements. Certain tags (
IMG,SVG,VIDEO,CANVAS,IFRAME,INPUT,TEXTAREA,BUTTON) are always treated as leaves regardless of children - Calls
getBoundingClientRecton each to get position and size relative to the host - Reads
border-radiusfrom computed styles - For container elements with a visible background, border, or box-shadow, it captures those styles separately (using
getComputedStylewith individualborderWidth/borderStyle/borderColorsince thebordershorthand returns empty in computed styles) - Renders absolutely-positioned shimmer blocks inside the Shadow DOM overlay
The shimmer animation is pure CSS, a linear-gradient sweep using background-position animation. No JavaScript animation loop.
.shimmer-block::after {
background: linear-gradient(90deg, var(--bg) 30%, var(--color) 50%, var(--bg) 70%);
background-size: 200% 100%;
animation: shimmer-sweep 1.5s linear infinite;
}
A ResizeObserver watches for layout changes. A MutationObserver catches DOM mutations (it disconnects during measurement to avoid re-triggering itself when injecting measurement spans for table cells). Both re-trigger _measure() automatically so the skeleton stays in sync. Observers are only active while loading is true and are properly disconnected on cleanup.
Measurement is batched via requestAnimationFrame, so multiple DOM mutations in the same frame result in a single measurement pass instead of thrashing.
Table cell handling
Table cells (<td>, <th>) get special treatment. A <td> is often wider than its text content because of table layout. phantom-ui temporarily injects a <span> inside the cell, measures the span's width instead of the cell's, then removes it. This produces shimmer blocks that match the text width, not the full column width.
Dual Shadow DOM / Light DOM styles
Content hiding requires two separate style strategies. The shimmer overlay lives in Shadow DOM (scoped, encapsulated). But the slotted content lives in Light DOM, and Shadow DOM styles can't fully target deep descendants of slotted elements. So phantom-ui injects a small stylesheet into the document head that targets phantom-ui[loading] * to hide text and images. This is done once, deduplicated by checking for an existing style element ID.
The loading attribute uses a custom Lit converter that treats the string "false" as falsy:
converter: {
fromAttribute: (value) => value !== null && value !== "false",
toAttribute: (value) => (value ? "" : null),
}
This means <phantom-ui loading="false"> removes the skeleton, which avoids a common gotcha with frameworks that serialize booleans as strings.
"But you need the data to render the DOM?"
This is the first thing people ask. If the content is behind {#if data} or data && <Component />, there's nothing in the DOM to measure. Fair point.
The trick is to always render the structure with fallback values:
<phantom-ui loading={isLoading}>
<div className="card">
<img src={user?.avatar ?? ""} width="48" height="48" />
<h3>{user?.name ?? "Placeholder Name"}</h3>
<p>{user?.bio ?? "A short bio goes here."}</p>
</div>
</phantom-ui>
The fallback text gives elements a size. It's invisible during loading anyway. Yeah, it's a tradeoff, you structure your template a bit differently. But you never maintain a separate skeleton.
For elements that don't have a natural size yet (image without width/height, a div filled by JS), you can force dimensions:
<img data-shimmer-width="200" data-shimmer-height="150" />
phantom-ui will use those values instead of the measured ones. This also works for elements with zero size, they won't be skipped if an override is present.
For lists where you don't know the count, the count attribute repeats skeleton rows from a single template:
<phantom-ui loading count="5" count-gap="12">
<div class="user-row">
<img width="32" height="32" />
<span>Placeholder</span>
</div>
</phantom-ui>
When using count, if the template element has a visible background, border, or box-shadow, phantom-ui replicates those on each repeated row as .shimmer-container-block elements. This means your skeleton rows look like real cards, not just floating shimmer blocks.
Why a Web Component?
I didn't want to build a React version, then a Vue version, then a Svelte one. One component that works everywhere, that's it.
phantom-ui is built with Lit (~8kb total) and registers as <phantom-ui>. Works out of the box with:
- React 19+ sets properties natively on custom elements
-
Vue detects properties via the
inoperator - Svelte handles boolean attributes natively
-
Angular with
CUSTOM_ELEMENTS_SCHEMAand[attr.loading]="loading() ? '' : null" -
Solid with
attr:loading={loading() ? "" : null}(theattr:prefix forces attribute mode, andnullremoves it, important becauseattr:loading={false}would setloading="false"in the DOM, which the CSS selectorphantom-ui[loading]still matches) -
Qwik with a dynamic
import()inuseVisibleTask$and areadysignal guard -
HTMX with a CDN script tag and
hx-on::after-swapto remove the loading attribute - Vanilla HTML just a script tag
Zero-config TypeScript setup
Nobody wants to manually wire up type declarations. The package ships a postinstall script that runs after npm install, detects your framework, and:
- Generates a
phantom-ui.d.tsfile with JSXIntrinsicElementsdeclarations tailored to your framework (React, Solid, Qwik each need different type augmentations) - For SSR frameworks (Next.js, Nuxt, SvelteKit, Remix, Qwik), it injects the pre-hydration CSS import into your root layout file automatically
If the postinstall didn't run, there's also a CLI:
npx @aejkatappaja/phantom-ui init
The TypeScript types include full JSDoc on every property, so you get autocomplete and hover docs in your editor. The package also ships a Custom Elements Manifest (custom-elements.json) for IDE tooling.
Animation modes and shimmer direction
4 animation modes:
-
shimmer(default), a gradient sweep moving across each block -
pulse, opacity oscillation between full and faded -
breathe, subtle scale and opacity breathing effect -
solid, static blocks with no animation
<phantom-ui loading animation="pulse">...</phantom-ui>
Shimmer direction controls the sweep direction of the gradient. Only applies to animation="shimmer":
-
ltr, left to right (default) -
rtl, right to left, useful for RTL layouts (Arabic, Hebrew) -
ttb, top to bottom -
btt, bottom to top
<phantom-ui loading shimmer-direction="rtl">...</phantom-ui>
Under the hood, each direction changes the gradient angle (90deg vs 180deg) and the background-size/background-position axis. The CSS uses :host([shimmer-direction="rtl"]) selectors to swap the keyframes, no JS involved.
Stagger: delay in seconds between each block's animation start. Creates a wave effect where blocks animate one after another.
<phantom-ui loading stagger="0.05">...</phantom-ui>
Reveal: smooth fade-out transition in seconds when loading ends. The overlay gets an opacity: 0 transition via a .revealing class, then removes itself from the DOM after the transition completes.
<phantom-ui loading reveal="0.3">...</phantom-ui>
Fine-grained control
data-shimmer-ignore keeps an element and its children visible during loading. Useful for static labels, navigation, or interactive elements that should remain usable:
<nav data-shimmer-ignore>
<a href="/">Home</a>
</nav>
The light DOM CSS resets -webkit-text-fill-color, pointer-events, and opacity for ignored elements and their descendants.
data-shimmer-no-children captures a container as a single shimmer block instead of walking its children. Useful for charts, progress bars, or any element where child structure doesn't map to meaningful skeleton blocks:
<div class="chart" data-shimmer-no-children>
<canvas></canvas>
</div>
data-shimmer-width / data-shimmer-height override measured dimensions. If only one axis is set and the other is zero, the element is still captured (normally zero-size elements are skipped):
<div data-shimmer-width="120" data-shimmer-height="24"></div>
Accessibility
phantom-ui automatically sets aria-busy="true" on the host element when loading, and aria-busy="false" when loading ends. Screen readers use this to announce that content is being loaded. The shimmer overlay also has aria-hidden="true" so assistive technology ignores the decorative blocks entirely.
SSR support
phantom-ui uses browser APIs (getBoundingClientRect, ResizeObserver, customElements), so the import must happen client-side. But the <phantom-ui> HTML tag is safe in server-rendered markup. The browser treats it as an unknown element until hydration.
The problem: before JavaScript loads, content inside <phantom-ui loading> can briefly flash as visible text. The package ships ssr.css that hides this content immediately with no JS:
@import "@aejkatappaja/phantom-ui/ssr.css";
The postinstall script auto-injects this into your layout file for Next.js, Nuxt, SvelteKit, Remix, and Qwik.
Performance
Benchmarked on a Mac Studio M4 Max:
| Elements | Measurement time |
|---|---|
| 100 | < 3ms |
| 500 | < 15ms |
| 1000 | < 31ms |
The measurement runs once per loading toggle (plus re-runs on resize/mutation). It's a single synchronous DOM walk, no layout thrashing, since getBoundingClientRect reads are batched before any writes.
Try it
npm install @aejkatappaja/phantom-ui
Or via CDN:
<script src="https://unpkg.com/@aejkatappaja/phantom-ui/dist/phantom-ui.cdn.js"></script>
Links:
Feedback welcome, especially on edge cases you've hit with skeleton loaders.
Top comments (2)
Nice skeleton loader.
Thanks, glad you like it!