Table pages
A table-heavy page is the most repeated pattern in Nexus. It takes one of two shapes depending on what else is on the page: full-bleed when the table is the page’s entire content, or wrapped in a Card when it shares the page with other tables or content.
When to use
Section titled “When to use”Reach for this composition whenever a page’s main job is showing tabular data.
Pick the shape by what else is on the page, not by preference: a lone table
filling the page goes full-bleed; a table sharing the page with anything else
goes in a Card.
Full-bleed
Section titled “Full-bleed”A table that is the page’s only content skips PageContent entirely — see
Pages — so it runs edge-to-edge with no card
chrome around it. Pagination’s default spacing is already built for sitting
directly on the page background, so it needs no adjustment here — unlike the
Card-wrapped case below, which strips it.
AppLayoutMain is the scroll container — the whole page (PageHeader, the
table, Pagination) scrolls together as one unit below the fixed AppHeader
and Sidebar, not the Table on its own. Resize the preview below short enough
to overflow and scroll it to see the effect.
Code
import { useState } from "react";import { AppHeader, AppHeaderLeading, AppHeaderNotifications, AppHeaderTrailing, AppLayout, AppLayoutContent, AppLayoutMain, Badge, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, Button, Checkbox, DownloadIcon, FieldIcon, Filter, FilterApply, FilterButton, FilterChip, FilterChipRemove, FilterChips, FilterClearAll, FilterContent, FilterFooter, FilterHeader, FilterNav, FilterNavItem, FilterOption, FilterOptionList, FilterPane, FilterPaneHeader, FilterPaneTitle, InputGroup, InputGroupAddon, InputGroupInput, LayoutDashboardIcon, ListPlusIcon, PageHeader, PageHeaderActions, PageHeaderTitle, PageHeaderToolbar, Pagination, PaginationContent, PaginationFirst, PaginationItem, PaginationLabel, PaginationLast, PaginationNext, PaginationPrevious, RadioGroup, RadioGroupItem, SearchIcon, Sidebar, SidebarBrand, SidebarContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger, Table, TableBody, TableCell, TableHead, TableHeadSortable, TableHeader, TableRow, TagIcon, UsersIcon,} from "@falcon/ui-kit";
const accounts = [ { id: "adams-ranch", name: "Adams Ranch", location: "Abilene, KS", syngentaId: "502081", retailers: ["AgView", "ICI"], cropProtection: "341 gal", }, { id: "albrecht-ag", name: "Albrecht Ag Inc", location: "Fayette, MO", syngentaId: "1005", retailers: ["MFA"], cropProtection: "3.1 gal", }, { id: "albrecht-farming", name: "Albrecht Farming Co", location: "Clinton - Farm Elev & Sup Co Inc, MO", syngentaId: "1128", retailers: ["MFA"], cropProtection: "98 gal", }, { id: "albrecht-land", name: "Albrecht Land Co", location: "Piggott, MO", syngentaId: "1146", retailers: ["MFA"], cropProtection: "-", }, { id: "allen-farms", name: "Allen Farms", location: "Minot, ND", syngentaId: "502101", retailers: ["TriAg", "Mercer"], cropProtection: "56 gal", }, { id: "allen-fields", name: "Allen Fields", location: "Shelby, NC", syngentaId: "502175", retailers: ["CenDak", "CMN", "Frontier"], cropProtection: "374 gal", },];
type Column = "name" | "syngentaId" | "retailers" | "cropProtection";
function sortValue(account: (typeof accounts)[number], column: Column) { if (column === "retailers") return account.retailers[0]; return account[column];}
export function Example() { const [selected, setSelected] = useState<Set<string>>(new Set()); const [sort, setSort] = useState<{ column: Column; direction: "asc" | "desc"; } | null>(null); const [timeFilterOpen, setTimeFilterOpen] = useState(false); const [timeFilterDraft, setTimeFilterDraft] = useState("Current market year"); const [timeFilter, setTimeFilter] = useState<string | null>( "Current market year", );
const sorted = sort ? [...accounts].sort((a, b) => { const result = String(sortValue(a, sort.column)).localeCompare( String(sortValue(b, sort.column)), ); return sort.direction === "asc" ? result : -result; }) : accounts;
const allSelected = selected.size === accounts.length;
function toggleSort(column: Column) { setSort((current) => current?.column === column ? { column, direction: current.direction === "asc" ? "desc" : "asc" } : { column, direction: "asc" }, ); }
function sortedDirection(column: Column) { return sort?.column === column ? sort.direction : false; }
function toggleSelectAll(checked: boolean) { setSelected( checked ? new Set(accounts.map((account) => account.id)) : new Set(), ); }
function toggleSelectRow(id: string, checked: boolean) { setSelected((current) => { const next = new Set(current); if (checked) next.add(id); else next.delete(id); return next; }); }
return ( <SidebarProvider defaultOpen> <AppLayout> <Sidebar> <SidebarBrand logo={<FieldIcon />} name="AgVend" />
<SidebarContent> <SidebarMenu> <SidebarMenuItem> <SidebarMenuButton icon={<LayoutDashboardIcon size={16} />}> Dashboard </SidebarMenuButton> </SidebarMenuItem>
<SidebarMenuItem> <SidebarMenuButton icon={<UsersIcon size={16} />}> Accounts </SidebarMenuButton> </SidebarMenuItem> </SidebarMenu> </SidebarContent> </Sidebar>
<AppLayoutContent> <AppHeader> <AppHeaderLeading> <SidebarTrigger />
<Breadcrumb> <BreadcrumbList> <BreadcrumbItem> <BreadcrumbLink href="#accounts">Accounts</BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem> <BreadcrumbPage>All Accounts</BreadcrumbPage> </BreadcrumbItem> </BreadcrumbList> </Breadcrumb> </AppHeaderLeading>
<AppHeaderTrailing> <AppHeaderNotifications count={2} aria-label="Notifications" /> </AppHeaderTrailing> </AppHeader>
<AppLayoutMain> <PageHeader> <PageHeaderTitle> Accounts <Badge variant="secondary">356</Badge> </PageHeaderTitle>
<PageHeaderToolbar> <Filter open={timeFilterOpen} onOpenChange={(nextOpen) => { if (nextOpen) { setTimeFilterDraft(timeFilter ?? "Current market year"); } setTimeFilterOpen(nextOpen); }} > <FilterButton>Filter</FilterButton>
<FilterContent> <FilterHeader />
<FilterNav> <FilterNavItem active applied values={[timeFilterDraft]}> Time </FilterNavItem> </FilterNav>
<FilterPane> <FilterPaneHeader> <FilterPaneTitle>Time</FilterPaneTitle> </FilterPaneHeader>
<FilterOptionList> <RadioGroup value={timeFilterDraft} onValueChange={setTimeFilterDraft} > {["Current market year", "All time"].map((option) => ( <FilterOption key={option}> <RadioGroupItem value={option} /> {option} </FilterOption> ))} </RadioGroup> </FilterOptionList>
<FilterFooter> <FilterClearAll onClick={() => setTimeFilterDraft("Current market year") } /> <FilterApply onClick={() => { setTimeFilter(timeFilterDraft); setTimeFilterOpen(false); }} /> </FilterFooter> </FilterPane> </FilterContent> </Filter>
<PageHeaderActions> <InputGroup> <InputGroupAddon> <SearchIcon /> </InputGroupAddon> <InputGroupInput placeholder="Search" /> </InputGroup> </PageHeaderActions> </PageHeaderToolbar>
{timeFilter && ( <FilterChips> <FilterChip category="Time" values={[timeFilter]}> <FilterChipRemove aria-label="Remove time filter" onClick={() => setTimeFilter(null)} /> </FilterChip> </FilterChips> )} </PageHeader>
<div className="tw:sticky tw:top-0 tw:z-20 tw:flex tw:items-center tw:gap-3 tw:border-b tw:border-border tw:bg-background tw:px-5 tw:py-4"> <Checkbox aria-label="Select all accounts" checked={allSelected} onCheckedChange={toggleSelectAll} />
<span className="tw:text-sm tw:font-medium"> {accounts.length} accounts </span>
<div className="tw:ml-auto tw:flex tw:items-center tw:gap-2"> <Button> <ListPlusIcon /> Create lead </Button> <Button> <TagIcon /> Edit tags </Button> <Button variant="outline" size="icon" aria-label="Export accounts" > <DownloadIcon /> </Button> </div> </div>
<Table> <TableHeader> <TableRow> <TableHead className="tw:w-10"> <Checkbox aria-label="Select all accounts" checked={allSelected} onCheckedChange={toggleSelectAll} /> </TableHead> <TableHead sorted={sortedDirection("name")}> <TableHeadSortable sorted={sortedDirection("name")} onClick={() => toggleSort("name")} > Account </TableHeadSortable> </TableHead> <TableHead sorted={sortedDirection("syngentaId")}> <TableHeadSortable sorted={sortedDirection("syngentaId")} onClick={() => toggleSort("syngentaId")} > Syngenta ID </TableHeadSortable> </TableHead> <TableHead sorted={sortedDirection("retailers")}> <TableHeadSortable sorted={sortedDirection("retailers")} onClick={() => toggleSort("retailers")} > Retailer(s) </TableHeadSortable> </TableHead> <TableHead sorted={sortedDirection("cropProtection")}> <TableHeadSortable sorted={sortedDirection("cropProtection")} onClick={() => toggleSort("cropProtection")} > '26 Crop Protection </TableHeadSortable> </TableHead> <TableHead>Lead</TableHead> </TableRow> </TableHeader>
<TableBody> {sorted.map((account) => ( <TableRow key={account.id} data-state={ selected.has(account.id) ? "selected" : undefined } > <TableCell> <Checkbox aria-label={`Select ${account.name}`} checked={selected.has(account.id)} onCheckedChange={(checked) => toggleSelectRow(account.id, checked === true) } /> </TableCell> <TableCell> <div className="tw:font-medium">{account.name}</div> <div className="tw:text-xs tw:text-muted-foreground"> {account.location} </div> </TableCell> <TableCell>{account.syngentaId}</TableCell> <TableCell> <div className="tw:flex tw:flex-wrap tw:gap-1"> {account.retailers.map((retailer) => ( <Badge key={retailer} variant="secondary"> {retailer} </Badge> ))} </div> </TableCell> <TableCell>{account.cropProtection}</TableCell> <TableCell> <Button variant="outline" size="icon" aria-label={`Create lead for ${account.name}`} > <ListPlusIcon /> </Button> </TableCell> </TableRow> ))} </TableBody> </Table>
<div className="tw:border-t tw:border-border"> <Pagination> <PaginationLabel>Page 1 of 24</PaginationLabel>
<PaginationContent> <PaginationItem> <PaginationFirst disabled /> </PaginationItem>
<PaginationItem> <PaginationPrevious disabled /> </PaginationItem>
<PaginationItem> <PaginationNext /> </PaginationItem>
<PaginationItem> <PaginationLast /> </PaginationItem> </PaginationContent> </Pagination> </div> </AppLayoutMain> </AppLayoutContent> </AppLayout> </SidebarProvider> );}Card-wrapped tables
Section titled “Card-wrapped tables”When a table shares the page with other tables or content (e.g. a dashboard),
each table is wrapped in a Card inside PageContent instead. The card gives
each table a boundary the others don’t compete with.
Code
import { useState } from "react";import { AppHeader, AppHeaderLeading, AppLayout, AppLayoutContent, AppLayoutMain, Badge, Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, Building2Icon, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, FieldIcon, Filter, FilterApply, FilterButton, FilterContent, FilterFooter, FilterHeader, FilterNav, FilterNavItem, FilterOptionList, FilterOption, FilterPane, FilterPaneHeader, FilterPaneTitle, LayoutDashboardIcon, ListPlusIcon, PageContent, PageHeader, PageHeaderTitle, Pagination, PaginationContent, PaginationFirst, PaginationItem, PaginationLabel, PaginationLast, PaginationNext, PaginationPrevious, RadioGroup, RadioGroupItem, Sidebar, SidebarBrand, SidebarContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger, StarIcon, Table, TableBody, TableCell, TableHead, TableHeadSortable, TableHeader, TableRow,} from "@falcon/ui-kit";
const retailerPositions = [ { retailer: "Endigo ZCX", applied: "1 gal", committed: "0 gal", forecasted: "0 gal", contacts: 4, }, { retailer: "Storen", applied: "0 gal", committed: "0 gal", forecasted: "0 gal", contacts: 0, }, { retailer: "Opello", applied: "0 gal", committed: "0 gal", forecasted: "0 gal", contacts: 0, }, { retailer: "Tendovo", applied: "0 gal", committed: "190 gal", forecasted: "0 gal", contacts: 1, },];
const opportunities = [ { account: "Henkel Farming Co", priority: "Top" as const, opportunity: "~139 gal", location: "Gallatin, MO", hasLead: true, }, { account: "Hahn Ag", priority: "Top" as const, opportunity: "~132 gal", location: "Wheaton, MO", hasLead: true, }, { account: "Hoffman Farms", priority: "Top" as const, opportunity: "~125 gal", location: "Windsor, MO", hasLead: false, }, { account: "Steiner Farming Co", priority: "Top" as const, opportunity: "~122 gal", location: "Golden City - Produce Exchange #299, MO", hasLead: false, }, { account: "Stark Farming Co", priority: "Top" as const, opportunity: "~188 gal", location: "Foristell - Coop Association #2, MO", hasLead: false, }, { account: "Reimer Farm", priority: "High" as const, opportunity: "~162 gal", location: "Alma, MO", hasLead: false, },];
export function Example() { const [filterOpen, setFilterOpen] = useState(false); const [priorityDraft, setPriorityDraft] = useState("Any priority"); const [priority, setPriority] = useState("Any priority"); const [sort, setSort] = useState<"asc" | "desc" | null>(null);
const sortedOpportunities = sort ? [...opportunities].sort((a, b) => { const result = a.account.localeCompare(b.account); return sort === "asc" ? result : -result; }) : opportunities;
function toggleSort() { setSort((current) => (current === "asc" ? "desc" : "asc")); }
return ( <SidebarProvider defaultOpen> <AppLayout> <Sidebar> <SidebarBrand logo={<FieldIcon />} name="AgVend" />
<SidebarContent> <SidebarMenu> <SidebarMenuItem> <SidebarMenuButton icon={<LayoutDashboardIcon size={16} />}> Dashboard </SidebarMenuButton> </SidebarMenuItem>
<SidebarMenuItem> <SidebarMenuButton icon={<Building2Icon size={16} />}> Retailers </SidebarMenuButton> </SidebarMenuItem> </SidebarMenu> </SidebarContent> </Sidebar>
<AppLayoutContent> <AppHeader> <AppHeaderLeading> <SidebarTrigger />
<Breadcrumb> <BreadcrumbList> <BreadcrumbItem> <BreadcrumbPage>Retailer Dashboard</BreadcrumbPage> </BreadcrumbItem> </BreadcrumbList> </Breadcrumb> </AppHeaderLeading> </AppHeader>
<AppLayoutMain> <PageHeader> <PageHeaderTitle>Retailer Dashboard</PageHeaderTitle> </PageHeader>
<PageContent> <Card> <CardHeader> <CardTitle>Retailer positions</CardTitle> </CardHeader>
<CardContent className="tw:px-0"> <Table> <TableHeader> <TableRow> <TableHead>Retailer</TableHead> <TableHead>Applied</TableHead> <TableHead>Committed</TableHead> <TableHead>Forecasted</TableHead> <TableHead>Status</TableHead> <TableHead>Contacts</TableHead> </TableRow> </TableHeader>
<TableBody> {retailerPositions.map((position) => ( <TableRow key={position.retailer}> <TableCell className="tw:font-medium"> {position.retailer} </TableCell> <TableCell>{position.applied}</TableCell> <TableCell>{position.committed}</TableCell> <TableCell>{position.forecasted}</TableCell> <TableCell> <Badge variant="destructive">At Risk</Badge> </TableCell> <TableCell>{position.contacts}</TableCell> </TableRow> ))} </TableBody> </Table> </CardContent> </Card>
<Card> <CardHeader> <CardTitle>'26 Fungicide Opportunity</CardTitle> <CardDescription>Last updated on 04/09/2026</CardDescription>
<CardAction> <Filter open={filterOpen} onOpenChange={(nextOpen) => { if (nextOpen) setPriorityDraft(priority); setFilterOpen(nextOpen); }} > <FilterButton>Filter</FilterButton>
<FilterContent> <FilterHeader />
<FilterNav> <FilterNavItem active applied={priorityDraft !== "Any priority"} values={ priorityDraft !== "Any priority" ? [priorityDraft] : [] } > Priority </FilterNavItem> </FilterNav>
<FilterPane> <FilterPaneHeader> <FilterPaneTitle>Priority</FilterPaneTitle> </FilterPaneHeader>
<FilterOptionList> <RadioGroup value={priorityDraft} onValueChange={setPriorityDraft} > {["Any priority", "Top", "High"].map((option) => ( <FilterOption key={option}> <RadioGroupItem value={option} /> {option} </FilterOption> ))} </RadioGroup> </FilterOptionList>
<FilterFooter> <FilterApply onClick={() => { setPriority(priorityDraft); setFilterOpen(false); }} /> </FilterFooter> </FilterPane> </FilterContent> </Filter> </CardAction> </CardHeader>
<CardContent className="tw:px-0"> <Table> <TableHeader> <TableRow> <TableHead sorted={sort ?? false}> <TableHeadSortable sorted={sort ?? false} onClick={toggleSort} > Account </TableHeadSortable> </TableHead> <TableHead>Priority</TableHead> <TableHead>'26 Fungicide Opportunity</TableHead> <TableHead>Location</TableHead> <TableHead>Lead</TableHead> </TableRow> </TableHeader>
<TableBody> {sortedOpportunities .filter( (row) => priority === "Any priority" || row.priority === priority, ) .map((row) => ( <TableRow key={row.account}> <TableCell className="tw:font-medium"> {row.account} </TableCell> <TableCell> <Badge variant={ row.priority === "Top" ? "secondary" : "destructive" } > <StarIcon /> {row.priority} </Badge> </TableCell> <TableCell>{row.opportunity}</TableCell> <TableCell>{row.location}</TableCell> <TableCell> {row.hasLead ? ( <Badge variant="secondary">Yes</Badge> ) : ( <Button variant="outline" size="icon" aria-label={`Create lead for ${row.account}`} > <ListPlusIcon /> </Button> )} </TableCell> </TableRow> ))} </TableBody> </Table> </CardContent>
<CardFooter> <Pagination className="tw:m-0 tw:w-full tw:p-0"> <PaginationLabel>Page 1 of 6</PaginationLabel>
<PaginationContent> <PaginationItem> <PaginationFirst disabled /> </PaginationItem>
<PaginationItem> <PaginationPrevious disabled /> </PaginationItem>
<PaginationItem> <PaginationNext /> </PaginationItem>
<PaginationItem> <PaginationLast /> </PaginationItem> </PaginationContent> </Pagination> </CardFooter> </Card> </PageContent> </AppLayoutMain> </AppLayoutContent> </AppLayout> </SidebarProvider> );}Layout rules
Section titled “Layout rules”Don’t mix the two shapes on one page — a bare full-bleed table next to a
Card-wrapped one leaves the bare one looking unfinished rather than
intentional.
In a Card, CardContent drops its horizontal padding (tw:px-0) so the
table’s row dividers reach the card’s edges, matching the card’s own border;
CardHeader and CardFooter keep their default padding. CardHeader and its
CardTitle name that card’s table — necessary once more than one table shares
the page, since nothing else distinguishes them. CardFooter is optional too —
add it, holding a Pagination, only when that table’s data paginates; strip
Pagination’s own spacing with tw:m-0 tw:w-full tw:p-0 so it doesn’t double
up on the footer’s own padding.
A CardAction beside the title holds actions scoped to that one table — a view
toggle, an export button, a per-table filter. Actions that affect the whole page
belong in PageHeaderActions instead, per
Pages.
States
Section titled “States”A full-bleed table’s empty state replaces the Table with
Empty, and its loading state replaces it with
Spinner or a skeleton table, both still direct children
of AppLayoutMain. A Card-wrapped table does the same inside that card’s
CardContent, leaving the Card, its CardHeader, and its CardFooter in
place so the page’s shape doesn’t shift while data loads.
Do / Don’t
Section titled “Do / Don’t”Do go full-bleed — skip PageContent and Card — when the table is the page’s
only content. Don’t wrap a lone full-page table in a Card; it adds a border
and corner radius with nothing else on the page for them to separate it from.
Do wrap each table in a Card as soon as it shares the page with another table
or content block. Don’t reuse a full-bleed table’s PageHeaderTitle as a
redundant CardTitle — full-bleed tables don’t have one.