Agent docs

PageSkeleton

The shape of the page that is coming, drawn while it loads, so nothing jumps when it lands.

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 no header is given; draws a second bar where the subtitle will be.
  • rows: list only. Match the page's usual page size.
  • cards: cards only, 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 carries role="status" and aria-busy.

Sanctioned combinations#

CombinationWhat it is for
layout="list" rows={n} with the route's own container classAn 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 pageA header that does not move when data lands
headroom={64} above a dropzone or filter barChrome the placeholder does not draw, held open

Banned combinations#

  • A cards height 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.
  • PageSkeleton and PageLoading on the same route. They are two answers to one question. Pick by whether the shape is known.
  • Omitting className on 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, PageLoading is 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>;
@versable-git/ui · reference, canon, and method, read in place