---
title: List Page Loading States
description: Render a list page's header, filters, and column headers on the first frame, then fill the table with skeleton rows that match the loaded rows, so nothing shifts when the data lands.
---

# List Page Loading States

A list page has one job before its data arrives: look like itself. The heading, the description, the primary action, the filters, and the column headers depend on nothing the server sends, so they can paint on the first frame. Only the count and the rows wait. This recipe wires a [Data Table](/components/data-display/data-table), [Skeleton](/components/feedback/skeleton) rows, [Empty](/components/feedback/empty) states, and a [Live Region](/components/primitives/live-region) into a page that paints fast, fills in without moving, and tells screen readers what it is doing.

The failure modes it removes are ones you have seen: a spinner where the page should be, a "No results" that flashes before the real rows, columns that slide sideways when text arrives, a pagination bar that pushes the page down once it mounts, and a count that reads `0` for a second.

> \[!IMPORTANT]
> Don't copy this UI verbatim. The recipe teaches the **state structure**: the shell renders on the first frame, exactly one region of the table body branches, the skeleton derives from the real columns, and a refetch keeps the loaded rows. The domains table, its copy, and the toolbar controls are stand-ins. Swap them for your product's content and keep the structure.

<!-- framed preview /preview/list-page-loading — full source in /llms-full.txt under "# Framed preview sources" -->

```tsx
import { AppLayout } from "@ngrok/mantle/app-layout";
import { Badge } from "@ngrok/mantle/badge";
import { Button } from "@ngrok/mantle/button";
import { cx } from "@ngrok/mantle/cx";
import {
	DataTable,
	type Row,
	createColumnHelper,
	tableFeatures,
	useTable,
} from "@ngrok/mantle/data-table";
import { Empty } from "@ngrok/mantle/empty";
import { useIsHydratedAfterPaint } from "@ngrok/mantle/hooks";
import { Input } from "@ngrok/mantle/input";
import { LiveRegion } from "@ngrok/mantle/live-region";
import { Select } from "@ngrok/mantle/select";
import { Skeleton } from "@ngrok/mantle/skeleton";
import { SkipToMainLink } from "@ngrok/mantle/skip-to-main-link";
import { Table } from "@ngrok/mantle/table";
import { GlobeHemisphereWestIcon } from "@phosphor-icons/react/GlobeHemisphereWest";
import { MagnifyingGlassIcon } from "@phosphor-icons/react/MagnifyingGlass";
import { WarningCircleIcon } from "@phosphor-icons/react/WarningCircle";
import {
	keepPreviousData,
	useQuery,
	useQueryClient,
	type UseQueryResult,
} from "@tanstack/react-query";
import { type ReactNode, useDeferredValue, useState } from "react";

const SIMULATED_API_LATENCY_MS = 1_500;
const PAGE_SIZE = 8;
const DEMO_DOMAIN_COUNT = 1_284;
const DOMAINS_QUERY_KEY_ROOT = ["list-page-loading-domains"];

const regions = ["us", "eu", "ap", "au", "jp", "in", "sa"] as const;

/** An ngrok region code. */
type Region = (typeof regions)[number];

const certificateKinds = ["managed", "custom"] as const;

/** Who issues and renews the domain's TLS certificate. */
type CertificateKind = (typeof certificateKinds)[number];

const certificateLabels: Record<CertificateKind, string> = {
	managed: "Managed",
	custom: "Custom",
};

/** One reserved domain, as the demo API returns it. */
type Domain = {
	/** Stable identifier from the backend. */
	id: string;
	/** The fully qualified domain name. */
	name: string;
	/** The region the domain is reserved in. */
	region: Region;
	/** Who manages the certificate. */
	certificate: CertificateKind;
	/** How many endpoints are online on the domain right now. */
	endpointCount: number;
	/** ISO date string for when the domain was reserved. */
	createdAt: string;
};

/** The active list filters. `"any"` leaves that dimension unfiltered. */
type DomainFilters = {
	region: Region | "any";
	certificate: CertificateKind | "any";
	search: string;
};

const defaultFilters: DomainFilters = { region: "any", certificate: "any", search: "" };

/** One page of the list, plus the total the filters match. */
type DomainsPage = {
	items: Domain[];
	total: number;
};

/** Which response the demo API returns. */
type DomainsScenario = "success" | "no-domains" | "server-error";

const domainsScenarios = [
	"success",
	"no-domains",
	"server-error",
] as const satisfies ReadonlyArray<DomainsScenario>;

const scenarioLabels: Record<DomainsScenario, string> = {
	success: "Scenario: loaded",
	"no-domains": "Scenario: no domains",
	"server-error": "Scenario: request fails",
};

/** Narrows a raw select value to a known demo scenario. */
function isDomainsScenario(value: string): value is DomainsScenario {
	return domainsScenarios.some((scenario) => scenario === value);
}

/** Narrows a raw select value to a region filter. */
function isRegionFilter(value: string): value is DomainFilters["region"] {
	return value === "any" || regions.some((region) => region === value);
}

/** Narrows a raw select value to a certificate filter. */
function isCertificateFilter(value: string): value is DomainFilters["certificate"] {
	return value === "any" || certificateKinds.some((kind) => kind === value);
}

/** Whether any filter narrows the list, so an empty page reads as "no results" and not "no data". */
function hasActiveFilter(filters: DomainFilters): boolean {
	return filters.region !== "any" || filters.certificate !== "any" || filters.search.trim() !== "";
}

// The mock API below stands in for your API client. Keep the shape it returns
// (a page plus a total) and the `signal` it accepts; replace everything else.

const nameStems = [
	"api",
	"app",
	"shop",
	"auth",
	"billing",
	"docs",
	"webhooks",
	"staging",
	"preview",
	"admin",
] as const;
const nameSuffixes = ["ngrok.app", "ngrok.dev", "acme-labs.example", "example.com"] as const;
const NEWEST_CREATED_AT = Date.UTC(2026, 8, 11);
const DAY_MS = 86_400_000;

/** Reads `items[index]` with wraparound. Throws on an empty list, because a demo with no data to cycle through is a bug. */
function cycle<T>(items: ReadonlyArray<T>, index: number): T {
	const item = items[index % items.length];
	if (item == null) {
		throw new Error("cycle() needs a non-empty list");
	}
	return item;
}

/** Builds the fixed dataset once. Every field derives from the index, so the list is the same on every load. */
function createDemoDomains(): Domain[] {
	return Array.from({ length: DEMO_DOMAIN_COUNT }, (_, index) => {
		const stem = cycle(nameStems, index);
		const suffix = cycle(nameSuffixes, Math.floor(index / nameStems.length));
		return {
			id: `rd_${(index + 1).toString().padStart(4, "0")}`,
			name: `${stem}-${index + 1}.${suffix}`,
			region: cycle(regions, index * 3),
			certificate: index % 5 === 0 ? "custom" : "managed",
			endpointCount: (index * 7) % 4,
			createdAt: new Date(NEWEST_CREATED_AT - index * DAY_MS).toISOString(),
		};
	});
}

const demoDomains = createDemoDomains();

/** Whether a domain passes every active filter. */
function matchesFilters(domain: Domain, filters: DomainFilters): boolean {
	if (filters.region !== "any" && domain.region !== filters.region) {
		return false;
	}
	if (filters.certificate !== "any" && domain.certificate !== filters.certificate) {
		return false;
	}
	const search = filters.search.trim().toLowerCase();
	return search === "" || domain.name.includes(search);
}

/** Waits for the simulated network, and rejects when TanStack Query aborts the request. */
function waitForDemoApi(signal?: AbortSignal): Promise<void> {
	return new Promise((resolve, reject) => {
		if (signal?.aborted) {
			reject(new DOMException("The domains request was aborted.", "AbortError"));
			return;
		}

		const timeoutId = setTimeout(() => {
			signal?.removeEventListener("abort", handleAbort);
			resolve();
		}, SIMULATED_API_LATENCY_MS);

		/** Cancels the simulated delay when the query unmounts or its key changes. */
		function handleAbort() {
			clearTimeout(timeoutId);
			reject(new DOMException("The domains request was aborted.", "AbortError"));
		}

		signal?.addEventListener("abort", handleAbort, { once: true });
	});
}

/** Options accepted by the simulated domains fetcher. */
type FetchDomainsOptions = {
	/** The filters to apply server-side. */
	filters: DomainFilters;
	/** Which response to simulate. */
	scenario: DomainsScenario;
	/** Abort signal passed by TanStack Query. */
	signal?: AbortSignal;
};

/** Simulates a paged list endpoint: latency, server-side filters, a total, and a failure mode. */
async function fetchDomains({
	filters,
	scenario,
	signal,
}: FetchDomainsOptions): Promise<DomainsPage> {
	await waitForDemoApi(signal);

	if (scenario === "server-error") {
		throw new Error("The domains service returned 503 Service Unavailable.");
	}

	if (scenario === "no-domains") {
		return { items: [], total: 0 };
	}

	const matches = demoDomains.filter((domain) => matchesFilters(domain, filters));
	return { items: matches.slice(0, PAGE_SIZE), total: matches.length };
}

/** Options for the domains list query. */
type UseDomainsQueryOptions = {
	/** The filters the server applies. Each distinct set is its own cache entry. */
	filters: DomainFilters;
	/** Demo scenario selected in the toolbar. */
	scenario: DomainsScenario;
};

/** Owns fetching, caching, and the previous-page policy for the domains list. */
function useDomainsQuery({
	filters,
	scenario,
}: UseDomainsQueryOptions): UseQueryResult<DomainsPage> {
	return useQuery({
		queryKey: [...DOMAINS_QUERY_KEY_ROOT, scenario, filters],
		queryFn: ({ signal }) => fetchDomains({ filters, scenario, signal }),
		// Why keepPreviousData: a filter change swaps the query key. Without it
		// the table falls back to skeleton rows on every keystroke. With it the
		// loaded rows stay on screen, dimmed, until the next page lands.
		placeholderData: keepPreviousData,
		// Why no retry: the demo's failure is deterministic, so a retry only adds
		// latency. A real list encodes its retry policy here, as the
		// overlay-async recipe does.
		retry: false,
		// Why staleTime: a return visit within 30 seconds renders from the cache,
		// so the reader never sees the skeleton twice.
		staleTime: 30_000,
	});
}

/** The one region of the table body that renders. */
type ListBodyState =
	| { kind: "pending" }
	| { kind: "error"; error: Error }
	| { kind: "no-data" }
	| { kind: "no-results" }
	| { kind: "rows"; items: Domain[]; total: number };

/** Options for deriving the table body state. */
type ResolveListBodyStateOptions = {
	/** The domains list query. */
	query: UseQueryResult<DomainsPage>;
	/** Whether a filter narrows the list. */
	isFiltered: boolean;
};

/**
 * Derives the table body state from the query. Pending wins over empty, so
 * "no results" never flashes before the first response lands.
 */
function resolveListBodyState({ query, isFiltered }: ResolveListBodyStateOptions): ListBodyState {
	switch (query.status) {
		case "pending": {
			return { kind: "pending" };
		}
		case "error": {
			return { kind: "error", error: query.error };
		}
		case "success": {
			const { items, total } = query.data;
			if (items.length > 0) {
				return { kind: "rows", items, total };
			}
			return { kind: isFiltered ? "no-results" : "no-data" };
		}
	}
}

const numberFormat = new Intl.NumberFormat("en-US");

/** Options for the count line. */
type FormatDomainCountOptions = {
	/** How many rows the page shows. */
	shown: number;
	/** How many domains match the filters. */
	total: number;
};

/** Formats the count line: `Showing 8 of 1,284 domains`. */
function formatDomainCount({ shown, total }: FormatDomainCountOptions): string {
	return `Showing ${numberFormat.format(shown)} of ${numberFormat.format(total)} domains`;
}

/** Options for the live-region message. */
type DescribeListStateOptions = {
	/** The table body state. */
	state: ListBodyState;
	/** Whether a refetch is in flight behind loaded rows. */
	isRefetching: boolean;
};

/**
 * The live-region message for a list state. The skeleton rows are
 * `aria-hidden`, so this message is the only loading signal a screen reader
 * gets.
 */
function describeListState({ state, isRefetching }: DescribeListStateOptions): string {
	if (isRefetching) {
		return "Updating domains";
	}
	switch (state.kind) {
		case "pending": {
			return "Loading domains";
		}
		case "error": {
			return "Domains failed to load";
		}
		case "no-data": {
			return "No domains yet";
		}
		case "no-results": {
			return "No domains match the filters";
		}
		case "rows": {
			return formatDomainCount({ shown: state.items.length, total: state.total });
		}
	}
}

const dateFormat = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" });

// The list is sorted and filtered on the server, so the table registers no
// client-side features.
const features = tableFeatures({});
const columnHelper = createColumnHelper<typeof features, Domain>();

// Every column but the first names its width. With `table-fixed` on the
// table, those widths hold from the skeleton rows through the loaded rows, and
// the first column absorbs whatever is left.
const columns = columnHelper.columns([
	columnHelper.accessor("name", {
		id: "name",
		header: () => <DataTable.Header>Domain</DataTable.Header>,
		cell: (props) => (
			<DataTable.Cell className="text-strong truncate" translate="no">
				{props.getValue()}
			</DataTable.Cell>
		),
	}),
	columnHelper.accessor("region", {
		id: "region",
		header: () => <DataTable.Header className="w-24">Region</DataTable.Header>,
		cell: (props) => <DataTable.Cell translate="no">{props.getValue()}</DataTable.Cell>,
	}),
	columnHelper.accessor("certificate", {
		id: "certificate",
		header: () => <DataTable.Header className="w-36">Certificate</DataTable.Header>,
		cell: (props) => (
			<DataTable.Cell>
				<Badge appearance="muted" color={props.getValue() === "managed" ? "success" : "neutral"}>
					{certificateLabels[props.getValue()]}
				</Badge>
			</DataTable.Cell>
		),
	}),
	columnHelper.accessor("endpointCount", {
		id: "endpointCount",
		header: () => <DataTable.Header className="w-28 text-right">Endpoints</DataTable.Header>,
		cell: (props) => <DataTable.Cell className="text-right">{props.getValue()}</DataTable.Cell>,
	}),
	columnHelper.accessor("createdAt", {
		id: "createdAt",
		header: () => <DataTable.Header className="w-36">Created</DataTable.Header>,
		cell: (props) => (
			<DataTable.Cell>{dateFormat.format(new Date(props.getValue()))}</DataTable.Cell>
		),
	}),
]);

/**
 * The skeleton bar for each column id, sized to the content it stands in for.
 * A bar near the real content's width keeps the row height and the column
 * widths still when the rows swap in.
 */
const skeletonCellClassNames: Record<string, string> = {
	name: "w-3/4",
	region: "w-6",
	certificate: "h-5 w-20 rounded-full",
	endpointCount: "ml-auto w-6",
	createdAt: "w-28",
};

/** Why a module constant: `useTable` compares `data` by reference, so a fresh `[]` per render would reset the row model. */
const noDomains: Domain[] = [];

/** Props for the skeleton rows. */
type SkeletonRowsProps = {
	/** The table's leaf columns, so the skeleton has one cell per real column. */
	columns: ReadonlyArray<{ id: string }>;
	/** How many rows to render. Match it to the page size. */
	count: number;
};

/**
 * Placeholder rows with one skeleton per column. They use the same cell part
 * as the loaded rows, so padding and row height match, and they derive from
 * the same column list, so the column count cannot drift.
 */
function SkeletonRows({ columns, count }: SkeletonRowsProps) {
	return Array.from({ length: count }, (_, index) => (
		// Why aria-hidden: the rows carry no information. The page's LiveRegion
		// announces the loading state instead.
		<Table.Row key={index} aria-hidden>
			{columns.map((column) => (
				<DataTable.Cell key={column.id}>
					<Skeleton className={cx("h-4", skeletonCellClassNames[column.id] ?? "w-24")} />
				</DataTable.Cell>
			))}
		</Table.Row>
	));
}

/** Props for the table body. */
type DomainsTableBodyProps = {
	/** The derived body state. */
	state: ListBodyState;
	/** The rows TanStack Table built from the loaded page. */
	rows: ReadonlyArray<Row<typeof features, Domain>>;
	/** The table's leaf columns, for the skeleton rows. */
	columns: ReadonlyArray<{ id: string }>;
	/** Whether the retry request is in flight. */
	isRetrying: boolean;
	/** Refetches the current page after an error. */
	onRetry: () => void;
	/** Resets every filter to its default. */
	onClearFilters: () => void;
};

/** Renders exactly one of the five body states inside the table frame. */
function DomainsTableBody({
	state,
	rows,
	columns,
	isRetrying,
	onRetry,
	onClearFilters,
}: DomainsTableBodyProps) {
	switch (state.kind) {
		case "pending": {
			return <SkeletonRows columns={columns} count={PAGE_SIZE} />;
		}
		case "rows": {
			return rows.map((row) => <DataTable.Row key={row.id} row={row} />);
		}
		case "error": {
			return (
				<DataTable.EmptyRow>
					<Empty.Root>
						<Empty.Icon svg={<WarningCircleIcon />} />
						<Empty.Title>Domains failed to load</Empty.Title>
						<Empty.Description>
							<p>{state.error.message}</p>
						</Empty.Description>
						<Empty.Actions>
							<Button
								type="button"
								appearance="outlined"
								intent="neutral"
								isLoading={isRetrying}
								onClick={onRetry}
							>
								Retry
							</Button>
						</Empty.Actions>
					</Empty.Root>
				</DataTable.EmptyRow>
			);
		}
		case "no-results": {
			return (
				<DataTable.EmptyRow>
					<Empty.Root>
						<Empty.Icon svg={<MagnifyingGlassIcon />} />
						<Empty.Title>No domains match the filters</Empty.Title>
						<Empty.Description>
							<p>Try a different search, or clear the filters to see every domain.</p>
						</Empty.Description>
						<Empty.Actions>
							<Button type="button" appearance="outlined" intent="neutral" onClick={onClearFilters}>
								Clear filters
							</Button>
						</Empty.Actions>
					</Empty.Root>
				</DataTable.EmptyRow>
			);
		}
		case "no-data": {
			return (
				<DataTable.EmptyRow>
					<Empty.Root>
						<Empty.Icon svg={<GlobeHemisphereWestIcon />} />
						<Empty.Title>No domains yet</Empty.Title>
						<Empty.Description>
							<p>Reserve a domain to route public traffic to an endpoint.</p>
						</Empty.Description>
						<Empty.Actions>
							<Button type="button" appearance="outlined" intent="neutral">
								New domain
							</Button>
						</Empty.Actions>
					</Empty.Root>
				</DataTable.EmptyRow>
			);
		}
	}
}

/** Props for the count line. */
type DomainCountProps = {
	/** The derived body state. */
	state: ListBodyState;
};

/** The text of the count line, or a skeleton while the first page loads. */
function DomainCountText({ state }: DomainCountProps) {
	switch (state.kind) {
		case "pending": {
			// Why asChild: a `<div>` inside a `<p>` splits the paragraph in the
			// server-rendered HTML, and the table below moves on hydration.
			return (
				<Skeleton asChild className="h-3 w-44">
					<span />
				</Skeleton>
			);
		}
		case "error": {
			return null;
		}
		case "no-data":
		case "no-results": {
			return formatDomainCount({ shown: 0, total: 0 });
		}
		case "rows": {
			return formatDomainCount({ shown: state.items.length, total: state.total });
		}
	}
}

/**
 * The count line above the table. It is mounted in every state at a fixed
 * height and aligned to the right edge, so the table never moves when the
 * count arrives and a wider number moves nothing beside it. It never reads
 * `0` while the first page is still loading.
 */
function DomainCount({ state }: DomainCountProps) {
	return (
		<p className="text-muted flex h-5 items-center justify-end text-sm">
			<DomainCountText state={state} />
		</p>
	);
}

/** Props for the domains table. */
type DomainsTableProps = {
	/** The domains list query. */
	query: UseQueryResult<DomainsPage>;
	/** Whether a filter narrows the list. */
	isFiltered: boolean;
	/** Resets every filter to its default. */
	onClearFilters: () => void;
};

/** The table frame: the header renders from the column list on the first frame, and only the body branches. */
function DomainsTable({ query, isFiltered, onClearFilters }: DomainsTableProps) {
	const table = useTable({ features, data: query.data?.items ?? noDomains, columns });
	const state = resolveListBodyState({ query, isFiltered });
	const isRefetching = query.isFetching && !query.isPending;

	return (
		<div className="flex flex-col gap-2">
			<DomainCount state={state} />
			{/* Why aria-busy: a refetch behind loaded rows dims the table instead of
			    replacing it, and the attribute tells assistive tech the region is
			    updating. */}
			<div aria-busy={isRefetching} className="transition-opacity aria-busy:opacity-60">
				<DataTable.Root table={table} className="[&_table]:table-fixed">
					<DataTable.Head />
					<DataTable.Body>
						<DomainsTableBody
							state={state}
							rows={table.getRowModel().rows}
							columns={table.getAllLeafColumns()}
							isRetrying={query.isFetching}
							onRetry={() => {
								void query.refetch();
							}}
							onClearFilters={onClearFilters}
						/>
					</DataTable.Body>
				</DataTable.Root>
			</div>
		</div>
	);
}

/** Props for a filter select. */
type FilterSelectProps = {
	/** The dimension the select filters, used as the accessible name and the option prefix. */
	label: string;
	/** The selected option value. */
	value: string;
	/** The options, including the `"any"` option first. */
	options: ReadonlyArray<{ value: string; label: ReactNode }>;
	/** Called with the raw option value; the caller narrows it. */
	onValueChange: (value: string) => void;
};

/** A fixed-width filter, so a label change (`Region: any` to `Region: eu`) moves no sibling. */
function FilterSelect({ label, value, options, onValueChange }: FilterSelectProps) {
	const selected = options.find((option) => option.value === value);

	return (
		<Select.Root value={value} onValueChange={onValueChange}>
			<Select.Trigger className="w-44" aria-label={label}>
				{/* Why children: Radix fills an empty `Select.Value` from the mounted
				    items, which the server never renders. Explicit text puts the
				    label in the server HTML, so the trigger is not blank on the
				    first frame. */}
				<Select.Value>
					{label}: <span>{selected?.label}</span>
				</Select.Value>
			</Select.Trigger>
			<Select.Content width="trigger">
				{options.map((option) => (
					<Select.Item key={option.value} value={option.value}>
						{label}: {option.label}
					</Select.Item>
				))}
			</Select.Content>
		</Select.Root>
	);
}

// Why translate="no": a region code is copied into a CLI flag, and a translated
// one names the wrong region. The domain and region cells above lock it too.
const regionOptions = [
	{ value: "any", label: "any" },
	...regions.map((region) => ({ value: region, label: <span translate="no">{region}</span> })),
];

const certificateOptions = [
	{ value: "any", label: "any" },
	...certificateKinds.map((kind) => ({ value: kind, label: certificateLabels[kind] })),
];

/** Props for the list page. */
type DomainsListPageProps = {
	/** Demo scenario selected in the toolbar. */
	scenario: DomainsScenario;
};

/**
 * The recipe. The heading, description, primary action, filters, and column
 * headers render on the first frame. Only the count line and the table body
 * read the query.
 */
export function DomainsListPage({ scenario }: DomainsListPageProps) {
	const [filters, setFilters] = useState(defaultFilters);
	// Why useDeferredValue: the search box updates on every keystroke, and the
	// query key follows one frame behind, so typing never waits on a render of
	// the table.
	const deferredSearch = useDeferredValue(filters.search);
	// Why one filters value for the query and the empty check: the eager
	// `filters` run a frame ahead of the deferred search. If the empty check
	// read them, clearing a no-result search would show "No domains yet" for
	// the frame in which the query still holds the old search.
	const queryFilters: DomainFilters = { ...filters, search: deferredSearch };
	const query = useDomainsQuery({ filters: queryFilters, scenario });
	const isFiltered = hasActiveFilter(queryFilters);
	const state = resolveListBodyState({ query, isFiltered });
	const message = describeListState({ state, isRefetching: query.isFetching && !query.isPending });

	// Why gate on hydration: a live region announces changes, not the text it
	// mounts with. The region mounts empty, in the server HTML too, and the
	// first message lands one frame after mount so a screen reader hears it.
	const announcement = useIsHydratedAfterPaint() ? message : "";

	return (
		<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 p-6">
			<LiveRegion>{announcement}</LiveRegion>
			<div className="flex flex-wrap items-start justify-between gap-4">
				<div className="flex flex-col gap-1">
					<h1 className="text-strong text-2xl font-medium">Domains</h1>
					<p className="text-muted text-sm">
						Reserved domains route public traffic to your endpoints. Reserve one per region you
						serve from.
					</p>
				</div>
				<Button type="button" appearance="filled" intent="accent">
					New domain
				</Button>
			</div>
			<div className="flex flex-wrap items-center gap-2">
				<FilterSelect
					label="Region"
					value={filters.region}
					options={regionOptions}
					onValueChange={(value) => {
						if (isRegionFilter(value)) {
							setFilters((current) => ({ ...current, region: value }));
						}
					}}
				/>
				<FilterSelect
					label="Certificate"
					value={filters.certificate}
					options={certificateOptions}
					onValueChange={(value) => {
						if (isCertificateFilter(value)) {
							setFilters((current) => ({ ...current, certificate: value }));
						}
					}}
				/>
				<Input
					type="search"
					aria-label="Search domains"
					placeholder="Search domains…"
					className="min-w-64 flex-1"
					value={filters.search}
					onChange={(event) => {
						const search = event.target.value;
						setFilters((current) => ({ ...current, search }));
					}}
				/>
			</div>
			<DomainsTable
				query={query}
				isFiltered={isFiltered}
				onClearFilters={() => {
					setFilters(defaultFilters);
				}}
			/>
		</div>
	);
}

/**
 * The framed preview: the domains list page inside an app shell, with
 * demo-only controls in the toolbar to pick the response and replay the
 * cold load. Renders as an entire framed-preview document (see
 * preview-registry.ts), so it pins itself with `fixed inset-0` and owns the
 * `Main` landmark.
 */
export function ListPageLoadingDemo() {
	const [scenario, setScenario] = useState<DomainsScenario>("success");
	const queryClient = useQueryClient();

	/** Demo-only: resets every cached page, so the next render shows the cold load again. */
	const replay = () => {
		void queryClient.resetQueries({ queryKey: DOMAINS_QUERY_KEY_ROOT });
	};

	return (
		<AppLayout.Root className="fixed inset-0">
			<SkipToMainLink />
			<AppLayout.Workspace>
				<AppLayout.Content>
					<AppLayout.Header>
						<p className="text-strong text-sm font-medium">Domains</p>
						<div className="ml-auto flex items-center gap-2">
							<Select.Root
								value={scenario}
								onValueChange={(value) => {
									if (isDomainsScenario(value)) {
										setScenario(value);
									}
								}}
							>
								<Select.Trigger className="w-52" aria-label="Demo scenario">
									<Select.Value>{scenarioLabels[scenario]}</Select.Value>
								</Select.Trigger>
								<Select.Content width="trigger">
									{domainsScenarios.map((value) => (
										<Select.Item key={value} value={value}>
											{scenarioLabels[value]}
										</Select.Item>
									))}
								</Select.Content>
							</Select.Root>
							<Button
								type="button"
								appearance="outlined"
								intent="neutral"
								size="sm"
								onClick={replay}
							>
								Replay load
							</Button>
						</div>
					</AppLayout.Header>
					<AppLayout.Body>
						<DomainsListPage scenario={scenario} />
					</AppLayout.Body>
				</AppLayout.Content>
			</AppLayout.Workspace>
		</AppLayout.Root>
	);
}
```

Pick a scenario in the toolbar, then click **Replay load** to watch it from a cold start. After the rows land, type in the search box: the rows dim while the next page loads, and the skeleton never comes back.

## Why the shell first

- **The first frame is the layout.** Everything above the rows is static markup. Paint it before the request resolves, and the reader gets the page's shape at once. The browser gets its largest paint without a round trip to the server.
- **A skeleton that matches the rows moves nothing.** Cumulative Layout Shift (CLS) counts every element that moves after it paints. Skeleton rows with the same cell padding, the same row count, and bars near the real content's width mean the swap to real rows changes pixels, not positions.
- **Column widths come from the headers, not the data.** The default `table-layout: auto` sizes every column by its widest cell. With no header widths, the columns slide sideways the moment real text arrives, and a long value on page two widens its column, moves every header after it, and pushes the table into a horizontal scroll. A width on each header holds the columns through the skeleton swap. `table-fixed` makes those widths final, so a long value truncates instead of moving the table.
- **A refetch is not a cold load.** The reader already has rows on screen. Replacing them with skeletons throws away a working page for the length of a request. Keep them, dim them, and swap when the next page lands.
- **Screen readers can't see a skeleton.** `Skeleton` renders `aria-hidden`, which is right: the bars carry no information. The page still has to say that it is loading, and later how many rows it shows, so a `LiveRegion` mounted from the first frame announces each state.

## What this covers

| State         | What renders                                                                                     |
| :------------ | :----------------------------------------------------------------------------------------------- |
| Cold load     | The header, the filters, the column headers, eight skeleton rows, and a skeleton count.          |
| Loaded        | Eight rows and `Showing 8 of 1,284 domains`.                                                     |
| Filter change | The loaded rows stay on screen, dimmed and `aria-busy`, until the filtered page lands.           |
| No results    | An `Empty` inside the table frame with a **Clear filters** action.                               |
| No data       | An `Empty` inside the table frame with the create action.                                        |
| Request fails | An `Empty` inside the table frame with a **Retry** action. The header and filters stay in place. |

The demo waits 1.5 seconds per request so every state is visible. The route is also available as plain markdown at [`/recipes/list-page-loading-states.md`](/recipes/list-page-loading-states.md), so humans and LLM tools can fetch the same recipe without scraping the rendered page.

## Anatomy

```text showLineNumbers=false
DomainsListPage                              renders on the first frame
├── LiveRegion                               mounted once; its text changes per state
├── Heading, description, primary action     static
├── Filters: Select, Select, Input           static; fixed widths
└── DomainsTable
    ├── DomainCount                          fixed height; skeleton | text
    └── DataTable.Root                       [&_table]:table-fixed
        ├── DataTable.Head                   built from the column list; never waits
        └── DataTable.Body                   pending | rows | no-results | no-data | error   ← the only region that branches
```

## Implementation

The rules below keep the code consistent with the intent above.

1. **Render everything that does not read the query outside the branch.** The heading, description, primary action, filters, and `DataTable.Head` take no data. In the demo, only `DomainCount` and `DomainsTableBody` read `state`. Give each `Select.Value` its text as children: Radix fills an empty one from the mounted items, which the server never renders, so the trigger is blank until hydration. Wrap the part of that text that changes in a `<span>`. A filter that starts on a plain string and resolves to an element flips a child's type beside a sibling that stays put, which crashes a page a browser translation engine has translated — see [Browser translation](/browser-translation).
2. **Derive one body state, and check pending first.** `resolveListBodyState` returns exactly one of `pending`, `rows`, `no-results`, `no-data`, and `error`. Pending comes first, so an empty state can't render before the first response. A `switch` on the state renders exactly one region.
3. **Build the skeleton from the real table.** `SkeletonRows` takes `table.getAllLeafColumns()` and renders one `DataTable.Cell` per column, so the skeleton can't have more or fewer columns than the loaded rows. A second column list, or a copy of the real one with different flags, drifts.
4. **Match the row count to the page size.** Eight skeleton rows for a page of eight. Three skeleton rows for a page of twenty guarantee seventeen rows of shift. If the table registers `rowPaginationFeature`, pass `manualPagination: true` to the skeleton's table, or the row model caps the placeholder at the default page size.
5. **Size each bar to its column.** A `Record` keyed by column id holds one class per column: a wide bar for the domain name, a pill for the badge, a right-aligned bar for the number. Bars that match the content's shape keep the row height still.
6. **Pin the column widths.** Set a width class on every `DataTable.Header` but one, and `[&_table]:table-fixed` on `DataTable.Root`. The header widths hold from skeleton to rows on their own, as long as no cell is wider than its column. `table-fixed` covers the case where one is: under auto layout that column grows and every header after it moves, and `truncate` only clips text under fixed layout. The unsized column absorbs the remaining width.
7. **Mount the count line in every state.** `DomainCount` is a fixed-height `<p>` aligned to the right edge, so a wider number moves nothing beside it. It shows a skeleton while pending and the count when loaded. It never shows `0` before the first response, because a count that appears with the data pushes the table down.
8. **Keep the loaded rows through a refetch.** `placeholderData: keepPreviousData` keeps the previous page while a new key loads. The wrapper sets `aria-busy` and dims, and `isRefetching` (fetching with data present) drives both. If your list filters on the client instead, run the filter in the query's `select` and pass the filter value through `useDeferredValue`. The query never re-enters pending.
9. **Announce every state once.** One `LiveRegion` renders from the first frame, empty, and `useIsHydratedAfterPaint` publishes the message one frame after mount: `Loading domains`, `Updating domains`, `Showing 8 of 1,284 domains`, and the empty and error messages. A live region announces changes, not the text it mounts with, so an empty mount is what makes the first message audible. Skeleton rows are `aria-hidden`, so the region is the loading signal.
10. **Keep errors inside the frame.** An error renders an `Empty` in `DataTable.EmptyRow` with a **Retry** button that calls `query.refetch()`. The frame, the header, and the filters stay where they are. Put the retry policy in the hook, as the [Overlays + Async Data](/recipes/overlay-async) recipe does.
11. **Cache the page for the return trip.** A `staleTime` long enough to cover a detail-and-back navigation renders the list from cache, so the reader sees the skeleton once per session instead of once per visit.

## Anti-patterns

The left column is what not to do. The right column is the fix.

| Don't                                                                                                                     | Do                                                                                                                                                             |
| :------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Return a full-page spinner while an existence check resolves. The heading and toolbar wait on a request they don't need.  | Render the heading, description, and toolbar, and gate only the region below them. If a zero state replaces the whole page, gate below the heading they share. |
| Render three skeleton rows for a twenty-row page.                                                                         | Render one skeleton row per row of the page size.                                                                                                              |
| Mount pagination only with the loaded table.                                                                              | Render the pagination controls in every state and disable them while pending.                                                                                  |
| Show `0 results` while the first page loads.                                                                              | Show a skeleton, or nothing, until the first response.                                                                                                         |
| Build the skeleton from a second column list with hard-coded flags.                                                       | Pass the real table's leaf columns to the skeleton.                                                                                                            |
| Leave the default `table-auto` layout with no header widths, so the columns move when text arrives or a long value lands. | Set a width on every header but one, and `table-fixed` so a long value truncates instead of widening its column.                                               |
| Replace loaded rows with skeletons on a filter change.                                                                    | Keep the rows with `keepPreviousData`, or filter on the client in `select`.                                                                                    |
| Say nothing to screen readers while the rows load.                                                                        | Mount a `LiveRegion` on the first frame and change its text per state.                                                                                         |

## Other data layers

The rules don't depend on TanStack Query. With React Router, a server `loader` blocks the document until the list resolves, so the first paint has rows and no skeleton, at the cost of a slower document. To paint the shell first, keep the list out of the loader and fetch it from the component, or return the promise from the loader and render the skeleton rows as the `Suspense` fallback around `Await`. `HydrateFallback` applies only when a `clientLoader` runs during hydration; if you have one, make it the same shell with skeleton rows, never a spinner. For later navigations, read `useNavigation().state`. Never gate an `<Outlet />` on a pending request: every child route waits behind it. For a page-level loading indicator, a fixed, zero-height progress bar at the top of the viewport costs no layout at all.
