Skeleton
Placeholder blocks and text lines shown while content loads.
Examples
Block
Text lines
Card placeholder
Pulse only
Caller-owned semantics
Skeleton or Shimmer Text?
Two different jobs, easy to confuse by name:
Skeletonis a loading placeholder, a grey block or line where content will appear. Reach for it whenever you know the shape of what's coming.ShimmerTextis animated text, a gradient sweeping across words that already exist ("Thinking…", "Generating response…"). It needs real text to animate.
Never shimmer long-form content to signal loading, and never use an empty Skeleton where a short status label would tell the user more.
Usage
import { Skeleton, SkeletonGroup } from "@rogo-technologies/ui/skeleton";
<Skeleton className="h-24 w-full" />;Shapes
variant="block" (the default) is a rounded box; give it a height. variant="text" is a line sized in em, so it matches the type it stands in for.
<Skeleton className="h-24 w-full" />
<Skeleton variant="text" width="60%" />width takes any CSS length (a number is read as pixels). Tailwind width classes work just as well, so use whichever reads better where a width is one of several dimensions being set.
Groups
A loading state goes inside a SkeletonGroup, which owns the live region so the set announces once. Settings > Skills renders 101 placeholders at once, which is why the group announces and the boxes don't.
<SkeletonGroup className="space-y-2" label="Loading team members">
<Skeleton variant="text" width="60%" />
<Skeleton variant="text" width="100%" />
</SkeletonGroup>Shimmer sweep
Every placeholder sweeps a highlight across itself on top of the pulse, so a loading state reads the same everywhere without each caller opting in. Pass shimmer={false} where the sweep is the wrong texture: a dense grid of small placeholders, or a box whose own animation already carries the motion.
<Skeleton className="aspect-video w-full" />
<Skeleton variant="text" width="60%" shimmer={false} />Accessibility
The announcing unit is the loading state, not the box. A SkeletonGroup is a live region: role="status", aria-live="polite", and an sr-only label ("Loading" unless you pass label). Every Skeleton inside it is aria-hidden, so a grid of a hundred placeholders announces once.
- A
Skeletonon its own is decorative and silent. Passannounce(with alabel) for a lone placeholder that stands in for a whole region with nothing else to announce it. announceis ignored inside a group, so nesting can't produce a second announcement.- Passing your own
role,aria-label,aria-labelledby, oraria-livealso keeps the placeholder in the accessibility tree, since the component won't hide semantics a caller wrote. Preferannounce+label; they do the same thing without the wiring. - Both the pulse and the shimmer sweep stop under
prefers-reduced-motion; the placeholder stays visible. - Reach for
SkeletonGroupby default. A loading state that says nothing to a screen reader is the failure mode this component exists to fix.
API
Skeleton
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "block" | "text" | "block" | Rounded box, or a line of text sized in em. |
width | number | string | - | CSS width; a number is read as pixels. |
shimmer | boolean | true | Sweeping highlight over the pulse; false leaves the pulse alone. |
label | string | "Loading" | Screen-reader text; reaches the a11y tree with announce. |
announce | boolean | false | Makes a lone placeholder its own live region. |
className | string | - | Additional classes: height, width, radius. |
Any other <div> attribute passes through.
SkeletonGroup
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | "Loading" | Screen-reader text for the whole group. |
className | string | - | Layout classes for the placeholder set. |
Guidelines
Do
- Match the shape and rough size of the content being loaded, so nothing jumps when it arrives
- Wrap multi-placeholder loading states in
SkeletonGroup - Prefer a skeleton over a bare "Loading…" line for content whose layout you already know
Don't
- Don't animate a skeleton that may stay on screen for minutes. Show progress or an explanation instead
- Don't build a placeholder out of
animate-pulsedivs; that's what this component is - Don't leave the sweep on a dense grid of small placeholders. Pass
shimmer={false}; the pulse alone is calmer