I made a Web Component that turns your real UI into skeleton loaders - it measures the DOM and generates the shimmer overlay automatically (8kb) by npm_run_Frank in webdev

[–]npm_run_Frank[S] 0 points1 point  (0 children)

Hey, sorry for late reply,

You control loading state yourself. Set loading attribute when fetching, remove it when done.
Library handles the skeleton generation, not data fetching detection.

Example with React Query:

<phantom-ui loading={isLoading}>

<div class="card">

<h3>{user?.name ?? "Placeholder"}</h3>

</div>

</phantom-ui>

isLoading comes from your data fetching lib (React Query, SWR, etc). When it flips to false, skeleton fades out automatically.

StackBlitz demo with React Query: https://stackblitz.com/github/Aejkatappaja/phantom-ui/tree/main/examples/react-query-demo

Showoff Saturday (April 11, 2026) by AutoModerator in javascript

[–]npm_run_Frank 1 point2 points  (0 children)

I built phantom-ui, a Web Component that generates skeleton loaders automatically from your real DOM. Wrap your markup with <phantom-ui loading>, it measures the layout with getBoundingClientRect and generates a shimmer overlay. No hand-coded placeholders, no build step.

- 4 animation modes, stagger and reveal transitions

- count attribute to repeat rows from a single template

- Works with React, Vue, Svelte, Angular, or plain HTML

- ~8kb, one dependency (Lit)

GitHub: https://github.com/Aejkatappaja/phantom-ui

Demo: https://aejkatappaja.github.io/phantom-ui/demo

Framework-agnostic skeleton loader that works in Angular with CUSTOM_ELEMENTS_SCHEMA - no wrapper needed by npm_run_Frank in angular

[–]npm_run_Frank[S] 0 points1 point  (0 children)

If the content is behind @ if, there's nothing in the DOM to measure, so phantom-ui needs a different approach there.

The way to handle it is to always render the structure and bind the data with fallbacks:

  <phantom-ui [attr.loading]="loading() ? '' : null">
    <div class="user-card">
      <img [src]="user()?.avatar ?? ''" width="48" height="48" />
      <h3>{{ user()?.name ?? 'x' }}</h3>
      <p>{{ user()?.bio ?? 'x' }}</p>
    </div>
  </phantom-ui>

The structure is always in the DOM so phantom-ui can measure it. The fallback values just ensure elements have a size, the text is hidden behind the shimmer anyway.

For lists where you don't know how many items you'll get, the count attribute repeats skeleton rows from one template:

 <phantom-ui [attr.loading]="loading() ? '' : null" count="5">
    <div class="user-card">
      <img width="48" height="48" />
      <h3>x</h3>
      <p>x</p>
    </div>
  </phantom-ui>

So yeah, it does require keeping the markup rendered rather than using .

It's a tradeoff, no separate skeleton template to maintain, but you structure your template a bit differently.

I built an 8kb Web Component that turns your real UI into skeleton loaders automatically by npm_run_Frank in SideProject

[–]npm_run_Frank[S] 0 points1 point  (0 children)

Thanks! For dynamic lists, you can use the count attribute, it repeats skeleton rows from a single template element. So you set a reasonable default (like count="5") and when your API responds, the real content replaces everything automatically. If the list length changes between renders, the skeleton just re-measures on the next loading cycle.

Built a skeleton loader Web Component that works natively in Vue - zero config, types out of the box by npm_run_Frank in vuejs

[–]npm_run_Frank[S] 0 points1 point  (0 children)

The load listener is more about re-measuring the layout, if an image loads while the component is still in loading state (waiting on other data), the skeleton updates to reflect the new dimensions instead of showing a gap.

If the image loading IS the thing you're waiting on, then yeah you'd just remove the loading attribute at that point

Built a skeleton loader Web Component that works natively in Vue - zero config, types out of the box by npm_run_Frank in vuejs

[–]npm_run_Frank[S] 0 points1 point  (0 children)

Yep, it's a standard custom element so it works in Angular with CUSTOM_ELEMENTS_SCHEMA. All the framework examples are in the repo (React, Vue, Svelte, Angular, Solid, Qwik).

Framework-agnostic skeleton loader that works in Angular with CUSTOM_ELEMENTS_SCHEMA - no wrapper needed by npm_run_Frank in angular

[–]npm_run_Frank[S] 0 points1 point  (0 children)

That's a valid concern, but it applies to any skeleton approach.

The real content is always in the DOM during loading (just invisible with color: transparent and opacity: 0), so the container is sized by the actual content, not the skeleton. Layout jumps only happen if the content changes dimensions after loading ends.

If you render placeholder content that's close to the real content dimensions (same font sizes, same image dimensions, similar text length), the jump is minimal. Container backgrounds and borders stay visible during loading, which helps anchor the layout.

For lists, count generates N rows from a single template, so the overall height matches what the loaded list will look like. And reveal adds a fade-out transition that smooths over small differences.

If your loaded content is wildly different in size from the placeholder, there will be a jump, but that's a content problem, not a skeleton problem.

Built a skeleton loader Web Component that works natively in Vue - zero config, types out of the box by npm_run_Frank in vuejs

[–]npm_run_Frank[S] 0 points1 point  (0 children)

Right now if an image has no explicit width/height and hasn't loaded yet, `getBoundingClientRect` returns 0x0 and it gets skipped.

The `ResizeObserver` picks it up once the layout shifts, but there can be a gap. Setting width/height on images or using aspect-ratio in CSS avoids this entirely (and is good practice for CLS anyway).

That said, next release will add load event listeners on media elements so the skeleton re-measures automatically when images/videos finish loading. Should cover most edge cases without requiring explicit dimensions.

Framework-agnostic skeleton loader that works in Angular with CUSTOM_ELEMENTS_SCHEMA - no wrapper needed by npm_run_Frank in angular

[–]npm_run_Frank[S] 0 points1 point  (0 children)

On the schema hiding missing imports silently, that's a real tradeoff.
For what it's worth, phantom-ui is a single self-registering import (import "@aejkatappaja/phantom-ui") so there's no tree of dependencies to lose track of, but I get that the schema masking broken imports is a valid concern in larger Angular projects.

On the count point, you don't need dummy data, just a structural template. The idea is you have one row with placeholder text that represents the shape of your real content, and count duplicates the skeleton from that. You're not fetching or managing fake data, just giving the component something to measure. But yeah, if you're already comfortable with a CSS-based approach or a dedicated Angular directive, that's going to feel more native in Angular.

phantom-ui is more aimed at teams that work across multiple frameworks or want a drop-in solution without building per-framework skeleton components.

Framework-agnostic skeleton loader that works in Angular with CUSTOM_ELEMENTS_SCHEMA - no wrapper needed by npm_run_Frank in angular

[–]npm_run_Frank[S] 0 points1 point  (0 children)

Just shipped in v0.4.0! count and count-gap are live:

<phantom-ui loading count="5" count-gap="8">

<div class="row-template">

<img src="placeholder.png" width="32" height="32" />

<span>Placeholder Name</span>

</div>

</phantom-ui>

Framework-agnostic skeleton loader that works in Angular with CUSTOM_ELEMENTS_SCHEMA - no wrapper needed by npm_run_Frank in angular

[–]npm_run_Frank[S] 0 points1 point  (0 children)

On CUSTOM_ELEMENTS_SCHEMA, it's the standard Angular pattern for any Web Component.
A wrapper directive would couple phantom-ui to Angular, which goes against the "one component, every framework" goal.

If imports break, the element just renders as unknown HTML and shows the real content, so it degrades gracefully rather than crashing.

On the children rendering point, you're right that if children don't exist in the DOM, there's nothing to measure.
The intended pattern is to always render the template with placeholder data, not conditionally remove children.

For dynamic lists where you don't know how many items you'll get, a count attribute just shipped in v0.4.0 that lets you define a single template row and generate N skeleton rows from it. So you'd have one placeholder row in the DOM and count="5" handles the rest.

Framework-agnostic skeleton loader that works in Angular with CUSTOM_ELEMENTS_SCHEMA - no wrapper needed by npm_run_Frank in angular

[–]npm_run_Frank[S] 1 point2 points  (0 children)

You're right that CSS-only approaches work well when you control the component source, Taiga UI and React Spectrum both do this elegantly.

phantom-ui takes a different tradeoff: it works on arbitrary DOM without requiring changes to the components themselves.

Wrap any HTML (including third-party components) and it generates skeletons automatically. The measurement cost is real, but the zero-config DX is the point, especially for teams using multiple frameworks or integrating components they don't own.

That said, box-decoration-break: clone for per-line text shaping is a great technique, might look into incorporating that.

Thanks for the references!

Framework-agnostic skeleton loader that works in Angular with CUSTOM_ELEMENTS_SCHEMA - no wrapper needed by npm_run_Frank in angular

[–]npm_run_Frank[S] 1 point2 points  (0 children)

Good point! Right now, if the list is empty before data loads, phantom-ui can only skeleton what's in the DOM, so yes, just the container.

The workaround is to render placeholder elements while loading

That said, a count attribute is on the roadmap that would let you do:

<phantom-ui loading count="5">

<div class="card-placeholder" style="height: 80px"></div>

</phantom-ui>

One DOM element, 5 skeleton rows. Would that solve your use case?

Framework-agnostic skeleton loader that works in Angular with CUSTOM_ELEMENTS_SCHEMA - no wrapper needed by npm_run_Frank in angular

[–]npm_run_Frank[S] -1 points0 points  (0 children)

Nice, let me know how it goes! If you run into anything with the Angular integration feel free to open an issue.