FoundationsSkeleton
A pulsing placeholder that holds the shape of loading content.
The thinking
Loading is a conversation turn — the screen owes you a reply that says "understood, thinking" before the real content lands. A blank space says nothing and a spinner says only that something is happening somewhere; a skeleton answers with the actual shape that's coming, so the layout never jumps when it arrives. That answer is also the thing this component refuses to fake: it only stands in for content whose shape I already know — the avatar circle, the two lines of text — because a guessed shape produces layout shift at the worst possible moment, right as the real thing arrives. The one decision everything hangs on is that the pulse animates opacity and nothing else:
animate-pulse breathes the block in and out without touching its size or position, which keeps the wait calm instead of theatrical. The detail that earns its keep is the restraint: it holds a known shape, breathes, and gets out of the way.Interface design
The entire visual vocabulary is two utilities. The surface is
bg-muted — one quiet rung off the page, never --background itself — because a placeholder should read as absence, not as a thing worth looking at. The corner is rounded-md, just enough to say made-on-purpose when a caller forgets to shape it. Everything else is deliberately missing: no width, no height, no variants, so an unstyled Skeleton renders nothing you can see. Every dimension is the caller's claim about the incoming content — the demo passes h-10 w-10 rounded-full for the avatar and h-4 w-2/5 for the short line of text — and the component's job is to hold that claim steady, not to have opinions of its own.Interaction
The only motion is Tailwind's stock
animate-pulse — a 2s cubic-bezier(0.4, 0, 0.6, 1) loop that eases opacity down to 0.5 and back — and I left it stock on purpose; tailwind.config.ts overrides plenty of animations, but not this one. Opacity is the whole story: the fade never asks for layout or paint, so a page of skeletons breathing at once costs roughly nothing. And there is no intent to infer here, which is worth naming — open delays and hover thresholds exist to read what you're about to do, but a skeleton sits on the other side of that loop. It is the reply, not the listener: the nod that says the system understood and is working on it. Two seconds a breath is slow on purpose — a quick pulse reads as urgency, and a wait doesn't need the interface acting anxious.Sound design
This one is silent, and the silence is load-bearing. The global SoundLayer speaks through a single capturing click listener that matches interactive roles —
a[href], button, [role="option"] and their kin — so a plain div with no role sits outside the audible set entirely. It never needs data-silent, because that attribute exists to hush elements the layer would otherwise catch, and the layer can't catch this one. That falls straight out of the delegation model: sound in this system lands with a click the user committed to, and a skeleton is the one moment where the user has nothing to do but wait. Nothing to click, nothing to say.Empathy
Under reduced motion,
motion-reduce:animate-none stops the pulse and leaves a still bg-muted block — the shape was always the information; the breathing was garnish. Keyboard users never snag on it, because a div takes no focus and holds no tab stop. A screen reader finds no role and no text and moves past it, which means this component announces nothing about loading at all — I don't set aria-busy or wire a live region here, because that signal belongs on the region doing the loading, once, not on every gray block inside it. And there is no overflow case, because there is no content: the skeleton is exactly the size of the classes you hand it, and if the real thing lands at a different size, that jump is the caller's guess gone wrong — no amount of pulsing hides it.import * as React from "react";
import { cn } from "@/lib/utils";
export interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
ref?: React.Ref<HTMLDivElement>;
}
// the pulse animates opacity only; under reduced motion it holds still
// as a calm muted block
const Skeleton = ({ ref, className, ...props }: SkeletonProps) => (
<div
ref={ref}
className={cn(
"animate-pulse rounded-md bg-muted motion-reduce:animate-none",
className
)}
{...props}
/>
);
Skeleton.displayName = "Skeleton";
export { Skeleton };