Skeleton
FeedbackLoading placeholders in three shapes, announced once for the whole region instead of once per grey box.
Preview
Controls
Notes
One announcement — aria-busy plus a visually hidden "Loading" — with every shape aria-hidden. Nine announced grey rectangles is worse than silence.
The shapes have to match the layout they stand in for. A skeleton that lies about the size of the content is a layout shift with extra steps, and it lands exactly as the reader starts reading.
The sweep is a masked pseudo-element translating across the box, so it animates transform and never repaints what it crosses.
Under reduced motion the tint stays and the sweep stops. The page still reads as loading; it just stops moving.
Source
ShowHide
import { cn } from "@/lib/cn";
/**
* Loading placeholders.
*
* ONE announcement for the whole region — aria-busy plus a visually hidden
* "Loading" — and every shape aria-hidden. Nine announced grey boxes is worse
* than silence.
*
* The shapes have to match the layout they stand in for. A skeleton that lies
* about the size of the content is a layout shift with extra steps, and the
* reader pays for it at exactly the moment they start reading.
*/
const css = `
@keyframes skeleton-sweep { to { translate: 100% 0; } }
.skeleton { position: relative; overflow: hidden; background: var(--paper-sunken); }
.skeleton::after {
content: "";
position: absolute;
inset: 0;
translate: -100% 0;
/* Transform only — the sweep never repaints the box it crosses. */
background: linear-gradient(
90deg,
transparent,
color-mix(in oklab, var(--ink) 9%, transparent),
transparent
);
animation: skeleton-sweep 1.5s ease-in-out infinite;
}
.skeleton-still::after { display: none; }
/* The tint stays, the sweep stops. The page still reads as loading. */
@media (prefers-reduced-motion: reduce) {
.skeleton::after { display: none; }
}
`;
function Bar({ className, shimmer }: { className?: string; shimmer: boolean }) {
return (
<div
aria-hidden="true"
className={cn("skeleton rounded-md", !shimmer && "skeleton-still", className)}
/>
);
}
export type SkeletonVariant = "text" | "card" | "list";
export function Skeleton({
variant = "card",
lines = 3,
shimmer = true,
className,
}: {
variant?: SkeletonVariant;
lines?: number;
shimmer?: boolean;
className?: string;
}) {
const rows = Math.max(1, lines);
return (
<div
role="status"
aria-busy="true"
className={cn("w-full", className)}
>
<style href="skeleton-sweep" precedence="default">
{css}
</style>
<span className="sr-only">Loading</span>
{variant === "text" && (
<div className="space-y-3">
{Array.from({ length: rows }, (_, i) => (
<Bar
key={i}
shimmer={shimmer}
className={cn("h-3.5", i === rows - 1 ? "w-3/5" : "w-full")}
/>
))}
</div>
)}
{variant === "card" && (
<div className="rounded-xl border border-rule bg-paper-raised p-6">
<Bar shimmer={shimmer} className="h-2.5 w-24" />
<Bar shimmer={shimmer} className="mt-5 h-6 w-2/3" />
<div className="mt-5 space-y-3">
{Array.from({ length: rows }, (_, i) => (
<Bar
key={i}
shimmer={shimmer}
className={cn("h-3.5", i === rows - 1 ? "w-1/2" : "w-full")}
/>
))}
</div>
</div>
)}
{variant === "list" && (
<ul className="divide-y divide-rule">
{Array.from({ length: rows }, (_, i) => (
<li key={i} className="flex items-center gap-4 py-4">
<Bar shimmer={shimmer} className="size-9 shrink-0 rounded-full" />
<div className="min-w-0 flex-1 space-y-2.5">
<Bar shimmer={shimmer} className="h-3.5 w-2/5" />
<Bar shimmer={shimmer} className="h-3 w-4/5" />
</div>
</li>
))}
</ul>
)}
</div>
);
}
export default function Demo({
variant = "card",
lines = "3",
shimmer = true,
}: {
variant?: SkeletonVariant;
lines?: string;
shimmer?: boolean;
}) {
return (
<div className="w-full max-w-sm">
<Skeleton variant={variant} lines={Number(lines) || 3} shimmer={shimmer} />
</div>
);
}
Next
MarqueeNext step
Tell me where the business is losing time.
No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.