Skeletons

How a DrumKit screen is never blank: when a region gets a skeleton rather than a spinner or nothing, the three shapes every loading state is built from, the fifteen family skeletons and what each keeps real, how many to show, when they appear and what replaces them, and the shimmer.

A screen is never blank

A screen is never blank. The page frame draws at once, real; a skeleton fills every region whose content is still on its way, at the size that content will take; and when the content lands, nothing moves.

Recent activity

What your team did this week.

Loading activity

The frame draws at once, and it is real

The sidebar, the page title, the tabs, the buttons and a table’s column headers are known before any request returns. They render as themselves, so the reader can see where they are before the data arrives.

Skeletons fill the regions where data lands

Each data-bearing family has a skeleton at its own footprint, so the region holds its final size from the first frame. Twenty-five are drawn; the register below names them. Ruled 2026-09-24: every loader is in, including section headers, command menu results, saved colours, the whole calendar, what loads inside a modal or slideout, and the four base families that carry server data: avatars, select options, the editor’s document and the video cover.

The generic fallbacks cover everything else

Skeleton.TextBlock for any text region and Skeleton.Card for any tile stand in where no family skeleton exists, including a component built next year. Nothing is ever empty.

Skeleton, spinner, or nothing

Is this text something the product wrote, or something the server sends? Written copy is drawn real. Server data gets a skeleton.

The situation
Reach for
Why
A region whose shape is known and repeats: rows, cards, a chart, a profileSkeletonThe reader sees where things will be before they are there, and nothing moves when they arrive.
A control doing work the reader started: Save, Send, UploadThe control’s own loading stateButton isLoading blocks the press and says the wait where the reader is looking.
A wait with no known shape, or a region too small to say anythingLoading indicatorA ring is honest when there is nothing to draw the shape of.
Content the product wrote, or an instant loadNothingCopy, labels and icons ship with the page; a skeleton for them is a flash for no reason.

Never a skeleton and a spinner together

Two marks for one wait say the app is confused. A region gets one of the two; a control keeps its own spinner and the region around it its skeleton.

What never gets a skeleton

These are chrome. They ship with the page and render as themselves while everything around them loads.

  • Buttons, button groups, inputs, checkboxes, radios, toggles, sliders
  • Tabs, breadcrumbs, filters, content dividers, section footers
  • Date pickers, progress steps
  • Alerts, banners, empty states, tooltips, badges and tags
  • Written forms: sign-in, sign-up, verification, a password prompt, settings
  • The frame of a modal, a slideout or a command menu: the title, the close button and the footer buttons are written; the body composes the skeletons of whatever loads inside it

The three shapes

Every skeleton is built from these, and only these. A Line for text, a Circle for an avatar, a Block for everything else, in one fill.

Line

xs12px text
sm14px text
md16px text
xl20px text
display30px text

Circle

xs 24, sm 32, md 40, xl 56; display is 160.

Block

Sized by class; radius matches what it replaces.

A Line stands in for text, at the text’s size

Skeleton.Line size names the text it replaces: xs 12px, sm 14, md 16, xl 20, display 30. The frame is that text’s full line height with a thinner bar centred in it, so swapping a text node for a Line moves nothing above or below. Set the width on the Line; it is full width by default.

A Circle stands in for an avatar, at the avatar’s size

Skeleton.Circle size: xs 24px, sm 32, md 40, xl 56, display 160.

A Block stands in for an image, a chart, a thumbnail or a badge

Skeleton.Block takes its size from className and matches the radius of what it replaces; a badge is a Block at the badge’s height with rounded-full.

One fill everywhere

bg-quaternary, which flips in dark mode. No other grey, no border, no shadow. Never draw a fake trend line, a fake number or fake words: a shape the data then contradicts is worse than a plain block.

Building a family skeleton

Start from the real component and swap out only what arrives. The activity feed row, loaded and loading, is the whole method.

The real row

Its skeleton

Loading activity

Start from the real component at its default variant

The skeleton is the component with its arriving content swapped out, not a drawing made from memory.

Swap each piece of arriving content for a shape

Text becomes a Line of the same text size, an avatar a Circle, an image or chart a Block. What arrives is what the server sends: a name, a number, a photo, a row.

Keep what is known up front real

Titles the product wrote, column headers, nav labels, a featured icon, the dots menu and every button render as themselves. A button is never a skeleton; if it cannot be used yet, it is disabled.

Change no height, padding or gap

The skeleton takes the real component’s classes. That is what makes the content land without a jump; a skeleton the wrong size makes the jump worse, not better.

One skeleton per layout, never per variant

Colour, tone, badges, selected states and icon choices get no skeleton of their own. Only a size or a breakpoint that changes the footprint earns one, plus short, medium and long line widths on rows that repeat.

How many

Enough to say what is coming, and no more.

Five rows, widths varied

Loading activity

Loading more keeps the rows

Loading activity

About five rows, or one screenful

Enough to say a list is coming, not fifty rows that scroll. A grid of cards shows one row of the grid.

Vary the line widths row to row

Identical rows read as a barcode. Each repeating skeleton takes text in short, medium or long; cycle them.

Loading more keeps the rows already showing

Add skeleton rows at the bottom of the list. Never swap a list that has content back to skeletons.

Timing, and when it fails

A skeleton appears at once, stays as long as the request takes, and is replaced by the content or by the error empty state. It never resolves on its own.

The frame stays; the region changes.

Recent activity

Show it at once

A region that is empty on arrival gets its skeleton on the first frame. The 200ms grace period belongs to a spinner on a control, where a flash would be noise; an empty region has nothing to flash from.

It never gives up on a timer

The skeleton stays until the request returns or fails. A weak connection gets the skeleton for as long as it takes; a fixed cut-off turns a slow load into a wrong error.

On failure, the error empty state with Retry

A region that failed to load and can be retried in place becomes the error empty state in the same region; something the reader did that failed is an alert. A skeleton that pulses forever is a blank screen with better manners.

Load progressively: chrome, then text, then media

The frame first, at once. Then the text of each region as it returns, replacing that region’s skeleton. Images, charts and avatars last, each replacing its own Block or Circle. Requests that return together should reveal together; a staggered reveal reads as busy.

Motion

One animation on every shape, and a flat fill for readers who have asked for less.

A light band sweeps left to right

The shimmer is one animation on every shape, from the --animate-skeleton-shimmer token: a two second cycle in which the band fades in as it enters, crosses at a steady speed, fades out before the far edge and rests off-screen before the next pass, so it never sits or snaps. It says the page is alive without drawing attention to any one shape.

Content rises into place

Wrap what replaces a skeleton in Skeleton.Reveal: a 1200ms rise of 8px with a fade, on an easing that lands softly. The skeleton already held the footprint, so the rise is small and nothing around it moves. Never fade the skeleton out on its own; the swap is the transition.

Reduced motion stops it flat

Under prefers-reduced-motion the band does not move, the flat fill stays, and content appears without the rise or the fade. The region’s status text carries the wait for those readers.

Accessibility

A screen reader hears one thing: the region, once. The shapes are decoration.

Wrap every skeleton in a Region

Skeleton.Region is a polite status marked busy that names the wait once (“Loading”, or label for something more specific). It is the one part a screen reader hears.

The shapes are silent

Everything inside the region is aria-hidden. Shapes have no role, no name and no focus; a skeleton is not a control.

Replace the whole region

When the content lands, the real content replaces the region, so the reader meets the content once, not a loading announcement followed by a change. If the wait had a name, the content’s own heading is what says it is here.

In practice

Six calls that come up on every screen that loads.

Skeleton the data, not the chrome

Do

Members

Loading members

Don’t

A toolbar, a title the product wrote and a row of buttons are known before the request is sent. Drawing them as shapes hides the one thing a reader could already use.

Match the real footprint to the pixel

Do

Don’t

The skeleton takes the component’s own height, padding and gaps. A near miss jumps when the content lands, which is the thing a skeleton exists to prevent.

Shapes, never fake content

Do

Don’t

A drawn trend line, a placeholder number or lorem ipsum makes a claim the data then contradicts. A block says only that something is coming.

A screenful, not a scroll

Do

Don’t

… and 47 more

Five rows say a list is coming. Fifty rows say the page is broken, and they scroll past anything else the reader could look at.

Fail to the error state, never pulse forever

Do

Activity didn’t load

Check your connection and try again.

Don’t

still going, a minute later

When the request fails, the region becomes the error empty state with Retry. A skeleton that never resolves is a blank screen that looks busy.

One mark per wait

Do

Don’t

A spinner inside a skeleton, or a skeleton behind a spinner, says two things about one wait. Pick by the decision table and stop.

Every family’s skeleton

One per data-bearing family, drawn on that component’s own page beside the real thing. Each card says what stays real.

Loading activity feeds

Activity feeds

Nothing; the whole row arrives
Loading tables

Tables

The lead checkbox and the column headers
Loading metrics

Metrics

The heading, the featured icon, the dots menu
Loading card headers

Card headers

The buttons, the overflow menu, the divider
Loading page headers

Page headers

The trail, the buttons, the search, the divider
Loading application navigation

Application navigation

Every fixed label; only the account and server-fed sections load
Loading messaging

Messaging

The bubble’s fill and padding
Loading tree views

Tree views

The indent, the chevron, the checkbox
Loading file upload

File upload

The card
Loading charts

Charts

The panel heading and range tabs
Loading calendars

Calendars

The whole grid, the dates, the headers, the time marker; Calendar loading places the chips itself
Loading carousel

Carousel

The arrows and the dot tray
Loading pagination

Pagination

Previous and Next, disabled; the per-page select
Loading inline ctas

Inline CTAs

The heading and description; only the card on file loads
Loading section headers

Section headers

The actions, the tabs, the divider; for a section headed by a record’s name
Loading command menus

Command menus

The field, the section titles, the footer; the result rows load
Loading color pickers

Color pickers

The picker, its sliders and inputs, the Add button; the saved row loads
Loading modals

Modals

The frame: title, close button and footer buttons
Loading slideout menus

Slideout menus

The panel: title, close button and footer buttons
Loading avatars

Avatars

Nothing; a label group’s name and subtitle load with the photo
Loading select

Select

The field, its label and the popover panel; the option rows load
Loading text editors

Text editors

The label, the toolbar strip and the hint; the document loads
Loading video players

Video players

Nothing; the controls draw over the media once it is there
Loading anything else

Anything else

The region’s own frame

Before you ship

What a review checks on any screen that waits.

  • Every region that waits on a request has a skeleton or a generic fallback; the frame around it is real.
  • The skeleton is the family’s own, or Skeleton.TextBlock or Skeleton.Card where none exists.
  • Each skeleton sits inside a Skeleton.Region with a label that names the wait.
  • About five rows, line widths varied, and loading more keeps the rows already showing.
  • The skeleton shows at once and stays until the request returns; on failure the region shows the error empty state with Retry.
  • No spinner shares the region with a skeleton.
  • Checked with reduced motion on: the fill is flat and the status text still names the wait.

The engineering side, the props and the wiring, is on the Implementation tab.