Skeleton loader for React
Placeholders in the shape of the content, with a soft light that stays still for reduced motion.
One of 4 components in Progress and states
Placeholders in the shape of the content
A skeleton shows the shape of what is coming while data loads, so the page does not jump when it arrives and people already know where to look. These skeletons copy the layout of the kit components, so the switch from placeholder to content keeps every line in place.
Cards fits grids of products, posts or people. List and dashboard covers rows with avatars and the blocks of an application screen, numbers, charts and tables. Article mirrors a post with title, meta line, picture and paragraphs. A soft light passes over the shapes, and with reduced motion it stays still.
Show a skeleton only for loads that take longer than about 300 milliseconds, shorter ones flicker. Match the number of placeholder rows to what usually arrives. Mark the region as busy for screen readers, the skeletons set aria-busy and hide their shapes from the reading order.
Variants
Details
What Skeleton brings along, read from the registry and the source.
- Category
- Progress and states
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/skeleton.tsx
- Brings along
- utilskit
- npm packages
- None
- Languages
- English and German, 1 text of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The SkeletonProps interface from components/ui/skeleton.tsx, with the comments from the code.
shapeoptional'line' | 'circle' | 'rect'line: text height, rounded. circle: avatars. rect: images and cards.
styleoptionalReact.CSSProperties
Built-in texts
Skeleton writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 1 texts
| Key | English | German |
|---|---|---|
| loading | English Loading | German Wird geladen |
Accessibility
Read from the source of the component.
- Sets aria-busy and aria-live, so screen readers announce purpose and state.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 112 lines
'use client';
import { useLayoutEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
const TEXTS = {
en: { loading: 'Loading' },
de: { loading: 'Wird geladen' },
};
/*
* A slow, slanted sheen that sweeps across and then rests, instead of a pulse.
* All placeholders start together, so the light reads as one pass over the page.
* When content arrives, the placeholders fade out over it and the content
* comes in from a soft blur, one item after another.
*/
const STYLES =
'.kitfx-sk::after{content:"";position:absolute;inset:0 -50%;transform:translateX(-60%) skewX(-18deg);background:linear-gradient(90deg,transparent 30%,color-mix(in oklab,var(--foreground) 5%,transparent) 44%,color-mix(in oklab,color-mix(in oklab,var(--primary) 30%,var(--foreground)) 9%,transparent) 50%,color-mix(in oklab,var(--foreground) 5%,transparent) 56%,transparent 70%);animation:kitfx-sk 2.4s cubic-bezier(.45,.05,.35,1) infinite}' +
'@keyframes kitfx-sk{0%{transform:translateX(-60%) skewX(-18deg)}62%,100%{transform:translateX(60%) skewX(-18deg)}}' +
'.kitfx-sk-ghost{animation:kitfx-sk-out .45s var(--ease-kit) forwards}@keyframes kitfx-sk-out{to{opacity:0;filter:blur(2px)}}' +
'.kitfx-sk-reveal>:not(.kitfx-sk-ghost){animation:kitfx-sk-in .65s var(--ease-kit) both}' +
'.kitfx-sk-reveal>:nth-child(2){animation-delay:70ms}.kitfx-sk-reveal>:nth-child(3){animation-delay:140ms}.kitfx-sk-reveal>:nth-child(4){animation-delay:210ms}.kitfx-sk-reveal>:nth-child(n+5){animation-delay:280ms}' +
'@keyframes kitfx-sk-in{from{opacity:0;filter:blur(6px);transform:translateY(6px)}}' +
'@media (prefers-reduced-motion:reduce){.kitfx-sk::after{animation:none;opacity:0}.kitfx-sk-ghost{display:none}.kitfx-sk-reveal>*{animation:none}}';
export interface SkeletonProps {
/** line: text height, rounded. circle: avatars. rect: images and cards. */
shape?: 'line' | 'circle' | 'rect';
className?: string;
style?: React.CSSProperties;
}
/**
* A placeholder that holds the space of content still loading. A slanted light
* sweeps over it and rests; with reduced motion it stays still. Decorative: wrap
* a group in <SkeletonGroup> so screen readers hear "Loading" once.
*/
export function Skeleton({ shape = 'line', className, style }: SkeletonProps) {
return (
<>
{/* React 19 hoists this once into the head, however many skeletons render */}
<style href="kitfx-sk" precedence="default">
{STYLES}
</style>
<span
aria-hidden
style={style}
className={cn(
'kitfx-sk relative block overflow-hidden bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)]',
shape === 'line' && 'h-3 rounded-full',
shape === 'circle' && 'aspect-square rounded-full',
shape === 'rect' && 'rounded-lg',
className,
)}
/>
</>
);
}
/** Several lines of text, the last one shorter, like a paragraph. */
export function SkeletonText({ lines = 3, className }: { lines?: number; className?: string }) {
const widths = ['100%', '94%', '97%', '88%', '92%'];
return (
<span className={cn('block space-y-2.5', className)} aria-hidden>
{Array.from({ length: lines }, (_, i) => (
<Skeleton key={i} style={{ width: i === lines - 1 && lines > 1 ? '62%' : widths[i % widths.length] }} />
))}
</span>
);
}
/**
* Wraps placeholders: marks the region busy and announces "Loading" once.
* When `loading` turns false the children are replaced by `content`: the
* placeholders fade out on top while the content comes in, so nothing jumps.
*/
export function SkeletonGroup({ loading = true, label, content, className, children }: { loading?: boolean; label?: string; content?: React.ReactNode; className?: string; children: React.ReactNode }) {
const t = useTexts(TEXTS);
const was = useRef(loading);
const [swap, setSwap] = useState(false);
// Before paint, so the content never shows a frame without its entrance
useLayoutEffect(() => {
const before = was.current;
was.current = loading;
if (!before || loading || prefersReducedMotion()) {
setSwap(false);
return;
}
setSwap(true);
const timer = window.setTimeout(() => setSwap(false), 700);
return () => window.clearTimeout(timer);
}, [loading]);
return (
<div aria-busy={loading} aria-live="polite" className={cn(swap && 'kitfx-sk-reveal relative', className)}>
{loading ? (
<>
<span className="sr-only">{label ?? t.loading}</span>
{children}
</>
) : (
<>
{content}
{swap && (
<div aria-hidden className={cn('kitfx-sk-ghost pointer-events-none absolute inset-0 z-10', className)} style={{ margin: 0 }}>
{children}
</div>
)}
</>
)}
</div>
);
}Frequent questions
How do I install Skeleton?
With aiw add @ai-website/skeleton. The command puts components/ui/skeleton.tsx into your project, the code is yours from then on. It needs no further npm packages.
Which variants does Skeleton have?
3 variants, Cards, List and dashboard and Article.
Can I translate the texts of Skeleton?
Yes. Skeleton carries 1 texts of its own in English and German, such as “Loading”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Skeleton?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Skeleton with aiw_components and install it with aiw_add @ai-website/skeleton.