Every skeleton screen you have ever built has the same flaw: it is a second copy of your component. You build the card, then you build the card-shaped grey blocks. Someone adds a subtitle to the real card three months later, and the skeleton quietly stops matching.
phantom-ui attacks that from the other end. Instead of asking you to describe the shape of the loading state, it renders your actual component, measures where every element landed, and paints shimmer blocks at exactly those coordinates. The real component is the skeleton template.
It ships as a single Web Component, so it works the same in React, Vue, Svelte, Angular, Solid, Qwik, HTMX, or a plain HTML file. No adapters, no per-framework packages.
The short version
- One custom element —
<phantom-ui loading>wraps your real markup. - Measures at runtime with
getBoundingClientRect(), so the skeleton cannot drift from the component. - Framework-agnostic — built with Lit, works anywhere custom elements do.
- ~11.5 kB gzipped standalone; under 2 kB if Lit is already in your bundle.
- Four animation modes plus an
overlayrefresh mode for stale-while-revalidate. - Accessible by default —
aria-busy, an announced label, and placeholder content made inert. - MIT licensed. github.com/aejkatappaja/phantom-ui — 767 stars, v1.6.1.
The problem with hand-built skeletons
The traditional pattern is to write a <UserCardSkeleton> alongside every <UserCard>. It works, and it has three costs that compound quietly:
- Duplication. Two components describe one layout. Padding, gaps and avatar sizes are declared twice.
- Drift. Nothing enforces that they stay in sync. The skeleton is only wrong when the data is loading, which is exactly when nobody is looking closely.
- Layout shift. Because the skeleton is an approximation, the content jumps slightly when it swaps in. Small, constant, and precisely the kind of thing that shows up in a Cumulative Layout Shift score.
None of these are hard problems individually. They are just permanent maintenance, spread across every component you own.
How phantom-ui works instead
The insight is that your component already knows its own shape — you just have to ask it. phantom-ui renders the real markup with color: transparent, hides media elements, walks the DOM for leaf nodes, measures each one, and draws an absolutely-positioned overlay of shimmer blocks at the measured coordinates.
Container backgrounds and borders are deliberately left visible. That is a nice detail: your card outline, its border radius and its shadow stay exactly as they are, and only the contents become placeholders. The result reads as "this card is loading" rather than "here is a grey rectangle where a card will be".
Because the measurement happens against the component you actually shipped, the skeleton cannot drift. Change the real card and the skeleton changes with it, without you touching anything.
Installing it
bun add @aejkatappaja/phantom-ui # bun
npm install @aejkatappaja/phantom-ui # npm
pnpm add @aejkatappaja/phantom-ui # pnpm
yarn add @aejkatappaja/phantom-ui # yarn
Or with no build step at all:
<script src="https://cdn.jsdelivr.net/npm/@aejkatappaja/phantom-ui/dist/phantom-ui.cdn.js"></script>
There is an optional setup command that handles two chores for you:
npx @aejkatappaja/phantom-ui init # npm
bunx @aejkatappaja/phantom-ui init # bun
pnpx @aejkatappaja/phantom-ui init # pnpm
yarn dlx @aejkatappaja/phantom-ui init # yarn
It detects your setup and does exactly two things: generates a phantom-ui.d.ts in src/ so <phantom-ui> type-checks in JSX (React, Solid and Qwik need this; Vue, Svelte and Angular do not), and adds the pre-hydration CSS import to your layout file if you are on an SSR framework.
Worth noting: the package has no postinstall hook. Nothing touches your source unless you run that command yourself, and both steps are small enough to do by hand if you would rather not.
Vendoring a single file
This part is unusually well thought through, and it is the section to read if you work somewhere with no registry access.
There is a standalone ES module with Lit bundled in — one file, 34 kB minified, 11.5 kB gzipped, no runtime dependency:
import "@aejkatappaja/phantom-ui/standalone";
If you cannot reach a registry at all, the release assets include three files:
| File | What it is |
|---|---|
phantom-ui.standalone.js | The component, unminified so a security review can actually read it. |
phantom-ui.standalone.min.js | The same code, minified. |
phantom-ui.standalone.d.ts | Its types, importing nothing at all. |
Copy the first and the third into your repo. TypeScript pairs a declaration file to a module by exact filename, so those two names belong together and the types apply with zero configuration:
import "./phantom-ui.standalone.js";
import type { PhantomUi } from "./phantom-ui.standalone.js";
The declared class extends HTMLElement rather than LitElement — a narrower view of the same object — so updateComplete is declared explicitly and the rest of the public API is unchanged. The practical payoff is that a project with no node_modules whatsoever still type-checks under strict with skipLibCheck: false. That is a real constraint in regulated environments, and it is rare to see a UI library actually solve it.
The licence is MIT, so vendoring is fine. Just write down which version you copied, somewhere near the file — a vendored copy does not get updates.
Quick start
<phantom-ui loading>
<div class="card">
<img src="avatar.png" width="48" height="48" style="border-radius: 50%" />
<h3>Ada Lovelace</h3>
<p>First computer programmer, probably.</p>
</div>
</phantom-ui>
Set loading to show the shimmer, remove it to reveal the real content. Nested images and media are hidden automatically.
Wiring it to data fetching
The pattern is the same regardless of your data layer: render placeholder values while loading, real values when the request resolves. The placeholder text is invisible — it exists only to give the measurement something to measure.
import { useQuery } from "@tanstack/react-query";
import "@aejkatappaja/phantom-ui";
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
});
return (
<phantom-ui loading={isLoading}>
<div className="card">
<img src={user?.avatar ?? "/placeholder.png"} width="48" height="48" />
<h3>{user?.name ?? "Placeholder Name"}</h3>
<p>{user?.bio ?? "A short bio goes here."}</p>
</div>
</phantom-ui>
);
}
Swap useQuery for useSWR and the component is untouched — it only cares about the loading attribute.
Be clear-eyed about one thing here: you are still writing placeholder strings. The win is not that placeholder content disappears, it is that the placeholder lives inline in the component you already maintain, using the same fallback expression you would have written anyway, instead of in a parallel skeleton file that can rot.
Lists and repeat mode
Lists are the awkward case, because before the data arrives there is nothing to repeat. The count attribute measures a single template row and duplicates the resulting blocks:
<phantom-ui loading count="5" count-gap="8">
<div class="user-row">
<img src="avatar.png" width="32" height="32" />
<span>John Doe</span>
<span>john@acme.io</span>
</div>
</phantom-ui>
count-gap adds vertical spacing in pixels between the repeated rows. When loading is removed, only the real template element remains.
Framework notes
The element registers itself on import, and the registration is guarded against duplicate customElements.define() calls. That matters more than it sounds: micro-frontends, lazy-loaded routes, dynamic imports and HMR all routinely initialise a package more than once, and an unguarded define() throws on the second call.
One detail worth internalising before you write your first integration. loading is a boolean attribute, so it must be absent when false, not set to "false". That is why the official Angular and Solid examples bind it the long way:
<!-- Angular -->
<phantom-ui [attr.loading]="loading() ? '' : null" animation="pulse">
<!-- Solid -->
<phantom-ui attr:loading={loading() ? "" : null} animation="shimmer">
Angular additionally needs CUSTOM_ELEMENTS_SCHEMA in the component's schemas array, or the template compiler rejects the unknown tag.
Vue and Svelte are the least ceremony of the group — :loading="props.loading" and {loading} respectively, with no type declaration needed:
<!-- Svelte -->
<phantom-ui {loading} reveal={0.4} stagger={0.03}>
<div class="card">
<img src="/avatar.png" alt="avatar" class="avatar" />
<h3>Ada Lovelace</h3>
<p>First computer programmer, probably.</p>
</div>
</phantom-ui>
Server-side rendering
The component measures the DOM, so it needs browser APIs. Import it client-side only:
// Next.js
"use client";
import { useEffect } from "react";
export default function Page() {
useEffect(() => { import("@aejkatappaja/phantom-ui"); }, []);
return <phantom-ui loading>...</phantom-ui>;
}
Nuxt uses onMounted inside <ClientOnly>, SvelteKit uses onMount, Qwik uses useVisibleTask$. Same idea in each case.
The <phantom-ui> tag itself is safe in server-rendered HTML — the browser treats it as an unknown element until the script hydrates it. Your content renders normally on the server, which is the outcome you want for SEO.
The pre-hydration flash
There is a gap between HTML arriving and JavaScript running, and during that gap the content inside <phantom-ui loading> is just… visible. The package ships a CSS file that closes it with no JS involved:
import "@aejkatappaja/phantom-ui/ssr.css";
On the CDN build, put the rules straight in your <head>:
<style>
phantom-ui[loading] * {
-webkit-text-fill-color: transparent !important;
pointer-events: none;
user-select: none;
}
phantom-ui[loading] img, phantom-ui[loading] svg,
phantom-ui[loading] video, phantom-ui[loading] canvas,
phantom-ui[loading] button, phantom-ui[loading] [role="button"] {
opacity: 0 !important;
}
</style>
Do not skip this on an SSR app. It is the difference between a clean load and a visible flash of your placeholder strings.
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
loading | boolean | false | Show the shimmer overlay, or the real content. |
animation | string | shimmer | shimmer, pulse, breathe or solid. |
mode | string | skeleton | skeleton hides content and shows blocks; overlay keeps content dimmed for refresh. |
shimmer-direction | string | ltr | Sweep direction: ltr, rtl, ttb, btt (shimmer mode). |
shimmer-color | string | rgba(128,128,128,0.3) | Colour of the animated gradient sweep. |
background-color | string | rgba(128,128,128,0.2) | Background of each shimmer block, in all modes. |
duration | number | 1.5 | Animation cycle in seconds. |
stagger | number | 0 | Delay in seconds between each block's animation start. |
reveal | number | 0 | Fade-out duration in seconds when loading ends. |
count | number | 1 | Skeleton rows to repeat from one template. |
count-gap | number | 0 | Gap in pixels between repeated rows. |
fallback-radius | number | 4 | Border radius in px for flat elements such as text. |
debug | boolean | false | Outline each measured block with its index. |
loading-label | string | Loading | Label announced by screen readers, set as aria-label. |
pierce-shadow | boolean | false | Measure inside open shadow roots of slotted components. |
That last one is the escape hatch for design systems. If your cards are themselves Web Components built with Stencil or Lit, their internals live behind a shadow root and a naïve measurement pass sees one opaque box. pierce-shadow descends into open shadow roots and measures what is actually in there.
Refresh mode
Skeleton mode is for the first load, when you have nothing to show. It is the wrong answer for a refetch, where blanking out a perfectly good table to replace it with grey bars is a downgrade.
<phantom-ui loading mode="overlay">
<div class="grid"><!-- the previous result --></div>
</phantom-ui>
In overlay mode the existing content stays visible and dimmed while a light glint sweeps across each element — still structure-aware, still measured. It is not clickable during the refresh (pointer-events: none), so nobody acts on stale data, but it stays readable and stays in the accessibility tree, with aria-busy announcing the update.
Dim it with --phantom-content-opacity (default 0.5; set it to 1 for full opacity and light only). count and count-gap do not apply here, which makes sense — you already have real rows.
The sweep adapts to the animation mode: pulse and breathe hold the light as a steady veil animated by that mode, solid holds it static, and the same static veil is used when the system requests reduced motion. The refresh stays visible in every case, which is the right call — accessibility preferences should soften an indicator, not delete it.
Fine-grained control
Three data attributes cover the cases where automatic measurement is not quite what you want:
data-shimmer-ignore— keeps an element and its descendants visible and interactive throughout. For logos, brand marks, live indicators.data-shimmer-no-children— captures the element as one block instead of recursing. For dense metric groups that should read as a single placeholder rather than a scatter of small bars.data-shimmer-width/data-shimmer-height— override the measured pixel dimensions. Elements with zero dimensions are normally skipped, so this is how you force a block for an image with no intrinsic size yet, or a container that JavaScript will fill later.
<phantom-ui loading>
<div class="dashboard">
<div class="logo" data-shimmer-ignore>ACME</div>
<div class="kpi-row" data-shimmer-no-children>
<span>$48.2k</span>
<span>2,847 users</span>
<span>42ms p99</span>
</div>
<img src="/hero.jpg" data-shimmer-width="600" data-shimmer-height="400" />
</div>
</phantom-ui>
That third attribute pair is the one you will reach for in practice. Images without explicit width/height are extremely common, and an unmeasurable element silently producing no skeleton block is the kind of bug that is easy to miss and annoying to diagnose.
What happens under the hood
The pipeline, in order:
- Hide, don't remove. Real content renders with
color: transparentand media hidden. Icons drawn with CSSmask-image— including on::before/::after— are detected at runtime and hidden too. Container backgrounds and borders stay. - Find the leaves. The tree is walked for text nodes, images, buttons, inputs and anything without child elements. Container divs are recursed into, not captured.
- Measure. Each leaf gets
getBoundingClientRect()relative to the host, and its border radius fromgetComputedStyle(). Table cells are special-cased to measure the text width rather than the cell width — otherwise every table would shimmer as full-width bars. - Overlay. One absolutely-positioned block per measured element, with a CSS gradient animation sweeping across it.
- Stay correct. A
ResizeObserver, aMutationObserverand a media-load listener re-measure on window resize, content injection, DOM mutation, or images and videos finishing loading. Withpierce-shadow, the pierced roots are watched as well.
Step five is what separates this from a weekend prototype. A measure-once implementation looks perfect in a demo and falls apart the moment a font swaps in or a late image resizes its container.
Performance
The project publishes its own benchmarks, measured in Chrome:
| Elements | Leaf nodes | Time |
|---|---|---|
| 100 | 334 | ~20 ms |
| 500 | 1,667 | ~25 ms |
| 1,000 | 3,334 | ~31 ms |
The shape of that curve is the interesting part: tripling the leaf count from 334 to 1,000 adds about 5 ms, and going to 3,334 adds another 6 ms. It is close to linear with a healthy constant, which is what you would expect from a batched measurement pass that avoids interleaving reads and writes.
Two honest caveats. These are the author's own numbers on one engine, and 1,000 elements is described as far more than a typical skeleton screen — so the benchmark is a headroom check, not a representative workload. Measure on your own worst page if it matters to you.
Bundle size
The CDN build with Lit included is ~34 kB, ~11.5 kB gzipped. Used as an ES module through a bundler, Lit is likely already in your dependency tree, which brings the marginal cost under 2 kB. If you are already running any Lit-based design system, this is close to free.
Accessibility
This is handled better than most loading indicators, and it is worth calling out specifically because it is the part teams usually get wrong.
aria-busyis set on the host automatically, so assistive technology knows an update is in flight.- Placeholder content is made inert while loading — out of the tab order and out of the accessibility tree. Invisible-but-focusable content is a classic skeleton bug: sighted users see shimmer while keyboard users tab into a phantom form.
loading-label(defaultLoading) sets the announcedaria-label.- Elements marked
data-shimmer-ignorestay interactive by design. - Reduced-motion preferences fall back to a static veil rather than removing the indicator.
Where it fits, and where it doesn't
Reach for it when:
- You maintain skeleton components by hand and they keep drifting.
- You ship more than one framework, or a design system consumed by several.
- You want a proper stale-while-revalidate refresh state without building a second indicator.
- Your components are layout-heavy — dashboards, tables, cards — where hand-matching every block is real work.
- You need to vendor dependencies. The single-file, no-node_modules story here is genuinely unusual.
Think twice when:
- Your loading states are one spinner and a line of text. The measurement machinery is overkill.
- You need skeletons rendered on the server with no JavaScript. Measurement is inherently a browser-side operation; the ssr.css file hides the flash, it does not produce a server-rendered skeleton.
- Your placeholder text would be badly wrong in shape — a one-word fallback where the real value is a paragraph produces a skeleton that misrepresents the layout. The measurement is only as good as the placeholder you give it.
- You are on a very small team with an already-working skeleton setup. This solves maintenance cost, and if you are not paying that cost, there is nothing here for you.
The project also credits its prior art openly — page-skeleton-webpack-plugin (2018) and @findify/skeleton-generator (~2019) explored DOM-measurement overlays before it. The new contribution is packaging the idea as one universal Web Component instead of framework-specific adapters, which is exactly the right lesson to take from a build-time plugin.
Frequently asked questions
What is phantom-ui?
phantom-ui is an open-source Web Component that generates skeleton loading screens automatically by measuring your real DOM at runtime. You wrap existing markup in a phantom-ui element and set the loading attribute; the component renders your content invisibly, measures the position and size of every leaf element with getBoundingClientRect, and overlays animated shimmer blocks at the same coordinates. Because it measures the component you actually shipped, the skeleton cannot drift out of sync with it.
Does phantom-ui work with React, Vue, Svelte and Angular?
Yes. It is a standard Web Component built with Lit, so it works in React, Vue, Svelte, Angular, Solid, Qwik, HTMX and plain HTML with no framework adapters. React, Solid and Qwik need a small JSX type declaration, which the bundled init command can generate for you; Vue, Svelte and Angular work without one. Angular additionally requires CUSTOM_ELEMENTS_SCHEMA in the component schemas array.
How big is phantom-ui?
The standalone build with Lit bundled in is about 34 kB minified and 11.5 kB gzipped. When you import it as an ES module through a bundler and Lit is already in your dependency tree, the marginal cost drops to under 2 kB.
Do I still have to write placeholder content?
Yes, but inline rather than in a separate component. The skeleton shape is derived from real rendered text, so you supply fallback values such as a placeholder name or a short bio, which render invisibly while loading. The benefit is not that placeholder content disappears — it is that it lives in the component you already maintain, using the same fallback expression you would write anyway, instead of in a parallel skeleton file that can fall out of sync.
Does phantom-ui work with server-side rendering?
The element tag is safe in server-rendered HTML — browsers treat it as an unknown element until hydration, so content renders normally on the server. The component itself needs browser APIs to measure the DOM, so import it client-side only: useEffect in Next.js, onMounted in Nuxt, onMount in SvelteKit, useVisibleTask$ in Qwik. The package also ships an ssr.css file that hides placeholder content before JavaScript loads, preventing a flash of visible text.
Is phantom-ui accessible?
It sets aria-busy on the host automatically, makes placeholder content inert while loading so it stays out of the tab order and the accessibility tree, and exposes a loading-label attribute for the announced label. Elements marked data-shimmer-ignore stay interactive. When the system requests reduced motion, the indicator falls back to a static veil rather than disappearing.
What is the difference between skeleton mode and overlay mode?
Skeleton mode is for a first load with no data: it hides the content and shows placeholder blocks. Overlay mode is for a refresh or stale-while-revalidate state where you already have a result. In overlay mode the existing content stays visible and dimmed while a light sweeps over each element, pointer events are disabled so nobody acts on stale data, and the content stays readable and in the accessibility tree. The count and count-gap attributes do not apply in overlay mode.
Is phantom-ui free for commercial use?
Yes. phantom-ui is released under the MIT Licence, which permits commercial use, modification, redistribution and vendoring a copy into your own repository, provided the licence notice is preserved. If you do vendor it, record which version you copied, because a vendored copy does not receive updates.
Sources and further reading
- phantom-ui on GitHub — source, README and releases.
- Live demo — the fastest way to judge whether the effect suits your UI.
- @aejkatappaja/phantom-ui on npm.
- The package ships a
custom-elements.jsonmanifest, so IDE autocomplete, Storybook autodocs and framework tooling pick up every attribute, property and type without extra configuration.
Building a component library or design system?
Loading states, accessibility and cross-framework packaging are the parts that get skipped and then cost the most later. If you want a hand with any of it, get in touch.
Comments (0)
Leave a Comment