docs / core / skeleton-mirror

skeleton-mirror

wrap your real markup and it greys every leaf into a shaped block, so the placeholder is the layout, not an approximation of it. flip loading to false and the children render untouched.

intensity · recedesrsc · server

provenance

authored in usva

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

around any component, usva or your own, while its data loadstoggle loading straight from the same boolean that fetchesnot for a bespoke placeholder. hand-build those with Skeletonnot a spinner. it promises the shape of what is coming

a11y

a labelled role="status" region · the greyed markup is aria-hidden · the sheen stops under reduced motionjest-axe

dependencies

Skeleton from the same package
SkeletonMirror
real
Aino VirtanenProduct designer

A short caption describing the item, its state, and one more line of supporting detail so the block has some height.

OpenShare
<SkeletonMirror>
Loading

props

proptypedefaultnotes
loadingbooleantruefalse renders the children untouched.
labelstring"Loading"what screen readers announce for the status region.

get it

npx shadcn add https://usva.build/r/skeleton.jsoncopy
source · components/ui/skeleton.tsxexactly what this command copiescopy
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";

/* One animation, not two: the sheen alone. `content-visibility` lets the
 * browser skip a skeleton that has scrolled out of view, animation included,
 * and a full-page skeleton is dozens of these. */
export const skeletonVariants = cva(
  "skeleton-sheen relative block bg-sunken [content-visibility:auto]",
  {
    variants: {
      variant: {
        text: "h-4 w-full rounded-md",
        circle: "aspect-square rounded-full",
        rect: "rounded-lg",
      },
    },
    defaultVariants: { variant: "text" },
  },
);

export interface SkeletonProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof skeletonVariants> {
  width?: string | number;
  height?: string | number;
  radius?: string | number;
}

export const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
  ({ className, variant, width, height, radius, style, ...props }, ref) => (
    <div
      ref={ref}
      aria-hidden="true"
      data-usva-skeleton=""
      className={cn(skeletonVariants({ variant }), className)}
      style={{
        width,
        height,
        ...(radius !== undefined ? { borderRadius: radius } : {}),
        ...style,
      }}
      {...props}
    />
  ),
);
Skeleton.displayName = "Skeleton";

export interface SkeletonMirrorProps
  extends React.HTMLAttributes<HTMLDivElement> {
  /** When false, renders children normally. Defaults to true. */
  loading?: boolean;
  label?: string;
  children: React.ReactNode;
}

/**
 * Auto-infers a skeleton from the layout it wraps: it renders your real
 * children with every leaf greyed into a shaped block, so the placeholder
 * always matches the component. One glimmer travels around the outline of the
 * whole skeleton (plus a soft sweep across it), not per block.
 */
export const SkeletonMirror = React.forwardRef<
  HTMLDivElement,
  SkeletonMirrorProps
>(
  (
    { className, loading = true, label = "Loading", children, ...props },
    ref,
  ) => {
    if (!loading) return <>{children}</>;
    return (
      <div
        ref={ref}
        role="status"
        aria-label={label}
        className={cn(
          "skeleton-sheen relative isolate select-none rounded-2xl",
          className,
        )}
        {...props}
      >
        <div aria-hidden="true" className="skeleton-mask pointer-events-none">
          {children}
        </div>
        <span
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 animate-shimmer motion-reduce:animate-none"
        />
        <span className="sr-only">{label}</span>
      </div>
    );
  },
);
SkeletonMirror.displayName = "SkeletonMirror";
source · components/ui/skeleton-group.tsxexactly what this command copiescopy
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface SkeletonGroupProps
  extends React.HTMLAttributes<HTMLDivElement> {}

export const SkeletonGroup = React.forwardRef<
  HTMLDivElement,
  SkeletonGroupProps
>(({ className, children, ...props }, ref) => {
  const groupRef = React.useRef<HTMLDivElement>(null);

  React.useImperativeHandle(ref, () => groupRef.current as HTMLDivElement, []);

  React.useLayoutEffect(() => {
    const group = groupRef.current;
    if (!group) return;

    const skeletons = new Set<HTMLElement>();

    const measure = () => {
      const groupRect = group.getBoundingClientRect();
      group.style.setProperty(
        "--usva-skeleton-group-width",
        `${groupRect.width}px`,
      );
      group.style.setProperty(
        "--usva-skeleton-group-height",
        `${groupRect.height}px`,
      );

      for (const skeleton of skeletons) {
        const skeletonRect = skeleton.getBoundingClientRect();
        skeleton.style.setProperty(
          "--usva-skeleton-offset-x",
          `${skeletonRect.left - groupRect.left}px`,
        );
        skeleton.style.setProperty(
          "--usva-skeleton-offset-y",
          `${skeletonRect.top - groupRect.top}px`,
        );
      }
    };

    const resizeObserver =
      typeof ResizeObserver === "undefined"
        ? null
        : new ResizeObserver(measure);
    resizeObserver?.observe(group);

    const syncSkeletons = () => {
      const next = new Set(
        Array.from(
          group.querySelectorAll<HTMLElement>("[data-usva-skeleton]"),
        ).filter(
          (skeleton) => skeleton.closest("[data-skeleton-group]") === group,
        ),
      );

      for (const skeleton of skeletons) {
        if (next.has(skeleton)) continue;
        resizeObserver?.unobserve(skeleton);
        delete skeleton.dataset.skeletonGrouped;
        skeleton.style.removeProperty("--usva-skeleton-offset-x");
        skeleton.style.removeProperty("--usva-skeleton-offset-y");
        skeletons.delete(skeleton);
      }

      for (const skeleton of next) {
        if (skeletons.has(skeleton)) continue;
        skeletons.add(skeleton);
        skeleton.dataset.skeletonGrouped = "";
        resizeObserver?.observe(skeleton);
      }

      measure();
    };

    syncSkeletons();

    const mutationObserver =
      typeof MutationObserver === "undefined"
        ? null
        : new MutationObserver(syncSkeletons);
    mutationObserver?.observe(group, { childList: true, subtree: true });
    window.addEventListener("resize", measure);

    return () => {
      mutationObserver?.disconnect();
      resizeObserver?.disconnect();
      window.removeEventListener("resize", measure);
      group.style.removeProperty("--usva-skeleton-group-width");
      group.style.removeProperty("--usva-skeleton-group-height");
      for (const skeleton of skeletons) {
        delete skeleton.dataset.skeletonGrouped;
        skeleton.style.removeProperty("--usva-skeleton-offset-x");
        skeleton.style.removeProperty("--usva-skeleton-offset-y");
      }
    };
  }, []);

  return (
    <div
      ref={groupRef}
      data-skeleton-group=""
      className={cn("skeleton-group", className)}
      {...props}
    >
      {children}
    </div>
  );
});
SkeletonGroup.displayName = "SkeletonGroup";