Data‑dense interfaces fail when they try to show everything at once. The craft is to progressively disclose information, keep interactions snappy, and ensure that assistive technologies get the same story users see. This guide gives you patterns for tables, charts, and dashboards that scale—without burning Core Web Vitals.
You’ll get: virtualized tables, accessible grid patterns, chunked transforms, progressive chart loading, streaming UI sections, and testing strategies for a11y + performance.
Table of Contents
- Dashboard information architecture
- Tables: virtualization, sorting, and filters
- Accessible tables & grid roles
- Charts: progressive enhancement
- Background work: Web Workers & WASM
- Streaming sections with Suspense
- State, caching & pagination
- Testing & observability
- Reference snippets
- FAQ
Dashboard information architecture
- Lead with KPIs (top row), trends (middle), and details (table/feeds) at the bottom.
- Use tabs or filters to partition data sets.
- Defer expensive visuals until the user expresses intent (scroll into view, clicks a tab).
Tables: virtualization, sorting, and filters
Use row virtualization to render only what’s visible.
pnpm add @tanstack/react-table @tanstack/react-virtual @tanstack/react-query zod// VirtualTable.tsx
"use client";
import * as React from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
type Row = { id: string; name: string; value: number };
export function VirtualTable({
data,
columns,
}: {
data: Row[];
columns: ColumnDef<Row, any>[];
}) {
const parentRef = React.useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
});
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div ref={parentRef} className="h-[480px] overflow-auto rounded border">
<div
className="relative h-[%s] w-full"
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{rowVirtualizer.getVirtualItems().map((vi) => {
const row = table.getRowModel().rows[vi.index];
return (
<div
key={row.id}
className="absolute left-0 top-0 flex w-full items-center border-b px-3"
style={{
transform: `translateY(${vi.start}px)`,
height: `${vi.size}px`,
}}
role="row"
>
{row.getVisibleCells().map((cell) => (
<div key={cell.id} className="flex-1 py-2" role="cell">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
))}
</div>
);
})}
</div>
</div>
);
}Add client‑side sorting & filters cheaply on small sets; push to server when it grows. Keep filters in the URL (searchParams) to preserve deep links.
Accessible tables & grid roles
Prefer semantic tables. For complex interactions (sticky headers, spreadsheets), consider role="grid" with correct keyboard behavior.
<table className="w-full border-collapse">
<caption className="sr-only">Monthly revenue by region</caption>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Revenue</th>
<th scope="col">Change</th>
</tr>
</thead>
<tbody>{/* rows */}</tbody>
</table>- Announce sort state via
aria-sort="ascending|descending". - For grids, implement roving tabindex, Arrow navigation, and Home/End.
Charts: progressive enhancement
- Start with text: title + short summary; provide a data table fallback (hidden but accessible).
- Lazy‑load chart libraries; they’re heavy.
- Avoid blur/filters; animate with transforms only.
// ProgressiveChart.tsx
"use client";
import dynamic from "next/dynamic";
const Chart = dynamic(() => import("./ChartImpl"), {
ssr: false,
loading: () => <div className="h-48 animate-pulse rounded bg-neutral-100" />,
});
export function ProgressiveChart(props: any) {
return <Chart {...props} />;
}Background work: Web Workers & WASM
Large transforms (group‑bys, stats) should not block the main thread.
// worker.ts
self.onmessage = (e) => {
const rows = e.data as Array<{ value: number }>;
const sum = rows.reduce((acc, r) => acc + r.value, 0);
// @ts-ignore
postMessage({ sum });
};// useWorker.ts
export function useWorker<T, R>(fn: (data: T) => R) {
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module",
});
return (data: T) =>
new Promise<R>((resolve) => {
worker.onmessage = (e) => resolve(e.data as R);
worker.postMessage(data);
});
}Consider WASM (e.g., Arrow, DuckDB‑Wasm) for very large in‑browser analytics.
Streaming sections with Suspense
Stream slow metrics/feeds with <Suspense> and show stable skeletons to preserve layout.
import { Suspense } from "react";
export default function Dashboard() {
return (
<div className="grid gap-6">
<Suspense
fallback={<div className="h-24 animate-pulse rounded bg-neutral-100" />}
>
{/* KPI widget */}
</Suspense>
<Suspense
fallback={<div className="h-48 animate-pulse rounded bg-neutral-100" />}
>
{/* Chart widget */}
</Suspense>
</div>
);
}State, caching & pagination
- Use TanStack Query for caching, retries, and pagination.
- Push filters and sort to server for very large tables (cursor pagination).
- Revalidate selectively with tags or query keys.
Testing & observability
- E2E: verify sort/filter URL params and persisted state on refresh.
- Visual tests: light/dark snapshots of charts and tables.
- Web Vitals: track INP/LCP on dashboard routes; alert on regressions.
- Runtime guards: dev‑only warnings for missing captions/labels.
Reference snippets
ARIA sort button
function SortableHeader({
label,
dir,
}: {
label: string;
dir: "asc" | "desc" | "none";
}) {
const ariaSort =
dir === "none" ? "none" : dir === "asc" ? "ascending" : "descending";
return (
<button aria-sort={ariaSort as any} className="flex items-center gap-1">
{label}
<span aria-hidden="true">
{dir === "asc" ? "▲" : dir === "desc" ? "▼" : "⇵"}
</span>
</button>
);
}CSV download (streaming)
export async function streamCsv(rows: Array<Record<string, any>>) {
const headers = Object.keys(rows[0] ?? {});
const lines = [headers.join(",")];
for (const r of rows) {
lines.push(headers.map((h) => JSON.stringify(r[h] ?? "")).join(","));
}
return new Blob([lines.join("\n")], { type: "text/csv" });
}FAQ
Which chart library is best?
Pick by ecosystem fit and size. Many teams start with Recharts or VisX; switch to ECharts or Chart.js when you need more features.
How do I keep the table accessible with virtualization?
Virtualization can break semantics. Prefer a div‑based grid with roles when virtualized, or paginate a semantic <table>.
Why is my dashboard slow?
Large chart bundles, synchronous transforms, and chatty queries. Lazy‑load charts, move transforms to workers, and batch requests.
Next steps
- Virtualize long lists; paginate or grid‑role when needed.
- Lazy‑load chart libs and stream heavy widgets.
- Add INP/LCP tracking to dashboard routes and fix the top offenders.
