I built phantom-ui – a Web Component that generates skeleton loaders from your real DOM

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…


This content originally appeared on DEV Community and was authored by Frank

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:

  1. Hides the slotted text with -webkit-text-fill-color: transparent and images with opacity: 0
  2. Walks the slotted DOM tree recursively
  3. Skips containers and captures leaf elements. Certain tags (IMG, SVG, VIDEO, CANVAS, IFRAME, INPUT, TEXTAREA, BUTTON) are always treated as leaves regardless of children
  4. Calls getBoundingClientRect on each to get position and size relative to the host
  5. Reads border-radius from computed styles
  6. For container elements with a visible background, border, or box-shadow, it captures those styles separately (using getComputedStyle with individual borderWidth/borderStyle/borderColor since the border shorthand returns empty in computed styles)
  7. 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 in operator
  • Svelte handles boolean attributes natively
  • Angular with CUSTOM_ELEMENTS_SCHEMA and [attr.loading]="loading() ? '' : null"
  • Solid with attr:loading={loading() ? "" : null} (the attr: prefix forces attribute mode, and null removes it, important because attr:loading={false} would set loading="false" in the DOM, which the CSS selector phantom-ui[loading] still matches)
  • Qwik with a dynamic import() in useVisibleTask$ and a ready signal guard
  • HTMX with a CDN script tag and hx-on::after-swap to 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:

  1. Generates a phantom-ui.d.ts file with JSX IntrinsicElements declarations tailored to your framework (React, Solid, Qwik each need different type augmentations)
  2. 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.


This content originally appeared on DEV Community and was authored by Frank


Print Share Comment Cite Upload Translate Updates
APA

Frank | Sciencx (2026-04-14T12:01:19+00:00) I built phantom-ui – a Web Component that generates skeleton loaders from your real DOM. Retrieved from https://www.scien.cx/2026/04/14/i-built-phantom-ui-a-web-component-that-generates-skeleton-loaders-from-your-real-dom/

MLA
" » I built phantom-ui – a Web Component that generates skeleton loaders from your real DOM." Frank | Sciencx - Tuesday April 14, 2026, https://www.scien.cx/2026/04/14/i-built-phantom-ui-a-web-component-that-generates-skeleton-loaders-from-your-real-dom/
HARVARD
Frank | Sciencx Tuesday April 14, 2026 » I built phantom-ui – a Web Component that generates skeleton loaders from your real DOM., viewed ,<https://www.scien.cx/2026/04/14/i-built-phantom-ui-a-web-component-that-generates-skeleton-loaders-from-your-real-dom/>
VANCOUVER
Frank | Sciencx - » I built phantom-ui – a Web Component that generates skeleton loaders from your real DOM. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2026/04/14/i-built-phantom-ui-a-web-component-that-generates-skeleton-loaders-from-your-real-dom/
CHICAGO
" » I built phantom-ui – a Web Component that generates skeleton loaders from your real DOM." Frank | Sciencx - Accessed . https://www.scien.cx/2026/04/14/i-built-phantom-ui-a-web-component-that-generates-skeleton-loaders-from-your-real-dom/
IEEE
" » I built phantom-ui – a Web Component that generates skeleton loaders from your real DOM." Frank | Sciencx [Online]. Available: https://www.scien.cx/2026/04/14/i-built-phantom-ui-a-web-component-that-generates-skeleton-loaders-from-your-real-dom/. [Accessed: ]
rf:citation
» I built phantom-ui – a Web Component that generates skeleton loaders from your real DOM | Frank | Sciencx | https://www.scien.cx/2026/04/14/i-built-phantom-ui-a-web-component-that-generates-skeleton-loaders-from-your-real-dom/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.