Bones sized to your text’s line box, lit by one shared slow sweep.
Loading & progressno dependencies
01Preview
Loading members
While loading
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup.
03Usage
import { Skeleton, SkeletonCircle, SkeletonLine, SkeletonRow } from "@/components/ui/skeleton";
// A preset, shaped like a list row.
<SkeletonRow count={3} label="Loading members" />
// Or compose the exact shape of what's coming.
<Skeleton label="Loading profile" className="flex items-center gap-3">
<SkeletonCircle size={40} />
<div className="flex-1">
<SkeletonLine className="text-[13px] leading-[18px]" width="40%" />
<SkeletonLine className="text-[12px] leading-[18px]" width="64%" />
</div>
</Skeleton>04Source
"use client";
import { createContext, useContext } from "react";
import { cn } from "@/lib/cn";
// Bones are the foreground at 7%, so they read on any surface in either theme.
// The shimmer is one light passing across every bone at once: the gradient is
// pinned to the viewport (background-attachment: fixed), so a wide block and a
// short line are lit by the same sweep instead of each flashing on its own.
const CSS = `
@keyframes stealth-skeleton-in { from { opacity: 0; } }
@keyframes stealth-bone-shimmer { from { background-position: -100vw 0; } to { background-position: 100vw 0; } }
@keyframes stealth-bone-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.stealth-skeleton { animation: stealth-skeleton-in 240ms cubic-bezier(0.25, 1, 0.5, 1) both; }
/* Defaults live in a layer below utilities, so any className you pass wins. */
@layer components {
.stealth-bone { display: block; background-color: color-mix(in oklab, var(--fg) 7%, transparent); }
.stealth-block { width: 100%; height: 96px; border-radius: 8px; }
.stealth-circle { flex-shrink: 0; border-radius: 9999px; }
}
[data-animation="shimmer"] .stealth-bone {
background-image: linear-gradient(100deg, transparent 38%, color-mix(in oklab, var(--fg) 9%, transparent) 50%, transparent 62%);
background-size: 100vw 100%;
background-repeat: no-repeat;
background-attachment: fixed;
animation: stealth-bone-shimmer 2.8s linear infinite;
}
[data-animation="pulse"] .stealth-bone { animation: stealth-bone-pulse 2.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.stealth-skeleton .stealth-bone { animation: none !important; background-image: none !important; }
}
`;
type Animation = "shimmer" | "pulse" | "static";
const Ctx = createContext<{ animation: Animation } | null>(null);
export type SkeletonProps = React.ComponentProps<"div"> & {
/** A slow light sweep, a slow opacity pulse, or nothing. */
animation?: Animation;
/** What is loading, for screen readers: "Loading invoices". */
label?: string;
};
/** The loading container. Marks the region busy and sets the animation for every bone inside. */
export function Skeleton({ animation = "shimmer", label = "Loading", className, children, ...rest }: SkeletonProps) {
return (
<Ctx.Provider value={{ animation }}>
<div aria-busy="true" data-animation={animation} className={cn("stealth-skeleton", className)} {...rest}>
<style href="stealth-skeleton" precedence="default">
{CSS}
</style>
<span className="sr-only">{label}</span>
<div aria-hidden className="contents">
{children}
</div>
</div>
</Ctx.Provider>
);
}
/** Presets used on their own bring their own container; inside a Skeleton they don't nest another. */
function Rooted({ root, children }: { root: SkeletonProps; children: React.ReactNode }) {
const inside = useContext(Ctx);
if (inside) return <div className={root.className} style={root.style}>{children}</div>;
return <Skeleton {...root}>{children}</Skeleton>;
}
const len = (v: string | number | undefined) => (typeof v === "number" ? `${v}px` : v);
// Deterministic, so server and client agree and reloads don't reshuffle.
const WIDTHS = [72, 56, 84, 64, 48, 78, 60, 90, 52, 68];
const pick = (i: number) => WIDTHS[i % WIDTHS.length];
export type SkeletonLineProps = Omit<React.ComponentProps<"span">, "children"> & {
/** Several lines, as a paragraph. The last one is shorter. */
lines?: number;
/** Width of a single line, or of the last line in a paragraph. */
width?: string | number;
};
/**
* A line of text that hasn't arrived. It is exactly one line-height tall and
* draws a bar the height of the letters, so set the same text size and leading
* as the real text (className="text-[13px] leading-[18px]") and nothing moves on swap.
*/
export function SkeletonLine({ lines = 1, width, className, style, ...rest }: SkeletonLineProps) {
const one = (w: string | undefined, key?: number) => (
<span key={key} className="flex h-[1lh] items-center" style={{ width: w }}>
<span className="stealth-bone h-[0.72em] w-full rounded-[3px]" />
</span>
);
if (lines <= 1)
return (
<span className={cn("block max-w-full", className)} style={{ width: len(width), ...style }} {...rest}>
{one("100%")}
</span>
);
return (
<span className={cn("flex w-full flex-col", className)} style={style} {...rest}>
{Array.from({ length: lines }, (_, i) => one(i === lines - 1 ? (len(width) ?? "62%") : `${[100, 96, 98, 93][i % 4]}%`, i))}
</span>
);
}
export type SkeletonCircleProps = Omit<React.ComponentProps<"span">, "children"> & {
/** Diameter in pixels. Match the avatar it stands in for. */
size?: number;
};
export function SkeletonCircle({ size = 32, className, style, ...rest }: SkeletonCircleProps) {
return <span className={cn("stealth-bone stealth-circle", className)} style={{ width: size, height: size, ...style }} {...rest} />;
}
export type SkeletonBlockProps = Omit<React.ComponentProps<"span">, "children">;
/** Any rectangle: an image, a chart, a button. Full width and 96px tall until your className says otherwise. */
export function SkeletonBlock({ className, ...rest }: SkeletonBlockProps) {
return <span className={cn("stealth-bone stealth-block", className)} {...rest} />;
}
export type SkeletonRowProps = SkeletonProps & {
/** How many rows. Widths vary row to row so it reads as a list, not a stamp. */
count?: number;
avatar?: boolean;
/** A second, quieter line under the name. */
meta?: boolean;
/** A short value at the end of the row: a time, an amount. */
trailing?: boolean;
};
/** A list row: 32px avatar, a 13px name, a 12px meta line and a trailing value. */
export function SkeletonRow({ count = 3, avatar = true, meta = true, trailing = true, className, ...root }: SkeletonRowProps) {
return (
<Rooted root={{ ...root, className: cn("flex flex-col", className) }}>
{Array.from({ length: count }, (_, i) => (
<div key={i} className="flex items-center gap-3 py-2">
{avatar && <SkeletonCircle size={32} />}
<div className="flex min-w-0 flex-1 flex-col">
<SkeletonLine className="text-[13px] leading-[18px]" width={`${pick(i) * 0.6}%`} />
{meta && <SkeletonLine className="text-[12px] leading-[18px]" width={`${pick(i + 3) * 0.9}%`} />}
</div>
{trailing && <SkeletonLine className="text-[12px] leading-[18px]" width={28 + (i % 3) * 6} />}
</div>
))}
</Rooted>
);
}
export type SkeletonCardProps = SkeletonProps & {
/** A media area on top: true for 16:9, or a width-to-height ratio such as 2 or 4 / 3. */
media?: boolean | number;
/** Lines of body text under the title. */
lines?: number;
/** An avatar, a name and an action along the bottom. */
footer?: boolean;
};
/** A card: media, a 15px title, body lines and a footer with an action. */
export function SkeletonCard({ media = true, lines = 2, footer = true, className, ...root }: SkeletonCardProps) {
return (
<Rooted root={{ ...root, className: cn("flex flex-col overflow-hidden rounded-xl border border-line", className) }}>
{media !== false && <SkeletonBlock className="h-auto rounded-none" style={{ aspectRatio: media === true ? 16 / 9 : media }} />}
<div className="flex flex-col gap-1.5 p-4">
<SkeletonLine className="text-[15px] leading-[20px]" width="58%" />
{lines > 0 && <SkeletonLine className="text-[13px] leading-[19px]" lines={lines} width="70%" />}
</div>
{footer && (
<div className="flex items-center gap-2 border-t border-line px-4 py-3">
<SkeletonCircle size={20} />
<SkeletonLine className="mr-auto text-[12px] leading-[18px]" width="34%" />
<SkeletonBlock className="h-7 w-16 rounded-md" />
</div>
)}
</Rooted>
);
}
export type SkeletonTableProps = SkeletonProps & {
rows?: number;
columns?: number;
/** Draw the header row. Real headers are usually known before the data, so pass false and render yours. */
header?: boolean;
};
/** A table: a mono header, then 36px rows. The first column is widest, the last is right-aligned like a number. */
export function SkeletonTable({ rows = 5, columns = 4, header = true, className, ...root }: SkeletonTableProps) {
const grid = { gridTemplateColumns: `minmax(0,1.6fr) repeat(${Math.max(columns - 1, 0)}, minmax(0,1fr))` };
const cell = (r: number, c: number, head: boolean) => (
<div key={c} className={cn("flex min-w-0 items-center", c === columns - 1 && c > 0 && "justify-end")}>
<SkeletonLine
className={head ? "text-[10.5px] leading-[14px]" : "text-[13px] leading-[18px]"}
width={head ? `${38 + ((c * 17) % 24)}%` : c === columns - 1 && c > 0 ? `${40 + ((r * 13) % 22)}%` : `${pick(r * 3 + c)}%`}
/>
</div>
);
return (
<Rooted root={{ ...root, className: cn("flex flex-col text-left", className) }}>
{header && (
<div className="grid h-9 items-center gap-4 border-b border-line px-3" style={grid}>
{Array.from({ length: columns }, (_, c) => cell(0, c, true))}
</div>
)}
{Array.from({ length: rows }, (_, r) => (
<div key={r} className="grid h-9 items-center gap-4 border-b border-line px-3 last:border-b-0" style={grid}>
{Array.from({ length: columns }, (_, c) => cell(r, c, false))}
</div>
))}
</Rooted>
);
}05Props
Skeleton
| Prop | Type | Default | Description |
|---|---|---|---|
| animation | "shimmer" | "pulse" | "static" | "shimmer" | A slow light sweep (2.8s), a slow opacity pulse (2.4s), or none. Every bone inside follows it. |
| label | string | "Loading" | What is loading, read by screen readers: "Loading invoices". |
SkeletonLine
| Prop | Type | Default | Description |
|---|---|---|---|
| width | string | number | "100%" | Width of the line, or of the last line in a paragraph (62% by default there). Numbers are pixels. |
| lines | number | 1 | Render a paragraph of this many lines with gently varied widths. |
| className | string | — | Give it the real text's size and leading, e.g. text-[13px] leading-[18px]; the line takes exactly that height. |
SkeletonCircle
| Prop | Type | Default | Description |
|---|---|---|---|
| size | number | 32 | Diameter in pixels. Match the avatar it stands in for. |
SkeletonBlock
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | Full width, 96px tall and rounded-lg until your classes say otherwise; defaults sit below utilities so yours always win. |
SkeletonRow
| Prop | Type | Default | Description |
|---|---|---|---|
| count | number | 3 | Rows to draw. Widths vary per row so it reads as a list, not a stamp. |
| avatar | boolean | true | A 32px circle at the start. |
| meta | boolean | true | A quieter 12px line under the name. |
| trailing | boolean | true | A short value at the end: a time, a role, an amount. |
SkeletonCard
| Prop | Type | Default | Description |
|---|---|---|---|
| media | boolean | number | true | Media on top: true for 16:9, a number for another width-to-height ratio, false for none. |
| lines | number | 2 | Body lines under the 15px title. |
| footer | boolean | true | Avatar, name and an action button along the bottom. |
SkeletonTable
| Prop | Type | Default | Description |
|---|---|---|---|
| rows | number | 5 | 36px rows. |
| columns | number | 4 | The first column is widest; the last is right-aligned like a number column. |
| header | boolean | true | Draw a header row. Pass false and render your real headers, which are known before the data. |
06Notes
Behavior
- SkeletonLine is exactly one line-height tall and draws a bar the height of the letters, so a line set to the real text's size and leading is replaced without a pixel of movement.
- Widths come from a fixed pattern, not Math.random, so server and client agree and a reload doesn't reshuffle the shapes.
- Presets used alone bring their own busy container; inside a Skeleton they join it instead of nesting another.
- Bones are the foreground at 7% alpha, so they read on frame, raised or any surface in either theme without per-surface tokens.
Motion
- Shimmer: one 9% highlight crosses the viewport every 2.8s, linear. The gradient is pinned to the viewport, so every bone is lit by the same pass instead of each flashing on its own.
- Pulse: opacity 1 → 0.5 → 1 over 2.4s, ease-in-out. The container fades in over 240ms so a skeleton never snaps onto the screen.
- Reduced motion drops both the sweep and the pulse; the bones sit still.
Accessibility
- The container has aria-busy="true" and a visually hidden label naming what is loading; every bone is aria-hidden.
- Nothing inside is focusable, so keyboard users skip straight past a region that isn't ready.