
phantom-ui is a Web Component skeleton loader that measures your real DOM at runtime and generates animated shimmer placeholder blocks at the exact position of every leaf element.
The library wraps any markup in a <phantom-ui> custom element. The loading attribute activates the shimmer overlay. Removing it reveals the actual content.
Features
- Generates shimmer placeholders from real DOM structure.
- Measures text, images, buttons, inputs, and leaf elements.
- Preserves card backgrounds, borders, and section outlines.
- Supports modern frameworks and plain HTML.
- Repeats list skeleton rows from one template.
- Offers multiple loading animation styles.
- Provides data attributes for ignored elements and custom block sizes.
- Adds pre-hydration CSS for server-rendered apps.
- Updates measurements after layout and media changes.
- Sets loading state for assistive technology.
- Works in React, Vue, Svelte, Angular, Solid, Qwik, and Vanilla JavaScript.
Use Cases
- Profile cards and user detail pages that fetch data from an API before render.
- Dashboard widgets displaying metrics loaded from multiple async sources.
- List and table views that need placeholder rows while paginated data loads.
- News feeds and content grids that show skeleton screens during initial page hydration in SSR apps.
How To Use It
Installation
Install the package from npm when your project uses a bundler, TypeScript, a JavaScript framework, or an SSR framework.
# npm npm install @aejkatappaja/phantom-ui # pnpm pnpm add @aejkatappaja/phantom-ui # yarn yarn add @aejkatappaja/phantom-ui # bun bun add @aejkatappaja/phantom-ui
Import the Web Component once in your client-side entry file.
// Register the <phantom-ui> custom element. import "@aejkatappaja/phantom-ui";
You can also use the CDN build in a static HTML page.
<!-- Load the browser build before using <phantom-ui>. --> <script src="https://cdn.jsdelivr.net/npm/@aejkatappaja/phantom-ui/dist/phantom-ui.cdn.js"></script>
The package includes an automatic setup script. It creates JSX type declarations for React, Solid, and Qwik projects, and it can add the SSR CSS import for Next.js, Nuxt, SvelteKit, Remix, and Qwik layouts.
Run the setup manually when install scripts are disabled in CI, monorepos, or locked package-manager workflows.
# npm npx @aejkatappaja/phantom-ui init # pnpm pnpx @aejkatappaja/phantom-ui init # yarn yarn dlx @aejkatappaja/phantom-ui init # bun bunx @aejkatappaja/phantom-ui init
Basic Usage
Wrap your real UI in <phantom-ui> and add the loading attribute. The wrapper hides child content, measures the visible layout, and renders skeleton blocks over the same coordinates.
<phantom-ui loading>
<article class="customer-card">
<!-- Explicit media dimensions help the skeleton keep a stable shape. -->
<img src="/assets/customers/maya-chen.jpg" width="56" height="56" alt="Maya Chen" />
<h3>Maya Chen</h3>
<p>Product manager at Northline Labs.</p>
</article>
</phantom-ui>
Remove the loading attribute when your content is ready.
const profileSkeleton = document.querySelector("phantom-ui");
// Show the skeleton while the request runs.
profileSkeleton.setAttribute("loading", "");
// Reveal the real content after your UI state changes.
profileSkeleton.removeAttribute("loading");
Advanced Examples
Async content needs realistic placeholder text before the API response arrives. The placeholder content controls the measured text width, so choose lengths close to the final content.
<phantom-ui id="account-shell" loading>
<section class="account-card">
<img src="/img/avatar-fallback.svg" width="48" height="48" alt="" />
<h3>Jordan Parker</h3>
<p>Senior frontend engineer in the dashboard team.</p>
</section>
</phantom-ui>
<script>
const shell = document.querySelector("#account-shell");
async function loadAccount() {
shell.setAttribute("loading", "");
const response = await fetch("/api/accounts/acct_48291");
const account = await response.json();
document.querySelector(".account-card h3").textContent = account.name;
document.querySelector(".account-card p").textContent = account.role;
// Remove the attribute when the real content is ready.
shell.removeAttribute("loading");
}
loadAccount();
</script>
Dynamic lists often need several loading rows before the real array exists. The count attribute repeats one template row, and count-gap controls the vertical space between repeated skeleton rows.
<phantom-ui loading count="6" count-gap="10">
<div class="invoice-row">
<span>INV-10482</span>
<span>Northline Labs</span>
<span>$2,480.00</span>
</div>
</phantom-ui>
Some UI elements should stay visible during loading. The data attributes below let logos, status pills, and complex metric groups opt out of normal leaf measurement.
<phantom-ui loading>
<section class="analytics-card">
<!-- Keep the brand mark visible during loading. -->
<div class="brand-mark" data-shimmer-ignore>ACME</div>
<!-- Measure this group as one skeleton block. -->
<div class="metric-strip" data-shimmer-no-children>
<span>$84.6k</span>
<span>12.8% growth</span>
<span>1,482 orders</span>
</div>
<!-- Force dimensions when the image has no natural size yet. -->
<img
src="/reports/chart-preview.png"
alt="Revenue chart"
data-shimmer-width="640"
data-shimmer-height="280"
/>
</section>
</phantom-ui>
React data fetching works best when the fallback content has stable image sizes and realistic text. The wrapper receives the same loading state that controls your query or request.
import { useQuery } from "@tanstack/react-query";
import "@aejkatappaja/phantom-ui";
type Profile = {
name: string;
title: string;
avatarUrl: string;
};
export function TeamProfile({ profileId }: { profileId: string }) {
const { data, isLoading } = useQuery<Profile>({
queryKey: ["team-profile", profileId],
queryFn: () => fetch(`/api/team/${profileId}`).then((res) => res.json()),
});
return (
<phantom-ui loading={isLoading} animation="pulse" reveal={0.25}>
<article className="team-profile">
<img
src={data?.avatarUrl ?? "/img/avatar-placeholder.svg"}
width="52"
height="52"
alt={data?.name ?? ""}
/>
<h3>{data?.name ?? "Morgan Ellis"}</h3>
<p>{data?.title ?? "Design systems engineer"}</p>
</article>
</phantom-ui>
);
}
SWR follows the same render pattern as other async data tools. Keep the wrapper in the component tree during loading, then let the real data replace the placeholder values.
import useSWR from "swr";
import "@aejkatappaja/phantom-ui";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export function ProductSummary({ productId }: { productId: string }) {
const { data, isLoading } = useSWR(`/api/products/${productId}`, fetcher);
return (
<phantom-ui loading={isLoading} animation="shimmer" duration={1.2}>
<div className="product-summary">
<img
src={data?.imageUrl ?? "/img/product-placeholder.svg"}
width="96"
height="96"
alt={data?.name ?? ""}
/>
<h3>{data?.name ?? "Wireless Desk Lamp"}</h3>
<p>{data?.summary ?? "Adjustable LED lamp for compact workspaces."}</p>
</div>
</phantom-ui>
);
}
Vue templates can bind the loading attribute directly. The component remains framework-neutral because the browser handles the custom element.
<script setup lang="ts">
import "@aejkatappaja/phantom-ui";
defineProps<{
loading: boolean;
}>();
</script>
<template>
<phantom-ui :loading="loading" animation="breathe" :stagger="0.04">
<article class="author-card">
<img src="/img/author-placeholder.svg" width="48" height="48" alt="" />
<h3>Riley Brooks</h3>
<p>Frontend editor and documentation lead.</p>
</article>
</phantom-ui>
</template>
Angular needs custom element schema support in the component that renders <phantom-ui>. Bind the boolean attribute as an attribute value, since Angular treats unknown Web Component properties differently from native component inputs.
import { Component, CUSTOM_ELEMENTS_SCHEMA, signal } from "@angular/core";
import "@aejkatappaja/phantom-ui";
@Component({
selector: "app-billing-card",
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<phantom-ui [attr.loading]="loading() ? '' : null" animation="pulse">
<section class="billing-card">
<h3>Enterprise Plan</h3>
<p>Next renewal on April 18.</p>
<strong>$249.00</strong>
</section>
</phantom-ui>
`,
})
export class BillingCardComponent {
loading = signal(true);
}
SSR frameworks need browser-side registration because DOM measurement requires browser APIs. The tag can exist in server-rendered HTML, then the Web Component activates after hydration.
// Next.js App Router
"use client";
import { useEffect } from "react";
export default function OrdersPanel() {
useEffect(() => {
// Register the custom element only in the browser.
import("@aejkatappaja/phantom-ui");
}, []);
return (
<phantom-ui loading>
<section className="orders-panel">
<h3>Recent orders</h3>
<p>Loading customer purchase activity.</p>
</section>
</phantom-ui>
);
}
Import the SSR CSS file in your root layout when you use server rendering. It hides loading content before JavaScript finishes hydration.
// Add this import to a root layout file such as app/layout.tsx or app/root.tsx. import "@aejkatappaja/phantom-ui/ssr.css";
The CDN build needs equivalent CSS in the document head when server-rendered loading markup may appear before JavaScript loads.
<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>
Configuration Options
loading(boolean): Shows the skeleton overlay when present and reveals real content when removed.animation(string): Sets the animation mode. Supported values areshimmer,pulse,breathe, andsolid.shimmer-direction(string): Sets the shimmer sweep direction for shimmer mode. Supported values areltr,rtl,ttb, andbtt.shimmer-color(string): Sets the animated gradient sweep color for shimmer mode.background-color(string): Sets the base color of each skeleton block.duration(number): Sets the animation cycle duration in seconds.stagger(number): Adds a delay in seconds between block animations.reveal(number): Sets the fade-out duration in seconds when loading ends.count(number): Repeats one measured template into multiple skeleton rows.count-gap(number): Sets the pixel gap between repeated skeleton rows.fallback-radius(number): Sets the fallback border radius in pixels for flat elements such as text blocks.debug(boolean): Shows measured block outlines and indexes for layout inspection.
Data Attributes
data-shimmer-ignore(boolean attribute): Keeps an element and its descendants visible during loading.data-shimmer-no-children(boolean attribute): Measures the element as one block instead of measuring its descendants.data-shimmer-width(number): Forces a skeleton block width in pixels.data-shimmer-height(number): Forces a skeleton block height in pixels.
CSS Custom Properties
phantom-ui {
/* Animated gradient sweep color. */
--shimmer-color: rgba(80, 160, 255, 0.32);
/* Animation cycle duration. */
--shimmer-duration: 1.8s;
/* Base block background. */
--shimmer-bg: rgba(80, 160, 255, 0.08);
}
Alternatives:
- Discover more JavaScript & CSS Skeleton Loader
- 10 Best Loading Spinner/Indicator Libraries for Pure CSS & Vanilla JS
- Convert HTML Elements to Skeleton Loaders with SkeletonJS
- Highly Customizable Skeleton Loader In Pure CSS: Skeleton Mammoth
- Facebook Inspired Skeleton Loader In Pure CSS: Placeholder Loading
- Animated Skeleton Loading Screens In Pure CSS
- Customizable Screen Skeleton Loader In CSS: css-skeletons.css
- Animated Skeleton Loader In Pure JavaScript
- Create A Skeleton Screen From Auto Generated Placeholders: Placeholdifier







