Skip to content

How It Works

When loading is set, phantom-ui runs through these steps:

The real content is rendered in the DOM but styled to be invisible:

  • color: transparent hides text while preserving layout
  • opacity: 0 hides images, SVGs, videos, and canvases
  • icons drawn with CSS mask-image (tinted via background-color) are detected at runtime and hidden too, including masks on ::before / ::after pseudo-elements
  • pointer-events: none prevents interaction
  • Container backgrounds and borders remain visible, giving a natural card outline

This applies to all descendants, not just direct children. A light DOM stylesheet (phantom-ui[loading] *) ensures deeply nested media and text are hidden.

The component recursively walks all slotted children. For each element, it determines whether it is a “leaf” – a terminal content node that should become a shimmer block.

An element is a leaf if it is one of these tags: img, svg, video, canvas, iframe, input, textarea, button, hr. Or if it has no child elements (only text nodes).

Container elements like div, section, ul are recursed into, not captured.

Each leaf element is measured with getBoundingClientRect() relative to the host component. The computed borderRadius is read from getComputedStyle().

Table cells (td, th) get special handling: a temporary <span> is inserted to measure actual text width rather than the full cell width.

An absolutely-positioned overlay is rendered with one div per measured element. Each div is positioned at the exact {x, y, width, height} with the correct border radius. A CSS animation sweeps a gradient across each block.

A ResizeObserver, MutationObserver, and media load listener watch for layout changes. When the container resizes, the DOM mutates, or an image/video finishes loading, measurement is re-triggered on the next animation frame. This means images without explicit width/height attributes are picked up automatically once they load. In pierce-shadow mode the same observer also watches each pierced shadow root, so a component re-rendering its own internals is re-measured too.

When loading is removed, the overlay is destroyed. The invisible styles are removed, the inert markers are cleared, and real content appears.

aria-busy is set on the host while loading. The slotted content is also made inert so it is removed from the tab order and the accessibility tree: invisible placeholder elements stay out of keyboard navigation and screen reader output, and cannot be activated. Because inert is inherited and cannot be cancelled by a descendant, phantom-ui marks the largest subtrees that contain no data-shimmer-ignore element and leaves those subtrees interactive. It only sets inert on elements it owns, so a consumer’s own inert is preserved. inert is applied in JavaScript, so during server-side rendering before hydration only the visual hiding applies.

If an element has this attribute, it and all descendants are skipped entirely during the DOM walk. The element stays visible and interactive during loading (it is never made inert, unlike the rest of the slotted content).

If an element has this attribute, it is captured as a single block without recursing into children. Useful for dense groups that should appear as one placeholder.

Many design systems render icons as an element (or its ::before / ::after pseudo-element) with a CSS mask-image and a background-color for the tint, rather than as an <img> or <svg>. CSS selectors can’t target “has a mask”, so phantom-ui detects these at runtime via getComputedStyle and hides them like real media during loading. Works in light DOM and inside pierced shadow roots.

Elements with width: 0 or height: 0 (collapsed, display: none) are skipped. Use data-shimmer-width and data-shimmer-height to override measured dimensions and force a block for elements that render at zero size.

Only the outer <svg> bounding box is captured, not individual paths or shapes within.

The DOM measurement pipeline runs in a single requestAnimationFrame callback. 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 any realistic skeleton screen), the full measure → render cycle completes within a single frame.