Pattern gallery

Shape-matched skeleton

One container map for both states: the skeleton can never come out a different size than the page it stands in for.

What this solves

Visual. A skeleton whose boxes do not match the real content's exact width and height makes the page jump the instant data arrives, and the reader's eye has to relocate whatever it was tracking. Drawing the page and its skeleton from one shared container map removes the jump, because neither one can be a different size than the other.

Use it when
  • A Suspense fallback for a route whose layout is already knownspeedway's job and run detail routes, WorkspaceSkeleton given the same card heights as the loaded page
  • A list page with a roughly known row countwalmart-mvp's Jobs table, TableSkeleton built from the same jobsColumns the loaded table uses

Rendered

Fixture data; check both themes.

The page beside its skeleton

Both columns draw from the same PANEL container, so the swap proves the shapes match.

The real page

Files

3 in this job
  • brake-pads-q3.xlsxIngested
  • fitment-update.csvMapping
  • lighting-full.xmlQueued

Its skeleton, same PANEL box

patterns/shape-matched-skeleton/example.tsxCopy this into a fresh route and it renders as above.
"use client";
import { Button, Card, Kicker, Skeleton } from "@versable-git/ui";
import { useState } from "react";
// The technique: page and skeleton draw into the SAME container map, so a
// placeholder can never come out narrower or shorter than the page it covers.
const PANEL = {
list: "flex w-full flex-col gap-3",
};
const rows = [
{ name: "brake-pads-q3.xlsx", state: "Ingested" },
{ name: "fitment-update.csv", state: "Mapping" },
{ name: "lighting-full.xml", state: "Queued" },
];
function RealList() {
return (
<div className={PANEL.list}>
<Card title="Files" subtitle="3 in this job" noAnimate>
<ul className="flex flex-col gap-2">
{rows.map((r) => (
<li key={r.name} className="flex justify-between text-sm">
<span className="truncate">{r.name}</span>
<span className="text-base-content/50">{r.state}</span>
</li>
))}
</ul>
</Card>
</div>
);
}
function MatchedSkeleton() {
return (
<div className={PANEL.list}>
<Card noAnimate>
<Skeleton className="mb-1 h-5 w-16" />
<Skeleton className="mb-4 h-3 w-24" />
<div className="flex flex-col gap-2">
{rows.map((r) => (
<div key={r.name} className="flex justify-between">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
</Card>
</div>
);
}
// A toggle swaps which column holds which state. Nothing resizes when it
// flips, because both columns draw from the same PANEL.list container.
export function ShapeMatchedSkeleton() {
const [swapped, setSwapped] = useState(false);
const left = swapped ? <MatchedSkeleton /> : <RealList />;
const right = swapped ? <RealList /> : <MatchedSkeleton />;
const leftLabel = swapped ? "Its skeleton, same PANEL box" : "The real page";
const rightLabel = swapped ? "The real page" : "Its skeleton, same PANEL box";
return (
<Card
title="The page beside its skeleton"
subtitle="Both columns draw from the same PANEL container, so the swap proves the shapes match."
// The swap is a card action, so it sits in the toolbar with a shade and
// an icon (doc 60 §6), never in a row of its own under the title.
toolbar={
<Button
size="sm"
variant="text"
shade
Icon="Swap"
content={swapped ? "Swap back" : "Swap sides"}
onClick={() => setSwapped((v) => !v)}
/>
}
>
<div className="grid gap-6 md:grid-cols-2">
<div className="flex flex-col gap-2">
<Kicker variant="bricked">{leftLabel}</Kicker>
{left}
</div>
<div className="flex flex-col gap-2">
<Kicker variant="bricked">{rightLabel}</Kicker>
{right}
</div>
</div>
</Card>
);
}

Where it ships

  • speedway/app/components/WorkspaceSkeleton.tsx:1-147the shared PANEL map, born after placeholders came out 45px narrow and 130px short when pages and skeletons picked containers independently

App-specific: The real component takes a layout key, an optional real header element reused verbatim, and row counts or card heights measured off the live page. Eight route files consume it as their Suspense fallback.

@versable-git/ui · composites proven in the apps