The invoices page opened with a spinner: one line, “Loading…”, under the heading, and the “Download statement” link right below it. An accountant went for the link the moment the page appeared. A moment later a page of invoices arrived, the link dropped below the fold, and her click opened INV-0040 instead.
Nothing was slow and nothing failed. The page changed shape after it had been drawn, and the click landed on whatever had moved under the pointer. Browsers measure this as layout shift. People feel it as a page that won’t hold still long enough to use.
A skeleton placeholder reserves the space content will take, drawn in the content’s own shape. Here that is the table with its caption, its header and a page of rows, each cell a muted bar where a value will go. When the data arrives it fills the shape it was given, and nothing around it moves.
A spinner one line tall
The page as it was:
return (
<main>
<h1>Invoices</h1>
{/* Avoid: a spinner one line tall where ten rows will be */}
{invoices ? (
<InvoiceRows invoices={invoices} />
) : (
<p>Loading…</p>
)}
<a href="/invoices/statement.csv">Download statement</a>
</main>
);The spinner is a line of text standing in for ten rows. Everything below it is laid out twice: once around the line, and again around the table. Google’s Core Web Vitals count that second layout as Cumulative Layout Shift, the movement of content people could already see, and it is the kind that sends a click to the wrong place.
A first fix is usually a grey box of roughly the right height. It helps until the table gains a column, or its rows grow a line taller, and nobody remembers the box. The placeholder and the content are two drawings of one thing, and two drawings drift.
One layout, two fillings
Picture a printed form before anyone has filled it in. The boxes, the labels and the spacing are all there; only the values are missing. A skeleton is the screen in that state: the real layout, with each value replaced by a mark the size of a typical one.
Draw the placeholder from the same layout as the content, or the two will drift apart. Here that means one list of columns that both kinds of row read, so a column added later appears in the loading table on the same day.
Drawing the table before its rows
The table takes its invoices or nothing. Without them it draws a page of placeholders from the same columns, in the same table, with the same widths.
The columns, written once
interface Column {
header: string;
/** The column's share of the table, the same in every state. */
width: string;
/** How long a typical value is: the placeholder bar's width. */
typical: string;
numeric?: boolean;
cell: (invoice: Invoice) => ReactNode;
}
const COLUMNS: readonly Column[] = [
{
header: "Invoice",
width: "22%",
typical: "8ch",
cell: (invoice) => (
<a href={`/invoices/${invoice.id}`}>{invoice.number}</a>
),
},
{
header: "Client",
width: "38%",
typical: "14ch",
cell: (invoice) => invoice.client,
},
{
header: "Due",
width: "18%",
typical: "6ch",
cell: (invoice) => invoice.due,
},
{
header: "Amount",
width: "22%",
typical: "8ch",
numeric: true,
cell: (invoice) => euros.format(invoice.amount),
},
];
/** The rows a page of invoices shows. */
const PAGE_SIZE = 10;Each column carries its header, its share of the width, the length of a typical value and how to draw a cell. PAGE_SIZE is the number of rows a page shows, so a page that is loading has as many rows as a page that has loaded.
A bar set in a line of text
/** A bar in a line of text: its row is a line tall. */
function Bar({ width }: { width: string }) {
const style: CSSProperties = {
display: "inline-block",
inlineSize: width,
blockSize: "0.75em",
verticalAlign: "middle",
borderRadius: "0.25em",
background: "currentColor",
opacity: 0.12,
};
return <span style={style} />;
}The bar is an inline block, so it sits inside the cell’s line like a word would, and a placeholder row is one line tall, as a loaded row is. Its colour is currentColor at low opacity, which follows the text colour into a dark theme without a colour of its own.
The bars here are still. If yours pulse, keep the animation in CSS and stop it under prefers-reduced-motion: a whole table of moving shapes is a lot of motion for someone who asked for less.
Fixed column widths
Matching the row count isn’t enough for a table. In the default automatic layout, a browser sizes each column from its content, so the columns would shift sideways as values replace bars. table-layout: fixed with a width on each <col> sets the widths from the columns alone, and they hold in both states.
The finished table
"use client";
// The invoices table. One list of columns draws the loaded rows and
// the placeholders, so the table keeps its size when the data
// arrives.
import type { CSSProperties, ReactNode } from "react";
import type { Invoice } from "./invoice";
const euros = new Intl.NumberFormat("en-IE", {
style: "currency",
currency: "EUR",
});
interface Column {
header: string;
/** The column's share of the table, the same in every state. */
width: string;
/** How long a typical value is: the placeholder bar's width. */
typical: string;
numeric?: boolean;
cell: (invoice: Invoice) => ReactNode;
}
const COLUMNS: readonly Column[] = [
{
header: "Invoice",
width: "22%",
typical: "8ch",
cell: (invoice) => (
<a href={`/invoices/${invoice.id}`}>{invoice.number}</a>
),
},
{
header: "Client",
width: "38%",
typical: "14ch",
cell: (invoice) => invoice.client,
},
{
header: "Due",
width: "18%",
typical: "6ch",
cell: (invoice) => invoice.due,
},
{
header: "Amount",
width: "22%",
typical: "8ch",
numeric: true,
cell: (invoice) => euros.format(invoice.amount),
},
];
/** The rows a page of invoices shows. */
const PAGE_SIZE = 10;
/** A bar in a line of text: its row is a line tall. */
function Bar({ width }: { width: string }) {
const style: CSSProperties = {
display: "inline-block",
inlineSize: width,
blockSize: "0.75em",
verticalAlign: "middle",
borderRadius: "0.25em",
background: "currentColor",
opacity: 0.12,
};
return <span style={style} />;
}
const align = (column: Column): CSSProperties => ({
textAlign: column.numeric ? "end" : "start",
});
export function InvoiceTable({
invoices,
}: {
invoices?: readonly Invoice[];
}) {
const loading = invoices === undefined;
const count = invoices?.length ?? 0;
const noun = count === 1 ? "invoice" : "invoices";
return (
<>
<table
aria-busy={loading}
style={{ tableLayout: "fixed", width: "100%" }}
>
<caption>Invoices</caption>
<colgroup>
{COLUMNS.map((column) => (
<col
key={column.header}
style={{ width: column.width }}
/>
))}
</colgroup>
<thead>
<tr>
{COLUMNS.map((column) => (
<th
key={column.header}
scope="col"
style={align(column)}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{loading
? Array.from({ length: PAGE_SIZE }, (_, row) => (
<tr key={row} aria-hidden="true">
{COLUMNS.map((column) => (
<td key={column.header} style={align(column)}>
<Bar width={column.typical} />
</td>
))}
</tr>
))
: invoices.map((invoice) => (
<tr key={invoice.id}>
{COLUMNS.map((column, index) =>
index === 0 ? (
<th
key={column.header}
scope="row"
style={align(column)}
>
{column.cell(invoice)}
</th>
) : (
<td key={column.header} style={align(column)}>
{column.cell(invoice)}
</td>
),
)}
</tr>
))}
</tbody>
</table>
<p role="status">
{loading ? "Loading invoices…" : `Loaded ${count} ${noun}.`}
</p>
</>
);
}
export function InvoicesPage({
invoices,
}: {
invoices?: readonly Invoice[];
}) {
return (
<main>
<h1>Invoices</h1>
<InvoiceTable invoices={invoices} />
{/* It stays put: the table keeps its size. */}
<a href="/invoices/statement.csv">Download statement</a>
</main>
);
}Open the page and the table appears at once, with its caption, its header and ten rows of bars; the statement link below it is already where it will stay. When the invoices arrive, each bar becomes its value in place, the first cell of each row becomes a row header, and the status line under the table says “Loaded 10 invoices.”
What a skeleton promises
A skeleton is a promise about what is coming. These are the places it can break.
How many rows to draw
Draw what a typical answer fills. On a paged list that is the page size. On a short list, the last count you saw is a good guess, kept with the data you cache. A guess that is too high still shifts content, upwards, when fewer rows arrive; a list that comes back empty is a different screen altogether, an empty state, not a shorter table.
Skeletons for the first load, not for every refresh
Replacing rows people are reading with bars, on every sort or filter, trades one kind of jump for another. For an update to content already on screen, keep the rows and mark the table busy until the new ones arrive. With Suspense, an update inside startTransition does exactly this: React keeps the revealed content rather than showing the fallback again.
Accessibility: hidden bars, one status
A bar has nothing to say, and ten rows of nothing would reach a screen reader as ten empty rows. Each placeholder row is aria-hidden, so the table reads as a caption and a header while it loads. The table is aria-busy meanwhile, which tells assistive technology that its content is changing; support varies, so it never carries the message alone.
The message is the status line. On the first load it mounts with “Loading invoices…”. A live region is read when its content changes, not when it first appears, so the line that counts is the next one: “Loaded 10 invoices.” A refresh that sends the table back to loading changes it to “Loading invoices…”, which is read too.
Testing the shape
jsdom has no layout engine, so a unit test can’t measure a shift. It can test the contract that prevents one: placeholder rows have the cells loaded rows have, stay out of the accessibility tree, and give way to real rows with a status that says what happened.
import { render, screen, within } from "@testing-library/react";
import { expect, test } from "vitest";
import { InvoiceTable } from "./invoice-table";
import type { Invoice } from "./invoice";
const PAGE: Invoice[] = Array.from({ length: 10 }, (_, i) => ({
id: `inv_${i + 40}`,
number: `INV-00${i + 40}`,
client: i % 2 ? "Brightside Studio" : "Northwind Traders",
due: `${i + 1} Dec`,
amount: 1200 + i * 100,
}));
test("placeholders keep the shape and stay hidden", () => {
const { container, rerender } = render(<InvoiceTable />);
const table = screen.getByRole("table", { name: "Invoices" });
expect(table).toHaveAttribute("aria-busy", "true");
expect(screen.getByRole("status")).toHaveTextContent(
"Loading invoices…",
);
// A screen reader meets the header row alone; the placeholders are
// drawn.
expect(within(table).getAllByRole("row")).toHaveLength(1);
const placeholders = container.querySelectorAll("tbody tr");
expect(placeholders).toHaveLength(10);
for (const row of placeholders) {
expect(row).toHaveAttribute("aria-hidden", "true");
expect(row.children).toHaveLength(4);
}
rerender(<InvoiceTable invoices={PAGE} />);
expect(table).toHaveAttribute("aria-busy", "false");
expect(screen.getByRole("status")).toHaveTextContent(
"Loaded 10 invoices.",
);
const rows = within(table).getAllByRole("row").slice(1);
expect(rows).toHaveLength(10);
for (const row of rows) expect(row.children).toHaveLength(4);
expect(
within(table).getByRole("rowheader", { name: "INV-0040" }),
).toBeVisible();
expect(
within(rows[0] as HTMLElement).getByRole("cell", {
name: "€1,200.00",
}),
).toBeVisible();
});It passes. The shift itself is measured in a browser: the Performance panel marks each layout shift on its timeline, and a PerformanceObserver watching layout-shift entries reports them from real visits.
Deep dive
Why not a row height? Rows here take their height from a line of text, which depends on the font, the size and the person’s zoom. A fixed height in pixels would match the loaded rows on one screen and miss them on the next. A bar inside the line inherits whatever the text does, so the two kinds of row agree at any zoom level.
Suspense, streaming and Server Components
With a data source that suspends, the table without data is its own fallback: <Suspense fallback={<InvoiceTable />}>. React’s Suspense reference describes the server side: with streaming, React sends the shell with the fallback first, then streams in the boundary’s HTML and swaps out the fallback as it arrives. The skeleton is in the first bytes, so the page holds its shape before any script runs.
The fallback and the content are separate elements, so a status line inside the table mounts afresh with the count and may not be read. When the table loads through Suspense, keep the announcement in the page, outside the boundary. Suspense also only sees data that suspends; a fetch inside an effect needs the invoices === undefined check this table already has.
React 18, 19 and the Compiler
Fallbacks work as they did in React 18, with one change described in the React 19 upgrade guide. When a component suspends, React 19 commits the nearest fallback at once and renders the suspended siblings afterwards. The Suspense reference adds that React reveals suspended content at most once every 300 milliseconds, so boundaries that resolve close together appear together. The Compiler memoises rendering; it has no say in what a placeholder looks like, which is the part that prevents the shift.
When a spinner is the honest answer
A skeleton needs a shape it can keep. Content whose shape isn’t known in advance, a generated report or a long free-text answer, would promise a layout it might break, and the shift returns anyway. An action in progress, a button saving or sending, belongs to the button’s own pending state, not to a placeholder of the result.
Waits that are nearly always short, such as a read from a local cache, are better shown as nothing for that moment than as bars that flash. And long work with known progress, an import or an upload, deserves a progress bar that says how far it has come.
How Turtlekit’s DataTable loads in its own shape
DataTable takes a loading flag and draws its placeholders from the same columns as its rows:
"use client";
// The invoices table with Turtlekit: DataTable draws its loading rows
// from the same columns as its loaded ones, hidden from screen
// readers.
import { DataTable } from "turtlekit";
import type { DataTableColumn } from "turtlekit";
import type { Invoice } from "./invoice";
const COLUMNS: DataTableColumn<Invoice>[] = [
{ key: "number", header: "Invoice" },
{ key: "client", header: "Client" },
{ key: "due", header: "Due" },
{
key: "amount",
header: "Amount",
format: "currency",
currency: "EUR",
},
];
export function Invoices({ invoices }: { invoices?: Invoice[] }) {
return (
<DataTable
caption="Invoices"
columns={COLUMNS}
rows={invoices ?? []}
loading={invoices === undefined}
getRowId={(invoice) => invoice.id}
getRowLabel={(invoice) => invoice.number}
/>
);
}With no rows yet, it shows placeholder rows laid out as loaded rows are, each bar about as wide as a typical value: the columns come from the table, the count from its paging page size (a screen’s worth at most). The placeholder rows are hidden from screen readers, the table is marked busy, and the number of rows is read from a live region that is always there when they arrive. Loading over rows already shown, after a sort or a filter, it keeps them as they are, draws a thin line in the accent colour along its top edge, and says “Updating results…”: no bars over rows someone is reading. For anything that isn’t a table, Skeleton and SkeletonText wrap content to keep its size, or draw lines in the size of the text on its way.
Built on this pattern in Turtlekit: Data table and Skeleton.
When the placeholder gives itself away
Three symptoms point to a skeleton that doesn’t quite match what it stands for.
The columns jump sideways when the data arrives
The table uses automatic layout, so its columns size to their content. Set table-layout: fixed with a width on each <col>.
A screen reader reads a table of empty rows
The placeholder rows are in the accessibility tree. Mark each one aria-hidden, and say the wait and the arrival in a status line instead.
Every filter turns the table back into bars
The update swaps the rows for placeholders. Keep the current rows while the next ones load, through a Transition or by keeping the previous data until the new data is in.
Promise only the shape you can keep
A spinner says “wait” and nothing more. A skeleton makes a promise: this many rows, these columns, this much room. The promise is only as good as its source, and the first invoices page had no source for it at all. Drawn from the table’s own columns, the placeholder can’t promise a column the table doesn’t have, and the statement link sits where it will stay before the first invoice arrives. The accountant’s click lands on the link she aimed for.