Skeleton
Holds the shape of content that is still loading, so the layout does not jump when data
Playground
Change a prop and watch it. The code below follows along.
Live
Open full cardInstall and import
Skeleton is in @dovetail-ds/react. Install it with React, if you haven't:
npm install @dovetail-ds/react react react-dom
import { Skeleton } from "@dovetail-ds/react";
Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.
Guidelines
Holds the shape of content that is still loading, so the layout does not jump when data arrives.
Rules
- Match the real content's dimensions. A skeleton that is the wrong size causes the reflow it exists to prevent.
- Skeletons are
aria-hidden. Announce loading state on the container witharia-busy. A screen reader user gains nothing from grey rectangles. - Use skeletons for content-shaped waits over roughly 300ms. Use Spinner for actions and indeterminate waits with no known shape.
- Do not skeleton an entire page. Show the chrome that is already known and skeleton only the parts that depend on the request.
Tradeoffs
Skeletons make a wait feel shorter but a failed request feel longer, because the promise of content never resolves. Pair every skeleton with an error state.
Props
The typed contract, from Skeleton.d.ts.
import * as React from "react";
/** Placeholder shape shown while content loads. */
export interface SkeletonProps extends React.HTMLAttributes<HTMLSpanElement> {
/** @default "text" */
variant?: "text" | "rect" | "circle";
width?: number | string;
height?: number | string;
/** Number of text lines. The last line is shortened. @default 1 */
lines?: number;
/** Override the variant's corner radius. */
radius?: string;
}
export declare function Skeleton(props: SkeletonProps): React.JSX.Element;
Tokens it reads
Every custom property Skeleton.jsx resolves, read from the source: 5 semantic, 2 undeclared. Component tier first, because that is the one to reach for.
- semantic Shared roles. Override one and everything using that role moves with it.
- none Referenced by the source but declared by no tier, so it resolves to nothing.
| Token | Tier | Declared as |
|---|---|---|
--dt-radius-container | semantic | var(--dt-radius-raw-16) |
--dt-radius-control | semantic | var(--dt-radius-raw-8) |
--dt-radius-pill | semantic | var(--dt-radius-raw-full) |
--dt-space-stack-2xs | semantic | var(--dt-dim-1) |
--dt-surface-sunken | semantic | var(--dt-color-neutral-100) |
--dt-motion-duration-slower | none | not declared |
--dt-motion-easing-standard | none | not declared |
Source
React only, no dependencies, every value a token.
import React from "react";
export function Skeleton({ variant = "text", width, height, lines = 1, radius, style, ...rest }) {
const base = {
background: "var(--dt-surface-sunken)",
borderRadius: radius || (variant === "circle" ? "var(--dt-radius-pill)" : variant === "text" ? "var(--dt-radius-control)" : "var(--dt-radius-container)"),
animation: "dt-skeleton-pulse var(--dt-motion-duration-slower, 1400ms) var(--dt-motion-easing-standard) infinite",
};
if (variant === "text") {
return (
<span aria-hidden="true" style={{ display: "flex", flexDirection: "column", gap: "var(--dt-space-stack-2xs)", width: width || "100%", ...style }} {...rest}>
{Array.from({ length: lines }).map((_, i) => (
<span key={i} style={{ ...base, display: "block", height: height || "0.85em", width: i === lines - 1 && lines > 1 ? "62%" : "100%" }} />
))}
</span>
);
}
return <span aria-hidden="true" style={{ ...base, display: "block", width: width || (variant === "circle" ? 32 : "100%"), height: height || (variant === "circle" ? 32 : 80), ...style }} {...rest} />;
}
In context
Skeleton also appears on the display reference cards, alongside the rest of its family.