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-pluslayer
core / primitivesintensity
recedes · safe anywhere, including dense surfacescomposition
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 owna11y
the group adds no semantics of its own · the shimmer is decorative and stops under reduced motionjest-axedependencies
Skeleton from the same packagelive demo · try it out
customize
groupedone sweep across all of them
usage
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
| prop | type | default | notes |
|---|---|---|---|
| children | ReactNode | — | any markup. every Skeleton beneath it joins the sweep, at any depth. |
| className | string | — | the 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.jsonsource · components/ui/skeleton.tsxexactly what this command copies
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 copies
"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";