docs / core / skeleton-group

SkeletonGroup

one light passing over the whole block. each Skeleton is measured against the group, so the shimmer crosses them in the order they sit in rather than restarting inside every one.

intensity · recedesrsc · client

provenance

shaped from sisu-plus

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

any cluster of Skeletons that stands for one thing: a card, a row, a profilewrap the layout you already have. the group takes your flex or grid classesnot around a whole page of unrelated blocks. the sweep then means nothinga single Skeleton needs no group. it already shimmers on its own

a11y

the group adds no semantics of its own · the shimmer is decorative and stops under reduced motionjest-axe

dependencies

Skeleton from the same package
live demo · try it out
customize
groupedone sweep across all of them
usagecopy
import { Skeleton, SkeletonGroup } from "usva/primitives/skeleton";

<SkeletonGroup>
  <Skeleton className="h-10 w-10 rounded-full" />
  <Skeleton className="h-4 w-40" />
  <Skeleton className="h-4 w-24" />
</SkeletonGroup>

props

proptypedefaultnotes
childrenReactNodeany markup. every Skeleton beneath it joins the sweep, at any depth.
classNamestringthe group is a plain div, so it carries your layout: flex, grid, whatever the real content uses.

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";