Design system

Skeleton

A pulsing grey block that holds the place of content while it loads, so the layout does not jump when it arrives.

import { Skeleton, SourcesSkeleton } from "@/ui";

Skeleton is a thin wrapper around MUI's Skeleton with the pulse animation: the block fades to 40% opacity and back every 2 seconds. You build a loading state by laying out several Skeletons in the shape of the content that will replace them.

Basic shapes

Set width and height on every Skeleton. Numbers are pixels; strings such as "35%" also work.

<Skeleton width={160} height={20} />
<Skeleton variant="circular" width={32} height={32} />
<Skeleton width={94} height={32} className="rounded-lg" />
variantShapeUsed for
rectangular (default)4px radius (from the rounded class)Text lines, buttons, images, table cells.
circularCircleAvatars and logos.
roundedMUI's 4px radiusSame as the default here.
textMUI text line, scaled to 60% of heightAvoid. The scaling makes heights hard to match.

To match a button or card, change the radius with className, for example rounded-lg. In this app rounded-lg is 12px, not Tailwind's usual 8px, because the design tokens redefine --radius-lg.

The wrapper adds bg-[var(--color-grey-200)], but MUI's own background wins, so every Skeleton actually renders rgba(0, 0, 0, 0.11): a neutral grey of about #E3E3E3 on white, not the product's blue-grey. Passing a background class through className has the same problem.

Composing a loading state

Copy the layout of the real content: the same padding, gaps and line lengths. Vary the widths of text lines so they read as text, not as a grid.

// Integration card, from settings/integrations
<div className="flex flex-col gap-3 p-4 border rounded-xl">
  <div className="flex items-center gap-3">
    <Skeleton width={24} height={24} />
    <Skeleton width="35%" height={20} />
  </div>
  <Skeleton width="100%" height={16} />
  <Skeleton width="85%" height={16} />
  <Skeleton width={94} height={32} className="rounded-lg" />
</div>

// List row, from PageSkeleton
<div className="flex items-center gap-3">
  <Skeleton variant="circular" width={32} height={32} />
  <div className="flex flex-col gap-1.5">
    <Skeleton width={220} height={14} />
    <Skeleton width={140} height={10} />
  </div>
</div>

Swapping in the content

When the data arrives, replace the Skeletons with the real content in the same container, so nothing around it moves. Use "Load again" to repeat it.

<div className="integration-card" aria-busy={isLoading}>
  {isLoading ? <IntegrationCardSkeleton /> : <IntegrationCardContent integration={hubspot} />}
</div>

SourcesSkeleton

A ready-made loading state for a four-column table: a header row and three body rows.

<SourcesSkeleton />

No screen uses SourcesSkeleton. Its body cells pass style={{ backgroundColor: "#F8F9FC" }} to Skeleton, but Skeleton does not accept style, so the lighter body colour is lost and every cell renders the same grey.

Props

PropTypeDefaultNotes
widthnumber | stringPixels, or any CSS length such as "35%".
heightnumber | string
variant"rectangular" | "circular" | "rounded" | "text""rectangular"
classNamestringMerged with tailwind-merge, so rounded-lg replaces rounded.

No other prop is passed to MUI. style, animation, aria-* and children are ignored. Skeleton is a default export of its file and a named export from @/ui; import it from @/ui.

Accessibility

  • Skeleton renders an empty <span> with no role or label, so screen readers skip it. That is right for the blocks themselves, but nothing tells a screen reader user that content is loading.
  • Set aria-busy="true" on the container while it loads, and keep a heading or label outside the skeleton so people know what is coming.
  • The pulse animation is not reduced for people who ask for reduced motion.

Do and don't

  • Do use Skeleton when you know the shape of the result: a table, a list, a card grid, a settings form.
  • Do keep the skeleton the same size as the content, so the page does not shift.
  • Do import from @/ui. Three folders (settings/integrations, settings/user-management and my-profile) each have their own components/Skeleton.jsx, a hand-rolled animate-pulse div. Prefer the shared one in new code.
  • Don't use a skeleton for a wait under about half a second. It flashes.
  • Don't use one when you cannot predict the layout, such as a chat reply. Use a Spinner.

On this page