Skip to content

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.

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.

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")}
>
&apos;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>
);
}

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>&apos;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>&apos;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>
);
}

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.

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 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.