{"id":48483243,"url":"https://github.com/aejkatappaja/phantom-ui","last_synced_at":"2026-05-03T01:04:35.529Z","repository":{"id":348058580,"uuid":"1196315863","full_name":"Aejkatappaja/phantom-ui","owner":"Aejkatappaja","description":"Structure-aware skeleton loader. One Web Component, every framework.","archived":false,"fork":false,"pushed_at":"2026-04-06T22:28:13.000Z","size":9768,"stargazers_count":5,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-06T23:24:44.001Z","etag":null,"topics":["angular","custom-element","lit","loading","placeholder","qwik","react","shimmer","skeleton","solidjs","typescript","vue","webcomponent"],"latest_commit_sha":null,"homepage":"https://aejkatappaja.github.io/phantom-ui/","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/Aejkatappaja.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-30T15:23:54.000Z","updated_at":"2026-04-06T23:19:58.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/Aejkatappaja/phantom-ui","commit_stats":null,"previous_names":["aejkatappaja/phantom-ui"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/Aejkatappaja/phantom-ui","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Aejkatappaja%2Fphantom-ui","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Aejkatappaja%2Fphantom-ui/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Aejkatappaja%2Fphantom-ui/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Aejkatappaja%2Fphantom-ui/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/Aejkatappaja","download_url":"https://codeload.github.com/Aejkatappaja/phantom-ui/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Aejkatappaja%2Fphantom-ui/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31506578,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-07T03:10:19.677Z","status":"ssl_error","status_checked_at":"2026-04-07T03:10:13.982Z","response_time":105,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["angular","custom-element","lit","loading","placeholder","qwik","react","shimmer","skeleton","solidjs","typescript","vue","webcomponent"],"created_at":"2026-04-07T09:01:05.245Z","updated_at":"2026-05-03T01:04:35.519Z","avatar_url":"https://github.com/Aejkatappaja.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n  \u003cimg src=\".github/assets/logo-phantom.svg\" alt=\"phantom-ui\" width=\"200\" /\u003e\n  \u003cbr /\u003e\n  \u003cimg src=\".github/assets/phantom-ui-text.svg\" alt=\"phantom-ui\" width=\"320\" /\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003cstrong\u003eStructure-aware skeleton loader. One Web Component. Every framework.\u003c/strong\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://www.npmjs.com/package/@aejkatappaja/phantom-ui\"\u003e\u003cimg src=\"https://img.shields.io/npm/v/@aejkatappaja/phantom-ui.svg?style=flat-square\" alt=\"npm version\" /\u003e\u003c/a\u003e\n  \u003cimg src=\"https://img.shields.io/badge/minzipped-~8kb-blue?style=flat-square\" alt=\"bundle size\" /\u003e\n  \u003ca href=\"https://github.com/Aejkatappaja/phantom-ui/blob/main/LICENSE\"\u003e\u003cimg src=\"https://img.shields.io/npm/l/@aejkatappaja/phantom-ui?style=flat-square\" alt=\"license\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://www.webcomponents.org/element/@aejkatappaja/phantom-ui\"\u003e\u003cimg src=\"https://img.shields.io/badge/webcomponents.org-published-blue.svg?style=flat-square\" alt=\"Published on webcomponents.org\" /\u003e\u003c/a\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://aejkatappaja.github.io/phantom-ui/\"\u003eDocumentation\u003c/a\u003e \u0026middot;\n  \u003ca href=\"https://aejkatappaja.github.io/phantom-ui/demo/\"\u003eLive Demo\u003c/a\u003e\n\u003c/p\u003e\n\n---\n\n\u003cbr /\u003e\n\n\u003cdiv align=\"center\"\u003e\n  \u003cpicture\u003e\n    \u003cimg src=\".github/assets/phantom-ui-preview.svg\" alt=\"phantom-ui demo\" width=\"640\" /\u003e\n  \u003c/picture\u003e\n\u003c/div\u003e\n\n\u003cbr /\u003e\n\nStop building skeleton screens by hand. Wrap your real UI in `\u003cphantom-ui\u003e` and it generates shimmer placeholders automatically by measuring your actual DOM at runtime.\n\nNo separate skeleton components to maintain. No copy-pasting layouts. The real component _is_ the skeleton template.\n\n## Why\n\nTraditional 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.\n\n`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.\n\nBecause 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.\n\n## Install\n\n```bash\nbun add @aejkatappaja/phantom-ui     # bun\nnpm install @aejkatappaja/phantom-ui # npm\npnpm add @aejkatappaja/phantom-ui    # pnpm\nyarn add @aejkatappaja/phantom-ui    # yarn\n```\n\nOr drop in a script tag with no build step:\n\n```html\n\u003cscript src=\"https://cdn.jsdelivr.net/npm/@aejkatappaja/phantom-ui/dist/phantom-ui.cdn.js\"\u003e\u003c/script\u003e\n```\n\n## Automatic setup\n\nA `postinstall` script runs after installation and detects your project setup. It handles two things:\n\n**JSX type declarations** — For React, Solid, and Qwik, it generates a `phantom-ui.d.ts` in your `src/` directory so `\u003cphantom-ui\u003e` is recognized in JSX. Vue, Svelte, and Angular work out of the box without any type declaration.\n\n**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)).\n\nIf the postinstall did not run (CI, monorepos, `--ignore-scripts`), you can trigger it manually:\n\n```bash\nnpx @aejkatappaja/phantom-ui init    # npm\nbunx @aejkatappaja/phantom-ui init   # bun\npnpx @aejkatappaja/phantom-ui init   # pnpm\nyarn dlx @aejkatappaja/phantom-ui init  # yarn\n```\n\n\u003cdetails\u003e\n\u003csummary\u003eManual JSX type declarations\u003c/summary\u003e\n\n**React / Next.js / Remix**\n\n```typescript\nimport type { PhantomUiAttributes } from \"@aejkatappaja/phantom-ui\";\n\ndeclare module \"react/jsx-runtime\" {\n  export namespace JSX {\n    interface IntrinsicElements {\n      \"phantom-ui\": PhantomUiAttributes;\n    }\n  }\n}\n```\n\n**Solid**\n\n```typescript\nimport type { SolidPhantomUiAttributes } from \"@aejkatappaja/phantom-ui\";\n\ndeclare module \"solid-js\" {\n  namespace JSX {\n    interface IntrinsicElements {\n      \"phantom-ui\": SolidPhantomUiAttributes;\n    }\n  }\n}\n```\n\n**Qwik**\n\n```typescript\nimport type { PhantomUiAttributes } from \"@aejkatappaja/phantom-ui\";\n\ndeclare module \"@builder.io/qwik\" {\n  namespace QwikJSX {\n    interface IntrinsicElements {\n      \"phantom-ui\": PhantomUiAttributes \u0026 Record\u003cstring, unknown\u003e;\n    }\n  }\n}\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003eManual SSR CSS import\u003c/summary\u003e\n\nAdd this import to your root layout file:\n\n```js\nimport \"@aejkatappaja/phantom-ui/ssr.css\";\n```\n\n| Framework | Layout file |\n| --- | --- |\n| Next.js (App Router) | `app/layout.tsx` |\n| Next.js (Pages) | `pages/_app.tsx` |\n| Nuxt | `app.vue` |\n| SvelteKit | `src/routes/+layout.svelte` |\n| Remix | `app/root.tsx` |\n| Qwik | `src/root.tsx` |\n\n\u003c/details\u003e\n\n## Quick start\n\n```html\n\u003cphantom-ui loading\u003e\n  \u003cdiv class=\"card\"\u003e\n    \u003cimg src=\"avatar.png\" width=\"48\" height=\"48\" style=\"border-radius: 50%\" /\u003e\n    \u003ch3\u003eAda Lovelace\u003c/h3\u003e\n    \u003cp\u003eFirst computer programmer, probably.\u003c/p\u003e\n  \u003c/div\u003e\n\u003c/phantom-ui\u003e\n```\n\nSet `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.\n\n## Data fetching\n\nphantom-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.\n\n### TanStack Query\n\n```tsx\nimport { useQuery } from \"@tanstack/react-query\";\nimport \"@aejkatappaja/phantom-ui\";\n\nfunction UserProfile({ userId }: { userId: string }) {\n  const { data: user, isLoading } = useQuery({\n    queryKey: [\"user\", userId],\n    queryFn: () =\u003e fetch(`/api/users/${userId}`).then((r) =\u003e r.json()),\n  });\n\n  return (\n    \u003cphantom-ui loading={isLoading}\u003e\n      \u003cdiv className=\"card\"\u003e\n        \u003cimg src={user?.avatar ?? \"/placeholder.png\"} width=\"48\" height=\"48\" /\u003e\n        \u003ch3\u003e{user?.name ?? \"Placeholder Name\"}\u003c/h3\u003e\n        \u003cp\u003e{user?.bio ?? \"A short bio goes here.\"}\u003c/p\u003e\n      \u003c/div\u003e\n    \u003c/phantom-ui\u003e\n  );\n}\n```\n\nWhile `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.\n\n### SWR\n\n```tsx\nimport useSWR from \"swr\";\nimport \"@aejkatappaja/phantom-ui\";\n\nfunction UserProfile({ userId }: { userId: string }) {\n  const { data: user, isLoading } = useSWR(`/api/users/${userId}`);\n\n  return (\n    \u003cphantom-ui loading={isLoading}\u003e\n      \u003cdiv className=\"card\"\u003e\n        \u003cimg src={user?.avatar ?? \"/placeholder.png\"} width=\"48\" height=\"48\" /\u003e\n        \u003ch3\u003e{user?.name ?? \"Placeholder Name\"}\u003c/h3\u003e\n        \u003cp\u003e{user?.bio ?? \"A short bio goes here.\"}\u003c/p\u003e\n      \u003c/div\u003e\n    \u003c/phantom-ui\u003e\n  );\n}\n```\n\n### Lists\n\nFor dynamic lists where the data hasn't loaded yet, use `count` to repeat a single template row:\n\n```tsx\nconst { data: users, isLoading } = useQuery({\n  queryKey: [\"users\"],\n  queryFn: () =\u003e fetch(\"/api/users\").then((r) =\u003e r.json()),\n});\n\nreturn (\n  \u003cphantom-ui loading={isLoading} count={5} count-gap={8}\u003e\n    {isLoading ? (\n      \u003cdiv className=\"row\"\u003e\n        \u003cimg src=\"/placeholder.png\" width=\"32\" height=\"32\" /\u003e\n        \u003cspan\u003ePlaceholder Name\u003c/span\u003e\n        \u003cspan\u003eplaceholder@email.com\u003c/span\u003e\n      \u003c/div\u003e\n    ) : (\n      users?.map((u) =\u003e (\n        \u003cdiv key={u.id} className=\"row\"\u003e\n          \u003cimg src={u.avatar} width=\"32\" height=\"32\" /\u003e\n          \u003cspan\u003e{u.name}\u003c/span\u003e\n          \u003cspan\u003e{u.email}\u003c/span\u003e\n        \u003c/div\u003e\n      ))\n    )}\n  \u003c/phantom-ui\u003e\n);\n```\n\n## Framework examples\n\n### React\n\n```tsx\nimport \"@aejkatappaja/phantom-ui\";\n\nfunction ProfileCard({ user, isLoading }: Props) {\n  return (\n    \u003cphantom-ui loading={isLoading} animation=\"pulse\" reveal={0.3}\u003e\n      \u003cdiv className=\"card\"\u003e\n        \u003cimg src={user?.avatar ?? \"/placeholder.png\"} className=\"avatar\" /\u003e\n        \u003ch3\u003e{user?.name ?? \"Placeholder Name\"}\u003c/h3\u003e\n        \u003cp\u003e{user?.bio ?? \"A few words about this person go here.\"}\u003c/p\u003e\n      \u003c/div\u003e\n    \u003c/phantom-ui\u003e\n  );\n}\n\n// List with repeat mode\nfunction UserList({ users, isLoading }: Props) {\n  return (\n    \u003cphantom-ui loading={isLoading} count={5} count-gap={8}\u003e\n      \u003cdiv className=\"row\"\u003e\n        \u003cimg src=\"/placeholder.png\" width=\"32\" height=\"32\" /\u003e\n        \u003cspan\u003ePlaceholder Name\u003c/span\u003e\n      \u003c/div\u003e\n    \u003c/phantom-ui\u003e\n  );\n}\n```\n\n### Vue\n\n```vue\n\u003cscript setup lang=\"ts\"\u003e\nimport \"@aejkatappaja/phantom-ui\";\n\nconst props = defineProps\u003c{ loading: boolean }\u003e();\n\u003c/script\u003e\n\n\u003ctemplate\u003e\n  \u003cphantom-ui :loading=\"props.loading\" animation=\"breathe\" stagger=\"0.05\"\u003e\n    \u003cdiv class=\"card\"\u003e\n      \u003cimg src=\"/avatar.png\" class=\"avatar\" /\u003e\n      \u003ch3\u003eAda Lovelace\u003c/h3\u003e\n      \u003cp\u003eFirst computer programmer, probably.\u003c/p\u003e\n    \u003c/div\u003e\n  \u003c/phantom-ui\u003e\n\u003c/template\u003e\n```\n\n### Svelte\n\n```svelte\n\u003cscript lang=\"ts\"\u003e\n  import \"@aejkatappaja/phantom-ui\";\n\n  export let loading = true;\n\u003c/script\u003e\n\n\u003cphantom-ui {loading} reveal={0.4} stagger={0.03}\u003e\n  \u003cdiv class=\"card\"\u003e\n    \u003cimg src=\"/avatar.png\" alt=\"avatar\" class=\"avatar\" /\u003e\n    \u003ch3\u003eAda Lovelace\u003c/h3\u003e\n    \u003cp\u003eFirst computer programmer, probably.\u003c/p\u003e\n  \u003c/div\u003e\n\u003c/phantom-ui\u003e\n```\n\n### Angular\n\n```typescript\nimport { Component, signal, CUSTOM_ELEMENTS_SCHEMA } from \"@angular/core\";\nimport \"@aejkatappaja/phantom-ui\";\n\n@Component({\n  selector: \"app-profile\",\n  schemas: [CUSTOM_ELEMENTS_SCHEMA],\n  template: `\n    \u003cphantom-ui [attr.loading]=\"loading() ? '' : null\" animation=\"pulse\"\u003e\n      \u003cdiv class=\"card\"\u003e\n        \u003cimg src=\"/avatar.png\" class=\"avatar\" /\u003e\n        \u003ch3\u003eAda Lovelace\u003c/h3\u003e\n        \u003cp\u003eFirst computer programmer, probably.\u003c/p\u003e\n      \u003c/div\u003e\n    \u003c/phantom-ui\u003e\n  `,\n})\nexport class ProfileComponent {\n  loading = signal(true);\n}\n```\n\n### Solid\n\n```tsx\nimport { createSignal } from \"solid-js\";\nimport \"@aejkatappaja/phantom-ui\";\n\nfunction ProfileCard() {\n  const [loading, setLoading] = createSignal(true);\n\n  return (\n    \u003cphantom-ui attr:loading={loading() ? \"\" : null} animation=\"shimmer\" stagger={0.05}\u003e\n      \u003cdiv class=\"card\"\u003e\n        \u003cimg src=\"/avatar.png\" class=\"avatar\" /\u003e\n        \u003ch3\u003eAda Lovelace\u003c/h3\u003e\n        \u003cp\u003eFirst computer programmer, probably.\u003c/p\u003e\n      \u003c/div\u003e\n    \u003c/phantom-ui\u003e\n  );\n}\n```\n\n### SSR frameworks (Next.js, Nuxt, SvelteKit, Remix, Qwik)\n\nThe component needs browser APIs to measure the DOM. Import it client-side only:\n\n```tsx\n// Next.js\n\"use client\";\nimport { useEffect } from \"react\";\n\nexport default function Page() {\n  useEffect(() =\u003e { import(\"@aejkatappaja/phantom-ui\"); }, []);\n  return \u003cphantom-ui loading\u003e...\u003c/phantom-ui\u003e;\n}\n```\n\n```vue\n\u003c!-- Nuxt --\u003e\n\u003cscript setup\u003e\nonMounted(() =\u003e import(\"@aejkatappaja/phantom-ui\"));\n\u003c/script\u003e\n\n\u003ctemplate\u003e\n  \u003cClientOnly\u003e\n    \u003cphantom-ui loading\u003e...\u003c/phantom-ui\u003e\n  \u003c/ClientOnly\u003e\n\u003c/template\u003e\n```\n\n```svelte\n\u003c!-- SvelteKit --\u003e\n\u003cscript\u003e\n  import { onMount } from \"svelte\";\n  onMount(() =\u003e import(\"@aejkatappaja/phantom-ui\"));\n\u003c/script\u003e\n```\n\n```tsx\n// Qwik\nimport { component$, useVisibleTask$ } from \"@builder.io/qwik\";\n\nexport default component$(() =\u003e {\n  // eslint-disable-next-line qwik/no-use-visible-task\n  useVisibleTask$(async () =\u003e {\n    import(\"@aejkatappaja/phantom-ui\");\n  });\n\n  return \u003cphantom-ui loading\u003e...\u003c/phantom-ui\u003e;\n});\n```\n\nThe `\u003cphantom-ui\u003e` 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.\n\n#### Pre-hydration CSS\n\nBefore JavaScript loads, content inside `\u003cphantom-ui loading\u003e` can briefly flash as visible text. The package ships a small CSS file that hides this content immediately, with no JS needed:\n\n```css\nimport \"@aejkatappaja/phantom-ui/ssr.css\";\n```\n\nThe `postinstall` script automatically 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). If you use the CDN build, add the rules directly in your `\u003chead\u003e`:\n\n```html\n\u003cstyle\u003e\n  phantom-ui[loading] * {\n    -webkit-text-fill-color: transparent !important;\n    pointer-events: none;\n    user-select: none;\n  }\n  phantom-ui[loading] img, phantom-ui[loading] svg,\n  phantom-ui[loading] video, phantom-ui[loading] canvas,\n  phantom-ui[loading] button, phantom-ui[loading] [role=\"button\"] {\n    opacity: 0 !important;\n  }\n\u003c/style\u003e\n```\n\n## Attributes\n\n| Attribute | Type | Default | Description |\n| --- | --- | --- | --- |\n| `loading` | `boolean` | `false` | Show shimmer overlay or real content |\n| `animation` | `string` | `shimmer` | Animation mode: `shimmer`, `pulse`, `breathe`, or `solid` |\n| `shimmer-direction` | `string` | `ltr` | Direction of the shimmer sweep: `ltr`, `rtl`, `ttb`, or `btt` (shimmer mode only) |\n| `shimmer-color` | `string` | `rgba(128,128,128,0.3)` | Color of the animated gradient sweep (shimmer mode only) |\n| `background-color` | `string` | `rgba(128,128,128,0.2)` | Background of each shimmer block (all modes) |\n| `duration` | `number` | `1.5` | Animation cycle in seconds |\n| `stagger` | `number` | `0` | Delay in seconds between each block's animation start |\n| `reveal` | `number` | `0` | Fade-out duration in seconds when loading ends |\n| `count` | `number` | `1` | Number of skeleton rows to repeat from a single template |\n| `count-gap` | `number` | `0` | Gap in pixels between repeated rows |\n| `fallback-radius` | `number` | `4` | Border radius (px) for flat elements like text |\n| `debug` | `boolean` | `false` | Outline each measured block with an index for inspection |\n\n## Fine-grained control\n\nData attributes let you control which elements get shimmer treatment and how they are measured:\n\n**`data-shimmer-ignore`** keeps an element and all its descendants visible during loading. Useful for logos, brand marks, or live indicators that should always be shown.\n\n**`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.\n\n**`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.\n\n```html\n\u003cphantom-ui loading\u003e\n  \u003cdiv class=\"dashboard\"\u003e\n    \u003cdiv class=\"logo\" data-shimmer-ignore\u003eACME\u003c/div\u003e\n    \u003cdiv class=\"kpi-row\" data-shimmer-no-children\u003e\n      \u003cspan\u003e$48.2k\u003c/span\u003e\n      \u003cspan\u003e2,847 users\u003c/span\u003e\n      \u003cspan\u003e42ms p99\u003c/span\u003e\n    \u003c/div\u003e\n    \u003cimg src=\"/hero.jpg\" data-shimmer-width=\"600\" data-shimmer-height=\"400\" /\u003e\n    \u003cdiv class=\"content\"\u003e\n      \u003cp\u003eEach leaf element here gets its own shimmer block.\u003c/p\u003e\n    \u003c/div\u003e\n  \u003c/div\u003e\n\u003c/phantom-ui\u003e\n```\n\n## Repeat mode\n\nWhen 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:\n\n```html\n\u003cphantom-ui loading count=\"5\" count-gap=\"8\"\u003e\n  \u003cdiv class=\"user-row\"\u003e\n    \u003cimg src=\"avatar.png\" width=\"32\" height=\"32\" /\u003e\n    \u003cspan\u003eJohn Doe\u003c/span\u003e\n    \u003cspan\u003ejohn@acme.io\u003c/span\u003e\n  \u003c/div\u003e\n\u003c/phantom-ui\u003e\n```\n\nThe 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.\n\nThis is useful with framework loops where the list is empty before data loads:\n\n```tsx\n// React\n\u003cphantom-ui loading={!users} count={5} count-gap={8}\u003e\n  \u003cdiv class=\"row-template\"\u003e\n    \u003cimg src=\"/placeholder.png\" width=\"32\" height=\"32\" /\u003e\n    \u003cspan\u003ePlaceholder Name\u003c/span\u003e\n    \u003cspan\u003eplaceholder@email.com\u003c/span\u003e\n  \u003c/div\u003e\n\u003c/phantom-ui\u003e\n```\n\n## How it works\n\n1. Your real content is rendered in the DOM with `color: transparent` and media elements hidden. Container backgrounds and borders stay visible, preserving the natural card/section outline.\n\n2. 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.\n\n3. 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.\n\n4. An absolutely-positioned overlay renders one shimmer block per measured element, with a CSS gradient animation sweeping across each block.\n\n5. 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).\n\n6. 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.\n\n## Performance\n\nThe DOM measurement pipeline is fast. Benchmarked in Chrome:\n\n| Elements | Leaf nodes | Time |\n| --- | --- | --- |\n| 100 | 334 | ~20ms |\n| 500 | 1,667 | ~25ms |\n| 1,000 | 3,334 | ~31ms |\n\nEven 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.\n\n## CSS custom properties\n\nYou can style the component from the outside using CSS custom properties instead of (or in addition to) attributes:\n\n```css\nphantom-ui {\n  --shimmer-color: rgba(100, 200, 255, 0.3);\n  --shimmer-duration: 2s;\n  --shimmer-bg: rgba(100, 200, 255, 0.08);\n}\n```\n\n## Custom Elements Manifest\n\nThe 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.\n\n## Bundle size\n\nThe CDN build (Lit included) is ~22kb / ~8kb gzipped.\n\nWhen used as an ES module with a bundler, Lit is likely already in your dependency tree, bringing the component cost down to under 2kb.\n\n## Development\n\n```bash\nbun install\nbun run storybook       # dev server on :6006\nbun run build           # tsc + custom elements manifest + CDN bundle\nbun run lint            # biome check\nbun run lint:fix        # biome auto-fix\nbun run test            # browser tests (Chromium)\nbun run test:all        # browser tests (Chromium + Firefox + WebKit)\nbun run playground      # local server to test the component\n```\n\nThe `examples/` directory contains test apps for React, Vue, Solid, Angular, and Qwik, each wired to the local package.\n\n## Acknowledgements\n\nThe 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.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Faejkatappaja%2Fphantom-ui","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Faejkatappaja%2Fphantom-ui","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Faejkatappaja%2Fphantom-ui/lists"}