The Suspense fallback speedway hand-built and reused across four route files.
Promoted under D7 (docs/plan/44-showcase-refinement.md §Rulings), which ruled
that "Skeleton + panel can be a wrapper around the unitary components": this
composes Card and Skeleton rather than replacing either.
It exists because of a measurement, not a preference. When a page and its placeholder each decided their own boxes privately, speedway's placeholders came out 45px narrow and 130px short of the card they stood in for, so the skeleton caused the exact layout jump it had been added to prevent.
When to reach for it#
Reach for PageSkeleton when the shape of the arriving page is known. Reach for
PageLoading when it is not. A spinner says something is happening; a skeleton
says what is arriving, and only the second one can hold the layout still.
Two shapes cover the common cases. list is a toolbar over a table, the shape of
an index route. cards is stacked detail cards at heights you measured. Anything
further from those is a page-specific fallback and should stay in the app.
Contract#
layout:"list" | "cards". Required, with no default on purpose. A default is a guess about a page nobody looked at, and the wrong shape re-creates the jump.className: the container class the REAL page renders. Pass the same value from both sides so one map decides the box. This is the component's central invariant, and it is why the kit does not ship a container map of its own: the app owns its layout classes, so the app must own both ends of the pair.header: the page's real header element, the same one it renders once loaded. Static titles drawn as grey bars are a worse version of something already in hand, and the top of the page redraws the moment data lands.subtitle: only read when noheaderis given; draws a second bar where the subtitle will be.rows:listonly. Match the page's usual page size.cards:cardsonly, an array of px heights, one per card the page resolves to.headroom: px of chrome above the first card that this does not reproduce. At 32 and above it draws a soft stand-in where the real control lives, so the gap reads as loading rather than neglect; below that it is a plain spacer.aria-label: names what is loading. Defaults to"Loading"; the container carriesrole="status"andaria-busy.
Sanctioned combinations#
| Combination | What it is for |
|---|---|
layout="list" rows={n} with the route's own container class | An index route: toolbar over a table |
layout="cards" cards={[190, 872]} | A detail route whose two cards were measured off the live page |
header={<TheSameHeader />} on both the skeleton and the page | A header that does not move when data lands |
headroom={64} above a dropzone or filter bar | Chrome the placeholder does not draw, held open |
Banned combinations#
- A
cardsheight you did not measure. A generic card promises a shape that never arrives, which is the defect this component exists to fix. Open the page, measure it, pass that. PageSkeletonandPageLoadingon the same route. They are two answers to one question. Pick by whether the shape is known.- Omitting
classNameon a page whose container carries layout. Without it the placeholder draws in a different box than the page, which is the original 45px-narrow failure with extra steps.
Before you adopt this#
- Do you know the arriving shape? If not,
PageLoadingis the honest choice. - Does your page's container class live somewhere both the route and its fallback can import? If it does not, make it so before wiring this; one map is the whole point.
- Did you measure the card heights on the live page, or estimate them?
- Does your route already have a bespoke fallback that is better than these two shapes? Keep it. This covers the common cases, not every case.
Travels with#
Card and Skeleton, which it composes. PageLoading and PageError, its
siblings in the page-state family. Suspense, which is normally what renders it.
Snippet#
import { Suspense } from "react";import { PageSkeleton } from "@versable-git/ui";import { PANEL } from "~/layout";<Suspense fallback={<PageSkeleton layout="list" className={PANEL.list} header={<JobsHeader />} rows={8} />}> <JobsTable /></Suspense>;