Skip to content

PageSkeleton

Hero example

When to use

Drop a PageSkeleton into a Next.js loading.tsx so the route's eventual content shape is visible while server actions stream. The result reads as a recognisable page outline, not a generic spinner — the user sees that something is loading and roughly what shape the answer will take.

PageSkeleton renders the content area only. It does not include a Header — the header belongs to the surrounding shell. In apps/app, the PageLoading helper pairs PageSkeleton with the dashboard Header for a one-line route fallback.

Anatomy

A single <output> element (which carries implicit role="status") with data-layout="<preset>". Inside, three blocks compose in order:

  1. Header block — a title (either text from the title prop or a 28-pixel Skeleton) plus a 14-pixel subtitle Skeleton.
  2. Stats row — optional. Renders headerStats Skeleton tiles when the value is greater than zero.
  3. Body — the layout-preset composition. List rows, grid tiles, two-pane columns, table rows, and so on.

Every Skeleton inside is aria-hidden. The outer wrapper carries the single role="status" + aria-busy="true" so assistive tech announces the load once per page.

Layouts

Eight presets cover the dashboard's content shapes:

  • list — Inbox, Timeline, Tasks, Audit log, Transactions, Sweeps. Stacked label-meta-chip rows.
  • grid — Equity tiles, Banking cards, People cards. N by M tiles.
  • detail — equity, board meeting, stakeholder profile. Title row, then a card-grid plus an aside.
  • two-pane — Documents. Left tree column plus right grid of thumbnails.
  • three-pane — Mail. Folders column, threads column, thread detail column.
  • tabs-content — Entities, Board, Equity overview. A pill strip plus a card body.
  • form — Settings sub-pages, /new flows. Stacked label plus input pairs plus an actions footer.
  • console — Developers, Admin, Registrations. Filter bar plus a table.

Props

PropTypeDefaultDescription
layoutrequiredPageSkeletonLayout
rowsnumberRow count for list / form / console. Default tuned per layout.
columnsnumberColumn count for grid. Default 4.
tabCountnumberTab pill count for tabs-content. Default 4.
headerStatsnumber0Header stats row above the main content (used on equity/banking-shaped pages). Renders N stat-tile skeletons. Default 0 (no stats row).
titlestringPage title to render as text instead of a skeleton block. Improves perceived navigation feedback — the title is known synchronously.
subtitleWidthnumber380Subtitle skeleton width. Default 380.
classNamestring

Also accepts Omit<React.HTMLAttributes<HTMLOutputElement>, "children">.

States

PageSkeleton has one state — loading. Once data resolves, the parent Suspense boundary unmounts PageSkeleton and renders the real content. No transition props or fade-out logic are needed; the App Router handles the swap.

Themes

Tokens consumed

  • --stone-200, --stone-300 via the underlying Skeleton
  • --border-soft, --bg-card, --bg-subtle for the surrounding card and table chrome
  • --radius-lg, --radius-md for card and field shapes
  • --fg-heading when a title string is passed

Accessibility

  • Keyboard interactions. None. PageSkeleton is presentational.
  • ARIA roles and properties. The outer wrapper is role="status" with aria-busy="true" and aria-label="Loading…". Child Skeletons are aria-hidden so the load announces exactly once per page.
  • Live region. PageSkeleton does not use aria-live — it is a static status region, not a polite announcement queue. Screen readers describe it on focus, which fires when the user navigates into the route.
  • Reduced motion. Each child Skeleton respects prefers-reduced-motion: reduce. Shimmer sweeps stop; tracks remain visible.
  • Forced colors. Tracks map to GrayText; gradient sweeps are hidden. The surrounding card chrome falls back to system colors.

Do / Don't

Do — pick the layout preset that matches the route's eventual content shape. The closer the skeleton mirrors the real page, the less the layout jumps on resolve.
Don't — use one layout preset everywhere. A list page should not show a grid skeleton; the resolve will feel like the page rewrote itself.
Do — pass title when the page title is known synchronously (most authenticated routes). The user sees real navigation feedback instead of a placeholder block.
Don't — render a PageSkeleton inside a Server Component just to "preload" the shape. Suspense already streams the chrome; PageSkeleton belongs in loading.tsx.

Recipes

Inbox-style list

import { PageSkeleton } from "@matter/components";

export default function InboxLoading() {
  return <PageSkeleton layout="list" rows={8} title="Inbox" />;
}

Equity overview with stats and tabs

import { PageSkeleton } from "@matter/components";

export default function EquityLoading() {
  return (
    <PageSkeleton
      headerStats={4}
      layout="tabs-content"
      tabCount={8}
      title="Equity"
    />
  );
}

Mail three-pane

import { PageSkeleton } from "@matter/components";

export default function MailLoading() {
  return <PageSkeleton layout="three-pane" rows={8} title="Mail" />;
}

Source

packages/components/src/PageSkeleton

On this page