<p align="center">
  <img src=".github/assets/logo-phantom.svg" alt="phantom-ui" width="200" />
  <br />
  <img src=".github/assets/phantom-ui-text.svg" alt="phantom-ui" width="320" />
</p>

<p align="center">
  <strong>Structure-aware skeleton loader. One Web Component. Every framework.</strong>
</p>

<p align="center">
  <a href="https://www.npmjs.com/package/@aejkatappaja/phantom-ui"><img src="https://img.shields.io/npm/v/@aejkatappaja/phantom-ui.svg?style=flat-square" alt="npm version" /></a>
  <img src="https://img.shields.io/badge/minzipped-~8kb-blue?style=flat-square" alt="bundle size" />
  <a href="https://github.com/Aejkatappaja/phantom-ui/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/@aejkatappaja/phantom-ui?style=flat-square" alt="license" /></a>
  <a href="https://www.webcomponents.org/element/@aejkatappaja/phantom-ui"><img src="https://img.shields.io/badge/webcomponents.org-published-blue.svg?style=flat-square" alt="Published on webcomponents.org" /></a>
</p>

<p align="center">
  <a href="https://aejkatappaja.github.io/phantom-ui/">Documentation</a> &middot;
  <a href="https://aejkatappaja.github.io/phantom-ui/demo/">Live Demo</a>
</p>

---

<br />

<div align="center">
  <picture>
    <img src=".github/assets/phantom-ui-preview.svg" alt="phantom-ui demo" width="640" />
  </picture>
</div>

<br />

Stop building skeleton screens by hand. Wrap your real UI in `<phantom-ui>` and it generates shimmer placeholders automatically by measuring your actual DOM at runtime.

No separate skeleton components to maintain. No copy-pasting layouts. The real component _is_ the skeleton template.

## Why

Traditional skeleton loaders require you to build and maintain a second version of every component, just for the loading state. When the real component changes, the skeleton drifts out of sync.

`phantom-ui` takes a different approach. It renders your real component with invisible text, measures the position and size of every leaf element (`getBoundingClientRect`), and overlays animated shimmer blocks at the exact same coordinates. Container backgrounds and borders stay visible, giving a natural card outline while loading.

Because it is a standard Web Component (built with Lit), it works in React, Vue, Svelte, Angular, Solid, Qwik, HTMX, or plain HTML. No framework adapters needed.

## Install

```bash
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 drop in a script tag with no build step:

```html
<script src="https://cdn.jsdelivr.net/npm/@aejkatappaja/phantom-ui/dist/phantom-ui.cdn.js"></script>
```

### Vendoring a single file

If you cannot install from a registry, or you would rather keep a copy in your repo, there is a standalone ES module with Lit bundled in. One file, 34 kB minified, 11.5 kB gzipped, no runtime dependency:

```ts
import "@aejkatappaja/phantom-ui/standalone";
```

Or, if you cannot reach a registry at all, copy two files into your project and import by path:

```
phantom-ui.standalone.js      # the component, unminified so a review can read it
phantom-ui.standalone.min.js  # the same code, minified
phantom-ui.standalone.d.ts    # its types, no imports of any kind
```

Take the first and the third. TypeScript pairs a declaration to a module by exact
filename, so those two names go together and the types apply with nothing to configure.

```ts
import "./phantom-ui.standalone.js";
import type { PhantomUi } from "./phantom-ui.standalone.js";
```

That is the whole install. The element registers itself on import, and the declarations reference nothing external, so a project with no `node_modules` at all still type-checks under `strict` with `skipLibCheck: false`.

The declared class extends `HTMLElement` rather than `LitElement`, which is a narrower view of the same object, so `updateComplete` is declared explicitly and the rest of the public API is unchanged.

Those are the release assets, which is the channel for people who cannot reach a registry at all. Inside the npm package the same builds are `phantom-ui.standalone.js` and `phantom-ui.standalone.dev.js`, resolved through the exports map rather than by filename. Either build is the same code, and both are bundled output rather than the original TypeScript: the sources import Lit, so they cannot be compiled on their own.

The library is MIT, so vendoring is fine. Note the version you copied somewhere near the file, since a vendored copy does not get updates.

## Optional setup

The component works with zero setup. For an extra convenience pass, run the bundled `init` command once from your project root:

```bash
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 project setup and handles two things:

**JSX type declarations**: For React, Solid, and Qwik, it generates a `phantom-ui.d.ts` in your `src/` directory so `<phantom-ui>` is recognized in JSX. Vue, Svelte, and Angular work out of the box without any type declaration.

**SSR pre-hydration CSS**: For Next.js, Nuxt, SvelteKit, Remix, and Qwik, it adds `import "@aejkatappaja/phantom-ui/ssr.css"` to your layout file to prevent content flash before hydration (see [Pre-hydration CSS](#pre-hydration-css)).

Nothing runs on install: the library deliberately has no `postinstall` hook, so your source is never modified unless you ask for it. Prefer not to run the command at all? Both steps are tiny and documented below for manual setup.

<details>
<summary>Manual JSX type declarations</summary>

**React / Next.js / Remix**

```typescript
import type { PhantomUiAttributes } from "@aejkatappaja/phantom-ui";

declare module "react/jsx-runtime" {
  export namespace JSX {
    interface IntrinsicElements {
      "phantom-ui": PhantomUiAttributes;
    }
  }
}
```

**Solid**

```typescript
import type { SolidPhantomUiAttributes } from "@aejkatappaja/phantom-ui";

declare module "solid-js" {
  namespace JSX {
    interface IntrinsicElements {
      "phantom-ui": SolidPhantomUiAttributes;
    }
  }
}
```

**Qwik**

```typescript
import type { PhantomUiAttributes } from "@aejkatappaja/phantom-ui";

declare module "@builder.io/qwik" {
  namespace QwikJSX {
    interface IntrinsicElements {
      "phantom-ui": PhantomUiAttributes & Record<string, unknown>;
    }
  }
}
```

</details>

<details>
<summary>Manual SSR CSS import</summary>

Add this import to your root layout file:

```js
import "@aejkatappaja/phantom-ui/ssr.css";
```

| Framework | Layout file |
| --- | --- |
| Next.js (App Router) | `app/layout.tsx` |
| Next.js (Pages) | `pages/_app.tsx` |
| Nuxt | `app.vue` |
| SvelteKit | `src/routes/+layout.svelte` |
| Remix | `app/root.tsx` |
| Qwik | `src/root.tsx` |

</details>

## Quick start

```html
<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. All child elements (including deeply nested images and media) are automatically hidden during loading.

## Data fetching

phantom-ui works with any data fetching approach. The pattern: render placeholder content while loading, real content when done. The placeholder text is invisible (CSS transparent) and only used to generate the skeleton shape.

### TanStack Query

```tsx
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>
  );
}
```

While `isLoading` is true, the placeholder text (`"Placeholder Name"`, `"A short bio goes here."`) is rendered invisibly and phantom-ui generates shimmer blocks matching their exact position and size. When the query resolves, `loading` is removed and the real content appears.

### SWR

```tsx
import useSWR from "swr";
import "@aejkatappaja/phantom-ui";

function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading } = useSWR(`/api/users/${userId}`);

  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>
  );
}
```

### Lists

For dynamic lists where the data hasn't loaded yet, use `count` to repeat a single template row:

```tsx
const { data: users, isLoading } = useQuery({
  queryKey: ["users"],
  queryFn: () => fetch("/api/users").then((r) => r.json()),
});

return (
  <phantom-ui loading={isLoading} count={5} count-gap={8}>
    {isLoading ? (
      <div className="row">
        <img src="/placeholder.png" width="32" height="32" />
        <span>Placeholder Name</span>
        <span>placeholder@email.com</span>
      </div>
    ) : (
      users?.map((u) => (
        <div key={u.id} className="row">
          <img src={u.avatar} width="32" height="32" />
          <span>{u.name}</span>
          <span>{u.email}</span>
        </div>
      ))
    )}
  </phantom-ui>
);
```

## Framework examples

Safe to import multiple times. The custom element registration is guarded against duplicate `customElements.define()` calls, so phantom-ui works reliably in **micro-frontends, lazy-loaded modules, dynamic imports, and HMR / hot reload** environments where the package can end up being initialized more than once.

### React

```tsx
import "@aejkatappaja/phantom-ui";

function ProfileCard({ user, isLoading }: Props) {
  return (
    <phantom-ui loading={isLoading} animation="pulse" reveal={0.3}>
      <div className="card">
        <img src={user?.avatar ?? "/placeholder.png"} className="avatar" />
        <h3>{user?.name ?? "Placeholder Name"}</h3>
        <p>{user?.bio ?? "A few words about this person go here."}</p>
      </div>
    </phantom-ui>
  );
}

// List with repeat mode
function UserList({ users, isLoading }: Props) {
  return (
    <phantom-ui loading={isLoading} count={5} count-gap={8}>
      <div className="row">
        <img src="/placeholder.png" width="32" height="32" />
        <span>Placeholder Name</span>
      </div>
    </phantom-ui>
  );
}
```

### Vue

```vue
<script setup lang="ts">
import "@aejkatappaja/phantom-ui";

const props = defineProps<{ loading: boolean }>();
</script>

<template>
  <phantom-ui :loading="props.loading" animation="breathe" stagger="0.05">
    <div class="card">
      <img src="/avatar.png" class="avatar" />
      <h3>Ada Lovelace</h3>
      <p>First computer programmer, probably.</p>
    </div>
  </phantom-ui>
</template>
```

### Svelte

```svelte
<script lang="ts">
  import "@aejkatappaja/phantom-ui";

  export let loading = true;
</script>

<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>
```

### Angular

```typescript
import { Component, signal, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import "@aejkatappaja/phantom-ui";

@Component({
  selector: "app-profile",
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <phantom-ui [attr.loading]="loading() ? '' : null" animation="pulse">
      <div class="card">
        <img src="/avatar.png" class="avatar" />
        <h3>Ada Lovelace</h3>
        <p>First computer programmer, probably.</p>
      </div>
    </phantom-ui>
  `,
})
export class ProfileComponent {
  loading = signal(true);
}
```

### Solid

```tsx
import { createSignal } from "solid-js";
import "@aejkatappaja/phantom-ui";

function ProfileCard() {
  const [loading, setLoading] = createSignal(true);

  return (
    <phantom-ui attr:loading={loading() ? "" : null} animation="shimmer" stagger={0.05}>
      <div class="card">
        <img src="/avatar.png" class="avatar" />
        <h3>Ada Lovelace</h3>
        <p>First computer programmer, probably.</p>
      </div>
    </phantom-ui>
  );
}
```

### SSR frameworks (Next.js, Nuxt, SvelteKit, Remix, Qwik)

The component needs browser APIs to measure the DOM. Import it client-side only:

```tsx
// Next.js
"use client";
import { useEffect } from "react";

export default function Page() {
  useEffect(() => { import("@aejkatappaja/phantom-ui"); }, []);
  return <phantom-ui loading>...</phantom-ui>;
}
```

```vue
<!-- Nuxt -->
<script setup>
onMounted(() => import("@aejkatappaja/phantom-ui"));
</script>

<template>
  <ClientOnly>
    <phantom-ui loading>...</phantom-ui>
  </ClientOnly>
</template>
```

```svelte
<!-- SvelteKit -->
<script>
  import { onMount } from "svelte";
  onMount(() => import("@aejkatappaja/phantom-ui"));
</script>
```

```tsx
// Qwik
import { component$, useVisibleTask$ } from "@builder.io/qwik";

export default component$(() => {
  // eslint-disable-next-line qwik/no-use-visible-task
  useVisibleTask$(async () => {
    import("@aejkatappaja/phantom-ui");
  });

  return <phantom-ui loading>...</phantom-ui>;
});
```

The `<phantom-ui>` tag can exist in server-rendered HTML. The browser treats it as an unknown element until hydration, then the Web Component activates and measures the DOM. Content renders normally on the server, which is good for SEO.

#### Pre-hydration CSS

Before JavaScript loads, content inside `<phantom-ui loading>` can briefly flash as visible text. The package ships a small CSS file that hides this content immediately, with no JS needed:

```css
import "@aejkatappaja/phantom-ui/ssr.css";
```

Running `npx @aejkatappaja/phantom-ui init` detects SSR frameworks and adds this import to your layout file (e.g. `app/layout.tsx` for Next.js, `app.vue` for Nuxt, `+layout.svelte` for SvelteKit). Or add the line yourself. If you use the CDN build, add the rules directly in your `<head>`:

```html
<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>
```

## Attributes

| Attribute | Type | Default | Description |
| --- | --- | --- | --- |
| `loading` | `boolean` | `false` | Show shimmer overlay or real content |
| `animation` | `string` | `shimmer` | Animation mode: `shimmer`, `pulse`, `breathe`, or `solid` |
| `mode` | `string` | `skeleton` | `skeleton` (hide content, show blocks) or `overlay` (keep content dimmed, sweep light over it for refresh) |
| `shimmer-direction` | `string` | `ltr` | Direction of the shimmer sweep: `ltr`, `rtl`, `ttb`, or `btt` (shimmer mode only) |
| `shimmer-color` | `string` | `rgba(128,128,128,0.3)` | Color of the animated gradient sweep (shimmer mode only) |
| `background-color` | `string` | `rgba(128,128,128,0.2)` | Background of each shimmer block (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` | Number of skeleton rows to repeat from a single template |
| `count-gap` | `number` | `0` | Gap in pixels between repeated rows |
| `fallback-radius` | `number` | `4` | Border radius (px) for flat elements like text |
| `debug` | `boolean` | `false` | Outline each measured block with an index for inspection |
| `loading-label` | `string` | `Loading` | Accessible label announced by screen readers while loading (set as `aria-label`) |
| `pierce-shadow` | `boolean` | `false` | Measure inside open shadow roots of slotted components (Stencil, Lit design systems) |

> Using a Web Component design system? See the [Shadow DOM guide](https://aejkatappaja.github.io/phantom-ui/guides/design-systems/) for how `pierce-shadow` measures inside your components.

## Refresh mode (`mode="overlay"`)

The default `skeleton` mode is for the first load: it hides the content and shows placeholder blocks. For a refresh / stale-while-revalidate state, where you already have a result and are refetching, use `mode="overlay"`:

```html
<phantom-ui loading mode="overlay">
  <div class="grid"><!-- the previous result --></div>
</phantom-ui>
```

The existing content stays visible and dimmed while a light glint sweeps over each element (structure-aware, just like the skeleton). It is not clickable during the refresh (`pointer-events: none`) so users cannot act on stale data, but it stays readable and in the accessibility tree (`aria-busy` announces the update). Dim it with `--phantom-content-opacity` (default `0.5`, set to `1` for full opacity with light only). `count` / `count-gap` do not apply here.

The sweep is the `shimmer` animation. With `pulse` or `breathe` the light is held as a steady veil over each element and animated by that mode instead, `solid` holds it static, and the same static veil is used when the system asks for reduced motion. The refresh stays visible in every case.

## Fine-grained control

Data attributes let you control which elements get shimmer treatment and how they are measured:

**`data-shimmer-ignore`** keeps an element and all its descendants visible and interactive during loading (never made `inert`). Useful for logos, brand marks, or live indicators that should always be shown.

**`data-shimmer-no-children`** captures the element as one single shimmer block instead of recursing into its children. Useful for dense metric groups that should appear as a single placeholder.

**`data-shimmer-width`** / **`data-shimmer-height`** override the measured dimensions (in pixels) of an element. Useful for dynamically sized elements that have no dimensions yet when the skeleton is generated (e.g. images without explicit `width`/`height`, containers filled by JS). Elements with zero dimensions are normally skipped. These attributes let you force a skeleton block.

```html
<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 class="content">
      <p>Each leaf element here gets its own shimmer block.</p>
    </div>
  </div>
</phantom-ui>
```

## Repeat mode

When loading a dynamic list or table, you often don't have the data yet to render N rows. The `count` attribute lets you define a single template element and generate multiple skeleton rows from it:

```html
<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>
```

The component measures the template once, then duplicates the skeleton blocks vertically for each count. `count-gap` adds spacing (in pixels) between repeated rows. When `loading` is removed, only the real template element is shown.

This is useful with framework loops where the list is empty before data loads:

```tsx
// React
<phantom-ui loading={!users} count={5} count-gap={8}>
  <div class="row-template">
    <img src="/placeholder.png" width="32" height="32" />
    <span>Placeholder Name</span>
    <span>placeholder@email.com</span>
  </div>
</phantom-ui>
```

## How it works

1. Your real content is rendered in the DOM with `color: transparent` and media elements hidden. Icons drawn with CSS `mask-image` (including on `::before` / `::after`) are detected at runtime and hidden too. Container backgrounds and borders stay visible, preserving the natural card/section outline.

2. The component walks the DOM tree and identifies "leaf" elements: text nodes, images, buttons, inputs, and anything without child elements. Container divs are recursed into, not captured.

3. Each leaf element is measured with `getBoundingClientRect()` relative to the host. Border radius is read from `getComputedStyle()`. Table cells get special handling to measure actual text width, not cell width.

4. An absolutely-positioned overlay renders one shimmer block per measured element, with a CSS gradient animation sweeping across each block.

5. A `ResizeObserver`, `MutationObserver`, and media `load` listener re-measure automatically when the layout changes (window resize, content injection, DOM mutations, or images/videos finishing loading). With `pierce-shadow`, the pierced shadow roots are watched as well.

6. When `loading` is removed, the overlay is destroyed and real content is revealed. `aria-busy` is set automatically on the host element to communicate loading state to assistive technologies, and the placeholder content is made `inert` while loading so it stays out of the tab order and the accessibility tree (elements marked `data-shimmer-ignore` stay interactive).

## Performance

The DOM measurement pipeline is fast. Benchmarked in Chrome:

| Elements | Leaf nodes | Time |
| --- | --- | --- |
| 100 | 334 | ~20ms |
| 500 | 1,667 | ~25ms |
| 1,000 | 3,334 | ~31ms |

Even with 1,000 elements (far more than a typical skeleton screen), the full measure → render cycle completes in a single frame. No debouncing or virtualization needed.

## CSS custom properties

You can style the component from the outside using CSS custom properties instead of (or in addition to) attributes:

```css
phantom-ui {
  --shimmer-color: rgba(100, 200, 255, 0.3);
  --shimmer-duration: 2s;
  --shimmer-bg: rgba(100, 200, 255, 0.08);
}
```

For live theming (e.g. a dark-mode toggle with no re-render), pass a `var()` reference through an attribute so it re-resolves on paint:

```html
<phantom-ui shimmer-color="var(--brand-shimmer)" background-color="var(--brand-bg)">
```

## Custom Elements Manifest

The package ships a `custom-elements.json` manifest, which gives IDE autocomplete, Storybook autodocs, and framework tooling the full picture of attributes, properties, slots, and types.

## Bundle size

The CDN build (Lit included) is ~34kb / ~11.5kb gzipped.

When used as an ES module with a bundler, Lit is likely already in your dependency tree, bringing the component cost down to under 2kb.

## Development

```bash
bun install
bun run storybook       # dev server on :6006
bun run build           # tsc + custom elements manifest + CDN bundle
bun run lint            # biome check
bun run lint:fix        # biome auto-fix
bun run test            # browser tests (Chromium)
bun run test:all        # browser tests (Chromium + Firefox + WebKit)
bun run playground      # local server to test the component
```

The `examples/` directory contains test apps for React, Vue, Solid, Angular, and Qwik, each wired to the local package.

## Contributing

Bug reports, feature requests, and PRs welcome. Read the [contributing guide](./CONTRIBUTING.md) and [code of conduct](./CODE_OF_CONDUCT.md) before opening a PR.

For security vulnerabilities, see the [security policy](./SECURITY.md).

## Acknowledgements

The DOM-measurement overlay technique builds on prior art from [page-skeleton-webpack-plugin](https://github.com/ElemeFE/page-skeleton-webpack-plugin) (2018) and [@findify/skeleton-generator](https://github.com/findify/skeleton-generator) (~2019). phantom-ui reimagines this concept as a single universal Web Component instead of framework-specific adapters.

## License

MIT
