DEV Community

Cover image for I built phantom-ui - a Web Component that generates skeleton loaders from your real DOM
Frank
Frank

Posted on

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 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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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),
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Or via CDN:

<script src="https://unpkg.com/@aejkatappaja/phantom-ui/dist/phantom-ui.cdn.js"></script>
Enter fullscreen mode Exit fullscreen mode

Links:

Feedback welcome, especially on edge cases you've hit with skeleton loaders.

Top comments (2)

Collapse
 
pengeszikra profile image
Peter Vivo

Nice skeleton loader.

Collapse
 
aejkatappaja profile image
Frank

Thanks, glad you like it!