Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

Skeleton placeholders in React

Turtlekit Editorial Team

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.

COLUMNSplaceholder rows<Bar />invoice rowscolumn.cell()<table>
The columns are written once. Placeholder rows and invoice rows read the same list, so they can’t disagree about how many cells a row has or how wide each one is.

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.

Rendering partial data in React

When some of the data has arrived and the rest should fill in around it.

Empty states in React

When the rows arrive and there are none.

Loading components on demand in React

When the wait is for code rather than data.

Guard clauses and early returns in React

When loading is one of several cases a component returns early for.