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 usvalayer
core / primitivesintensity
recedes · safe anywhere, including dense surfacescomposition
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 cominga11y
a labelledrole="status" region · the greyed markup is aria-hidden · the sheen stops under reduced motionjest-axedependencies
Skeleton from the same packageSkeletonMirror
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
| prop | type | default | notes |
|---|---|---|---|
| loading | boolean | true | false renders the children untouched. |
| label | string | "Loading" | what screen readers announce for the status region. |
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";