Skeleton
Placeholder that holds the space content will take while it loads
Skeleton
A placeholder that holds the space content will take while it loads.
Size it with className the way you would size the real thing, so the page does not move when the content arrives.
Installation
import { Skeleton } from '@geckoui/geckoui';Basic Usage
<Skeleton /> {/* one line */}
<Skeleton lines={3} /> {/* a paragraph */}The last line of a multi-line block is drawn short.
Props API
| Prop | Type | Default | Description |
|---|---|---|---|
shape | 'text' | 'rounded' | 'circle' | 'text' | The outline of the placeholder |
animation | 'pulse' | 'wave' | 'none' | 'pulse' | How it animates while it waits |
lines | number | 1 | How many stacked lines to draw. Only applies to text |
loading | boolean | true | While true the placeholder is drawn, otherwise children render |
children | ReactNode | - | What replaces the placeholder once loading is false |
className | string | - | CSS class for the placeholder, and where you size it |
| ...rest | HTMLAttributes<HTMLDivElement> | - | All standard div attributes |
Examples
Shapes
<Skeleton shape="circle" className="size-12" />
<Skeleton shape="rounded" className="h-24 w-40" />
<Skeleton lines={2} />Size a skeleton through className. A text skeleton takes its height from the current font size.
Animation
pulsewavenone<Skeleton animation="pulse" shape="rounded" className="h-16 w-full" />
<Skeleton animation="wave" shape="rounded" className="h-16 w-full" />
<Skeleton animation="none" shape="rounded" className="h-16 w-full" />pulse fades the whole bar, wave sweeps a highlight across it. Both are dropped under prefers-reduced-motion.
Swapping in the real content
<Skeleton loading={isLoading} shape="circle" className="size-12">
<Avatar src={user.image} />
</Skeleton>
<Skeleton loading={isLoading} className="w-40">
<p className="font-semibold">{user.name}</p>
</Skeleton>
<Skeleton loading={isLoading} lines={2}>
<p>{user.bio}</p>
</Skeleton>Children are never rendered while loading, so user.bio is not read until user exists. Once loading is over the wrapper is gone too, leaving only your own markup.
Styling with CSS
| Variable | Default | Applies to |
|---|---|---|
--gecko-skeleton-bg | --color-surface-hover-strong | Bar fill |
--gecko-skeleton-highlight | rgb(255 255 255 / 55%) | The sweep on wave |
--gecko-skeleton-radius | 0.25rem | Corner radius, except on circle |
--gecko-skeleton-duration | 1.6s | One cycle of either animation |
--gecko-skeleton-line-height | 1em | Height of a text bar |
--gecko-skeleton-line-gap | 0.5rem | Space between stacked lines |
--gecko-skeleton-last-line-width | 60% | Width of the short last line |
.GeckoUISkeleton {
--gecko-skeleton-bg: oklch(0.92 0 none);
--gecko-skeleton-duration: 1s;
}Class names and data attributes
GeckoUISkeleton- The placeholder, withdata-shapeanddata-animationGeckoUISkeleton__bar- One bar. Atextskeleton has one per line, every other shape has exactly one
Adding your own shapes
Both axes are extensible maps, so you can add keys through module augmentation and style them with CSS. No component change is needed:
declare module '@geckoui/geckoui' {
interface SkeletonShapeMap {
pill: unknown;
}
}
<Skeleton shape="pill" className="h-6 w-24" />.GeckoUISkeleton[data-shape="pill"] > .GeckoUISkeleton__bar {
border-radius: 9999px;
}The same works for SkeletonAnimationMap.
Accessibility
The placeholder is marked aria-busy="true" and has no role of its own.
To announce a loading region, put the announcement on the region rather than on each skeleton:
<section aria-busy={isLoading} aria-live="polite">
<Skeleton loading={isLoading} lines={3}>
<Article body={article.body} />
</Skeleton>
</section>