Skeleton

Holds the shape of content that is still loading, so the layout does not jump when data

Display Skeleton.jsx Skeleton.d.ts Skeleton.md

Playground

Change a prop and watch it. The code below follows along.

Install 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 with aria-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.
TokenTierDeclared as
--dt-radius-containersemanticvar(--dt-radius-raw-16)
--dt-radius-controlsemanticvar(--dt-radius-raw-8)
--dt-radius-pillsemanticvar(--dt-radius-raw-full)
--dt-space-stack-2xssemanticvar(--dt-dim-1)
--dt-surface-sunkensemanticvar(--dt-color-neutral-100)
--dt-motion-duration-slowernonenot declared
--dt-motion-easing-standardnonenot 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.