SkeletonSwap
Both layers share one reserved frame, so the swap is a cross-fade and the surrounding layout physically cannot move.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/skeleton-swap.jsonLoading · DOMzero CLS
fetching…shift 0.000
"use client";
import * as React from "react";
import { cx, inertValue } from "@pouriahlabs/wisp-ui";
/**
* SkeletonSwap — a skeleton that occupies the real content's box.
*
* The dullest component in the library and the most useful in a real app.
* Almost every skeleton ships the wrong height, so content lurches into place
* when it loads and everything below it jumps. The fix isn't a better easing
* curve, it's structural: both layers are absolutely positioned inside one
* reserved frame, so the swap is a cross-fade and the surrounding layout
* physically cannot move.
*
* That means you own the frame's height — via `minHeight`, an aspect ratio, or
* a parent that sizes it. If the real content can vary a lot, size the frame to
* the common case and let the content scroll rather than letting the frame grow.
*
* `aria-busy` on the frame is the real, programmatic loading signal. The
* skeleton layer additionally gets `role="status"` while it's the one shown —
* a plain `<div>` has no implicit role, and AT doesn't announce `aria-label`
* on an element that isn't in the accessibility tree to begin with, so
* `label` needs a role that actually gets included. Both layers use `inert`
* so the one currently hidden can't be tabbed into.
*/
export interface SkeletonSwapProps extends React.HTMLAttributes<HTMLDivElement> {
/** `false` shows the skeleton, `true` cross-fades to children. */
loaded: boolean;
/** The placeholder. Build it to the same shape as the real content. */
skeleton: React.ReactNode;
/** Cross-fade duration, ms. */
duration?: number;
/** Announce the loading state to assistive tech. */
label?: string;
}
export function SkeletonSwap({
loaded,
skeleton,
duration = 420,
label = "Loading",
className,
style,
children,
...props
}: SkeletonSwapProps) {
return (
<div
className={cx("wisp-swap", loaded && "is-loaded", className)}
style={{ "--wisp-swap-duration": `${duration}ms`, ...style } as React.CSSProperties}
aria-busy={!loaded}
{...props}
>
<div
className="wisp-swap-layer is-skeleton"
aria-hidden={loaded}
inert={inertValue(loaded)}
role={loaded ? undefined : "status"}
aria-label={loaded ? undefined : label}
>
{skeleton}
</div>
<div
className="wisp-swap-layer is-real"
aria-hidden={!loaded}
inert={inertValue(!loaded)}
>
{children}
</div>
</div>
);
}
/**
* A single skeleton bar. Give it the width of the line it stands in for —
* matching the real content's rhythm is most of what makes the swap invisible.
*/
export function SkeletonBar({
width = "100%",
height = "0.62em",
className,
style,
...props
}: React.HTMLAttributes<HTMLSpanElement> & { width?: string | number; height?: string | number }) {
return (
<span
className={cx("wisp-skeleton wisp-swap-bar", className)}
style={{ width, height, ...style }}
{...props}
/>
);
}
/**
* The other two shapes real content actually arrives in.
*
* A placeholder made only of equal bars is what gives skeletons their bad name:
* it stands in for text, and then the avatar, the thumbnail and the chart land
* on top of it at entirely different sizes. Building the placeholder to the
* same *shape* as what is coming is the whole premise of the component, and
* one primitive cannot express three shapes.
*/
export function SkeletonCircle({
size = "2em",
className,
style,
...props
}: React.HTMLAttributes<HTMLSpanElement> & { size?: string | number }) {
return (
<span
className={cx("wisp-skeleton wisp-swap-bar", className)}
style={{ width: size, height: size, flex: "none", borderRadius: "50%", ...style }}
{...props}
/>
);
}
/** Real area: a thumbnail, a chart panel, a map. Square corners, like the frame. */
export function SkeletonBlock({
width = "100%",
height = "4em",
className,
style,
...props
}: React.HTMLAttributes<HTMLSpanElement> & { width?: string | number; height?: string | number }) {
return (
<span
className={cx("wisp-skeleton wisp-swap-bar", className)}
style={{ width, height, borderRadius: 0, ...style }}
{...props}
/>
);
}
Certified against the contract
- ✓Reduced motion paints one composed still frame — never a blank box
- ✓Pauses off-screen and in hidden tabs
- ✓Re-reads design tokens when the theme changes
- ✓SSR-safe: no hydration mismatch, no layout shift
- ✓Decorative layers are aria-hidden and pointer-events-none
- ✓Device pixel ratio clamped
- ✓Zero runtime dependencies beyond React