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.json
Loading · DOMzero CLS
fetching…shift 0.000

SkeletonSwapesc or double-click to exit full screendouble-tap to exit

data/skeleton-swap.tsx
"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