List page
The full-bleed tool: header, 28px toolbar, a grid or board that scrolls inside, and a totals footer.
What it's for#
The list page is a full-bleed tool for working through many records of one kind: find them, sort them, change them in bulk and open one.
It fills the viewport and never scrolls the window. The header, toolbar and footer stay put; the grid or board between them is the one region that scrolls. Because the grid is the page, the visible title lives in the header's breadcrumb and the page's h1 is screen-reader text. If people read the page more than they work in it, it is a document page.
| Route | What it lists |
|---|---|
| /deals | Deals as a board by stage or a table, with a pipeline stat row and totals. |
| /companies | Companies with open pipeline in the footer. |
| /people | People with verified emails counted in the footer. |
| /tickets | Tickets by saved view, the one list that loads through Data state. |
| /tasks | Tasks grouped by due date. |
| /lists | Saved lists of records. |
Live composition#
Cedarline's deals at 1280px. Switch views, search, turn on the stage filter, change the row height under Display, select rows to raise the action bar, or switch to the board.
import { ActionBar } from "@oration/canon/components/action-bar";import { Button } from "@oration/canon/components/button";import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { DataGrid, type DataGridColumn } from "@oration/canon/components/data-grid";import { EmptyState } from "@oration/canon/components/data-state";import { FilterChip, FilterChipRow } from "@oration/canon/components/filter-chip";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { Popover, PopoverContent, PopoverTrigger } from "@oration/canon/components/popover";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { Tag, type TagColor } from "@oration/canon/components/tag";import { toast } from "@oration/canon/components/toast";import { ToolbarRow, ToolbarSearch } from "@oration/canon/components/toolbar";import { cn } from "@oration/canon/lib/utils";import { ArrowDownNarrowWideIcon, ArrowRightLeftIcon, ArrowUpNarrowWideIcon, CalendarIcon, CircleDollarSignIcon, DownloadIcon, KanbanSquareIcon, ListFilterIcon, PlusIcon, Settings2Icon, TableIcon, Trash2Icon, UserIcon,} from "lucide-react";import * as React from "react";import { AppShell } from "../document-page/frame";export function DealsList() { type Stage = "discovery" | "proposal" | "negotiation" | "won"; type Deal = { id: string; name: string; company: string; color: TagColor; stage: Stage; amount: number; close: string; owner: string; next: string; }; const stages: { id: Stage; label: string; color: TagColor }[] = [ { id: "discovery", label: "Discovery", color: "blue" }, { id: "proposal", label: "Proposal", color: "violet" }, { id: "negotiation", label: "Negotiation", color: "amber" }, { id: "won", label: "Closed won", color: "green" }, ]; const seed: Deal[] = [ [ "d1", "Invoice automation rollout", "Northwind Freight", "teal", "negotiation", 186000, "Oct 2", "Maya Okafor", "Walk Jordan through the 3-year option", ], [ "d2", "Vendor portal expansion", "Halcyon", "violet", "proposal", 94500, "Oct 31", "Priya Raman", "Send the security packet", ], [ "d3", "Multi-entity AP", "Orchard Street", "green", "negotiation", 142000, "Oct 23", "Tomás Ferreira", "Return redlines to Wen", ], [ "d4", "Remittance automation", "Bramble Logistics", "amber", "discovery", 38000, "Nov 14", "Aisha Bello", "Book a discovery call", ], [ "d5", "W-9 collection pilot", "Fernhill Clinics", "pink", "proposal", 27500, "Nov 6", "Jordan Lee", "Confirm pilot scope", ], [ "d6", "Payment runs, 9 entities", "Larkspur Foods", "blue", "discovery", 64000, "Dec 4", "Priya Raman", "Map their approval chain", ], [ "d7", "Supplier onboarding", "Kestrel Marine", "orange", "won", 51200, "Sep 18", "Maya Okafor", "Kickoff on Oct 5", ], [ "d8", "AP inbox migration", "Juniper Health", "red", "proposal", 73800, "Oct 28", "Wen Zhou", "Price the second entity", ], [ "d9", "Early-pay discounts", "Saltmarsh Retail", "indigo", "negotiation", 45900, "Oct 16", "Tomás Ferreira", "Legal review of terms", ], [ "d10", "Invoice capture", "Cobalt Works", "gray", "discovery", 22000, "Dec 12", "Aisha Bello", "Find the AP lead", ], [ "d11", "Three-way match", "Pinecrest Hotels", "teal", "proposal", 88300, "Nov 20", "Jordan Lee", "Demo PO matching", ], [ "d12", "Global payouts", "Meridian Air", "violet", "won", 132000, "Sep 4", "Maya Okafor", "Expansion review in Q1", ], ].map(([id, name, company, color, stage, amount, close, owner, next]) => ({ id: String(id), name: String(name), company: String(company), color: color as TagColor, stage: stage as Stage, amount: Number(amount), close: String(close), owner: String(owner), next: String(next), })); const [deals, setDeals] = React.useState(seed); const [view, setView] = React.useState("all"); const [layout, setLayout] = React.useState<"table" | "board">("table"); const [query, setQuery] = React.useState(""); const [stageFilter, setStageFilter] = React.useState(false); const [ascending, setAscending] = React.useState(true); const [density, setDensity] = React.useState< "compact" | "default" | "comfortable" >("default"); const [selection, setSelection] = React.useState<Set<string>>(new Set()); const [confirming, setConfirming] = React.useState(false); const tabRefs = React.useRef<Record<string, HTMLButtonElement | null>>({}); const [pill, setPill] = React.useState({ left: 0, width: 0 }); const views = [ { id: "all", label: "All deals", match: () => true }, { id: "mine", label: "My deals", match: (d: Deal) => d.owner === "Maya Okafor", }, { id: "closing", label: "Closing in October", match: (d: Deal) => d.close.startsWith("Oct"), }, ]; const stageOf = (id: Stage) => stages.find((s) => s.id === id) ?? stages[0]; const money = (value: number) => `$${value.toLocaleString("en-US", { minimumFractionDigits: 2 })}`; const viewMatch = views.find((v) => v.id === view)?.match ?? (() => true); const rows = deals .filter(viewMatch) .filter( (d) => !stageFilter || d.stage === "proposal" || d.stage === "negotiation", ) .filter((d) => `${d.name} ${d.company}` .toLowerCase() .includes(query.toLowerCase()), ) .sort((a, b) => ascending ? a.amount - b.amount : b.amount - a.amount, ); const selectedIds = rows .filter((d) => selection.has(d.id)) .map((d) => d.id); React.useLayoutEffect(() => { const tab = tabRefs.current[view]; if (tab) setPill({ left: tab.offsetLeft, width: tab.offsetWidth }); }, [view]); const columns: DataGridColumn<Deal>[] = [ { id: "name", header: "Deal", icon: <CircleDollarSignIcon />, width: 260, minWidth: 200, pinned: true, cell: (d) => ( <span className="truncate font-medium text-foreground"> {d.name} </span> ), }, { id: "company", header: "Company", width: 190, cell: (d) => ( <span className="flex min-w-0 items-center gap-2"> <MonogramTile name={d.company} color={d.color} size="sm" /> <span className="truncate">{d.company}</span> </span> ), }, { id: "stage", header: "Stage", width: 140, cell: (d) => ( <Tag color={stageOf(d.stage)?.color} dot> {stageOf(d.stage)?.label} </Tag> ), }, { id: "amount", header: "Amount", width: 130, align: "right", cell: (d) => ( <span className="tabular-nums">{money(d.amount)}</span> ), }, { id: "close", header: "Close date", icon: <CalendarIcon />, width: 120, cell: (d) => <span className="tabular-nums">{d.close}</span>, }, { id: "owner", header: "Owner", icon: <UserIcon />, width: 150, cell: (d) => d.owner, }, { id: "next", header: "Next step", width: 280, cell: (d) => ( <span className="truncate text-muted-foreground">{d.next}</span> ), }, ]; const clearFilters = () => { setQuery(""); setStageFilter(false); setView("all"); }; return ( <AppShell active="Deals" heading="Deals" crumbs={[ { label: "Deals", icon: <CircleDollarSignIcon className="size-3.5" />, }, ]} actions={ <> <SegmentedControl label="Layout" value={layout} onValueChange={(next) => { setLayout(next); setSelection(new Set()); }} options={[ { value: "board", label: "Board", icon: <KanbanSquareIcon />, }, { value: "table", label: "Table", icon: <TableIcon />, }, ]} /> <Button size="sm" onClick={() => toast.add({ title: "New deal", description: "Starts in Discovery with you as the owner.", }) } > <PlusIcon data-icon="inline-start" aria-hidden="true" /> New deal </Button> </> } > <div className="relative flex min-h-0 flex-1 flex-col"> <ToolbarRow> <div role="tablist" aria-label="Deal views" className="no-scrollbar relative flex min-w-0 items-center gap-0.5 overflow-x-auto" > <span aria-hidden="true" className="absolute inset-y-0 left-0 rounded-md bg-muted transition-[transform,width] duration-300 ease-out motion-reduce:transition-none" style={{ transform: `translateX(${pill.left}px)`, width: pill.width, }} /> {views.map((item) => ( <button key={item.id} ref={(node) => { tabRefs.current[item.id] = node; }} type="button" role="tab" aria-selected={view === item.id} onClick={() => { setView(item.id); setSelection(new Set()); }} className={cn( "relative flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-13 whitespace-nowrap outline-none transition-colors duration-150 focus-visible:ring-3 focus-visible:ring-ring/40", view === item.id ? "font-medium text-foreground" : "text-muted-foreground hover:text-foreground", )} > {item.label} <span className="text-xs font-normal text-muted-foreground tabular-nums"> {deals.filter(item.match).length} </span> </button> ))} </div> <div className="ml-auto flex items-center gap-1 @max-[640px]:ml-0 @max-[640px]:w-full"> <ToolbarSearch value={query} onChange={setQuery} placeholder="Search deals" /> <Button variant="ghost" size="sm" aria-pressed={stageFilter} className="text-muted-foreground" onClick={() => setStageFilter((on) => !on)} > <ListFilterIcon data-icon="inline-start" aria-hidden="true" /> <span className="@max-[640px]:sr-only">Filter</span> </Button> <Button variant="ghost" size="sm" className="text-muted-foreground" onClick={() => setAscending((value) => !value)} > {ascending ? ( <ArrowUpNarrowWideIcon data-icon="inline-start" aria-hidden="true" /> ) : ( <ArrowDownNarrowWideIcon data-icon="inline-start" aria-hidden="true" /> )} <span className="@max-[640px]:sr-only"> Sort: Amount </span> </Button> <Popover> <PopoverTrigger render={ <Button variant="ghost" size="sm" className="text-muted-foreground" /> } > <Settings2Icon data-icon="inline-start" aria-hidden="true" /> <span className="@max-[640px]:sr-only"> Display </span> </PopoverTrigger> <PopoverContent align="end" className="w-64 gap-3 p-3" > <p className="text-xs font-medium text-muted-foreground"> Row height </p> <SegmentedControl label="Row height" className="grid w-full grid-cols-3" value={density} onValueChange={setDensity} options={[ { value: "compact", label: "Compact" }, { value: "default", label: "Default" }, { value: "comfortable", label: "Spacious", }, ]} /> </PopoverContent> </Popover> </div> </ToolbarRow> {stageFilter ? ( <div className="flex items-center gap-2 border-b border-border px-3 py-2"> <FilterChipRow> <FilterChip icon={ <ArrowRightLeftIcon className="size-3.5 text-muted-foreground" /> } field="Stage" valuesLabel="Proposal, Negotiation" multiple onRemove={() => setStageFilter(false)} /> </FilterChipRow> <Button variant="ghost" size="sm" className="text-muted-foreground" onClick={clearFilters} > Clear all </Button> </div> ) : null} {layout === "table" ? ( <DataGrid label="Deals" className="flex-1" columns={columns} rows={rows} getRowId={(d) => d.id} selection={selection} onSelectionChange={setSelection} density={density} empty={ <EmptyState illustration="filters" title="No deals match" description="Try a different search, or clear filters to see every deal." action={ <Button variant="outline" size="sm" onClick={clearFilters} > Clear filters </Button> } /> } footer={ <div className="sticky bottom-0 left-0 z-10 flex h-9 items-center gap-6 border-t border-border bg-background px-3 text-xs text-muted-foreground tabular-nums"> <span> {rows.length}{" "} {rows.length === 1 ? "deal" : "deals"} </span> <span> Total{" "} <span className="font-medium text-foreground"> {money( rows.reduce( (sum, d) => sum + d.amount, 0, ), )} </span> </span> </div> } /> ) : ( <div className="min-h-0 flex-1 overflow-x-auto p-3"> <div className="flex h-full gap-3"> {stages.map((stage) => { const items = rows.filter( (d) => d.stage === stage.id, ); return ( <section key={stage.id} aria-label={stage.label} className="flex w-72 shrink-0 flex-col rounded-xl bg-muted/60" > <div className="flex items-center gap-2 px-3 pt-3 pb-2"> <Tag color={stage.color} dot> {stage.label} </Tag> <span className="text-xs text-muted-foreground tabular-nums"> {items.length} </span> </div> <div className="flex flex-col gap-2 overflow-y-auto px-2 pb-2"> {items.map((d) => ( <article key={d.id} className="flex flex-col gap-1 rounded-xl bg-card p-3 shadow-border" > <span className="truncate text-13 font-medium"> {d.name} </span> <span className="flex items-center justify-between gap-2 text-xs text-muted-foreground"> <span className="truncate"> {d.company} </span> <span className="text-foreground tabular-nums"> {money(d.amount)} </span> </span> </article> ))} </div> </section> ); })} </div> </div> )} <ActionBar count={layout === "table" ? selectedIds.length : 0} noun={selectedIds.length === 1 ? "deal" : "deals"} onClear={() => setSelection(new Set())} > <Button variant="ghost" size="sm" onClick={() => toast.add({ title: `Moved ${selectedIds.length} to Negotiation`, actionProps: { children: "Undo", onClick: () => undefined, }, }) } > <ArrowRightLeftIcon data-icon="inline-start" aria-hidden="true" /> Change stage </Button> <Button variant="ghost" size="sm" onClick={() => toast.add({ title: "Export started", description: `${selectedIds.length} deals as CSV.`, }) } > <DownloadIcon data-icon="inline-start" aria-hidden="true" /> Export </Button> <Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={() => setConfirming(true)} > <Trash2Icon data-icon="inline-start" aria-hidden="true" /> Delete </Button> </ActionBar> <ConfirmDialog open={confirming} onOpenChange={setConfirming} title={ selectedIds.length === 1 ? "Delete this deal?" : `Delete ${selectedIds.length} deals?` } description="The deals leave the pipeline and forecast. Their activity stays on the company timeline." confirmLabel={ selectedIds.length === 1 ? "Delete deal" : "Delete deals" } onConfirm={() => { const removed = deals.filter((d) => selectedIds.includes(d.id), ); setDeals((list) => list.filter((d) => !selectedIds.includes(d.id)), ); setSelection(new Set()); setConfirming(false); toast.add({ title: `Deleted ${removed.length} ${removed.length === 1 ? "deal" : "deals"}`, actionProps: { children: "Undo", onClick: () => setDeals((list) => [...list, ...removed]), }, }); }} /> </div> </AppShell> );}View tabs, the filter menu, sort and display menus live in apps/web/src/components/records/toolbar, not in packages/canon, so the composition draws a minimal view tab strip and plain buttons in their place. The board here is static; the product's board is Kanban, with drag and drop between stages.
Anatomy#
Seven parts stacked in one full-height column. Only the fifth scrolls.
- App header. Breadcrumbs carry the visible title;
headingrenders theh1as screen-reader text. Actions hold the layout switch and the view's one filled button. - View tabs. 28px Body Dense pills with counts in 12px Slate Meta. A Well Gray fill slides to the active view on a 0.3s spring with no bounce. The row scrolls and fades its clipped edge.
- Toolbar controls. Search, filter, sort and display at 28px on the right of the same Toolbar row. Below 640px they wrap to their own row and hide their labels.
- Filter chips. A row of active Filter chips, 28px with a 6% indigo tint and a 22% inset ring, then Clear all and Save as view.
- Grid or board. A Data grid with a sticky 32px header, 36px rows and a pinned first column, or a Kanban board of 60% Well Gray columns.
- Footer. Sticky, 36px, counts and totals in 12px tabular figures, rendered through the grid's
footerso it pins to the bottom of the scroll region. - Action bar. Raised by selection, bottom center. The count, ghost actions, a destructive tint for Delete, and a clear button. See Action bar.
Measurements#
From DESIGN.md's Layout and Data grid sections.
| Part | Value | Classes |
|---|---|---|
| Column | 100svh | relative flex h-svh min-h-0 flex-col |
| App header | 48px | h-12 |
| Toolbar row | 44px minimum, 28px controls | min-h-11 px-3 py-2, h-7 |
| View tab | 28px, 8px corners | h-7 rounded-md px-2 text-13 |
| Grid header | 32px, sticky | Data grid |
| Grid rows | 36px default; 32px compact, 44px comfortable | density |
| Cell padding | 8px | Data grid |
| Footer | 36px | sticky bottom-0 h-9 text-xs tabular-nums |
| Board padding and gap | 12px | p-3, gap-3 |
| Kanban column | 60% Well Gray, 12px corners | rounded-xl bg-muted/60 |
| Action bar | 20px from the bottom | absolute bottom-5, floating bar shadow |
Responsive behavior#
A list page keeps its shape at every width. The toolbar wraps and the grid scrolls sideways; nothing turns into cards.
From 1024px most columns fit. The grid is still the only thing that scrolls; the header, toolbar and footer never move.
| Width | What changes |
|---|---|
| 390px | The toolbar's controls wrap to a full-width row and show icons only (max-sm:sr-only on labels). The grid scrolls sideways under the pinned first column, which casts its edge shadow once scrolled. |
| 768px | One toolbar row with labels. The sidebar becomes a rail. |
| 1024px | Most columns fit; hidden columns come back through Display. |
| 1440px | Every column fits. The tool still fills the viewport and never centers in 72rem. |
States#
The grid owns loading, empty, no results and error. The header, toolbar and footer stay usable through all of them.
| Region | States it owns |
|---|---|
| View tabs | Active, with a count per view. Switching views clears the selection. |
| Toolbar | Active filters show as chips; an active filter button takes the indigo tint and ring. |
| Grid | Loading as a SkeletonTable, empty with the first action, no results with Clear filters, error with Retry. Rows have hover, selected (6% indigo) and active states. |
| Footer | Counts and totals follow the filtered rows, not the whole table. |
| Action bar | Hidden at zero, entering on a spring from 12px below. Destructive actions confirm and then offer Undo. |
The grid's states
Every list page wraps its grid in Data state so loading, empty and error are drawn in the grid's place. The footer stays.
Invoice | Supplier | Amount | |
|---|---|---|---|
INV-20931 | Northwind Freight | $12,480.00 | |
INV-20944 | Halcyon | $8,212.75 | |
INV-20957 | Orchard Street | $1,904.50 |
import { Button } from "@oration/canon/components/button";import { DataGrid, type DataGridColumn } from "@oration/canon/components/data-grid";import { EmptyState, ErrorState } from "@oration/canon/components/data-state";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { SkeletonReveal } from "@oration/canon/components/skeleton-reveal";import { SkeletonTable } from "@oration/canon/components/skeletons";import { SearchXIcon } from "lucide-react";import * as React from "react";export function GridStates() { type Invoice = { id: string; supplier: string; amount: string }; const [state, setState] = React.useState< "ready" | "loading" | "empty" | "no-results" | "error" >("ready"); const rows: Invoice[] = [ { id: "INV-20931", supplier: "Northwind Freight", amount: "$12,480.00", }, { id: "INV-20944", supplier: "Halcyon", amount: "$8,212.75" }, { id: "INV-20957", supplier: "Orchard Street", amount: "$1,904.50" }, ]; const columns: DataGridColumn<Invoice>[] = [ { id: "id", header: "Invoice", width: 140, cell: (row) => <span className="font-mono text-xs">{row.id}</span>, }, { id: "supplier", header: "Supplier", width: 220, cell: (row) => row.supplier, }, { id: "amount", header: "Amount", width: 140, align: "right", cell: (row) => <span className="tabular-nums">{row.amount}</span>, }, ]; return ( <div className="flex w-full flex-col items-center gap-4"> <SegmentedControl label="Grid state" value={state} onValueChange={setState} options={[ { value: "ready", label: "Ready" }, { value: "loading", label: "Loading" }, { value: "empty", label: "Empty" }, { value: "no-results", label: "No results" }, { value: "error", label: "Error" }, ]} /> <div className="flex h-72 w-full max-w-xl flex-col overflow-hidden rounded-xl bg-card shadow-border"> <SkeletonReveal loading={state === "loading"} label="Loading invoices" className="flex min-h-0 flex-1 flex-col" layerClassName="flex min-h-0 flex-1 flex-col" skeleton={<SkeletonTable rows={4} columns={3} />} > {state === "error" ? ( <ErrorState title="Couldn't load invoices" onRetry={() => setState("ready")} className="flex-1" /> ) : ( <DataGrid label="Invoices" className="flex-1" columns={columns} rows={state === "ready" ? rows : []} getRowId={(row) => row.id} empty={ state === "empty" ? ( <EmptyState illustration="inbox" title="No invoices yet" description="Invoices emailed to ap@cedarline.com show up here as they arrive." size="sm" /> ) : ( <EmptyState illustration="filters" title="No invoices match" description="Nothing over $50,000 is due this week." size="sm" action={ <Button variant="outline" size="sm" onClick={() => setState("ready") } > <SearchXIcon data-icon="inline-start" aria-hidden="true" /> Clear filters </Button> } /> ) } footer={ <div className="sticky bottom-0 left-0 z-10 flex h-9 items-center gap-6 border-t border-border bg-background px-3 text-xs text-muted-foreground tabular-nums"> <span> {state === "ready" ? 3 : 0} invoices </span> <span> Total{" "} <span className="font-medium text-foreground"> {state === "ready" ? "$22,597.25" : "$0.00"} </span> </span> </div> } /> )} </SkeletonReveal> </div> </div> );}Deals, companies and people read from the store synchronously and draw no loading or error state today. Tickets is the reference: DataState with useMockQuery, a list skeleton and errorTitle.
Starter code#
The deals view, trimmed to its frame. The toolbar menus are product components; swap in your own fields.
// components/deals/deals-view.tsxexport function DealsView() { return ( // relative: the action bar positions itself inside the column <div className="relative flex h-svh min-h-0 flex-col"> <AppHeader heading="Deals" crumbs={[{ label: "Deals", icon: <CircleDollarSignIcon className="size-3.5" /> }]} actions={ <> <SegmentedControl label="Layout" value={layout} onValueChange={setLayout} options={layouts} /> <Button size="sm"><PlusIcon data-icon="inline-start" />New deal</Button> </> } /> <ToolbarRow> <ViewTabs views={views} value={view} onValueChange={setView} label="Deal views" /> <div className="ml-auto flex items-center gap-1 max-sm:ml-0 max-sm:w-full"> <ToolbarSearch value={query} onChange={setQuery} placeholder="Search deals" /> <FilterMenu fields={filterFields} filters={filters} onFiltersChange={setFilters} /> <SortMenu options={sortOptions} sort={sort} onSortChange={setSort} /> <DisplayMenu columns={columns} density={density} onDensityChange={setDensity} /> </div> </ToolbarRow> {filters.length ? ( <div className="border-b border-border px-3 py-2"> <FilterChips fields={filterFields} filters={filters} onFiltersChange={setFilters} /> </div> ) : null} {/* The one region that scrolls */} <DataGrid label="Deals" className="flex-1" columns={columns} rows={rows} getRowId={(deal) => deal.id} selection={selection} onSelectionChange={setSelection} density={density} empty={<DealsEmpty onClear={clearFilters} />} footer={ <div className="sticky bottom-0 left-0 z-10 flex h-9 items-center gap-6 border-t border-border bg-background px-3 text-xs text-muted-foreground tabular-nums"> <span>{pluralize(rows.length, "deal")}</span> <span>Total <span className="font-medium text-foreground">{formatCurrency(total)}</span></span> </div> } /> <ActionBar count={selection.size} noun="deals" onClear={() => setSelection(new Set())}> <Button variant="ghost" size="sm">Change stage</Button> <Button variant="ghost" size="sm">Export</Button> <Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">Delete</Button> </ActionBar> </div> );}min-h-0on every flex child between the column and the grid, or the grid grows instead of scrolling.- Put the footer in the grid's
footerprop so it sticks to the bottom of the scroll region and scrolls sideways with nothing. - Clear the selection when the view, layout or filters change, so the action bar never acts on rows that aren't visible.
Rules that apply#
The Scroll Edge Rule
The Tabular Figures Rule
The One Filled Button Rule
The Label-Beside-Color Rule
Do and don't#
12
$966K
heading so the page still has one h1.