| Project | Status |
|---|---|
| Website | In progress |
| Mobile app | Planned |
"use client";
import { Table } from "@vortexlabs/vortex";
<Table.Root ariaLabel="Project status">
<Table.Header>
<Table.Row>
<Table.HeaderCell>Project</Table.HeaderCell>
<Table.HeaderCell>Status</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell rowHeader>Website</Table.Cell>
<Table.Cell>In progress</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell rowHeader>Mobile app</Table.Cell>
<Table.Cell>Planned</Table.Cell>
</Table.Row>
</Table.Body>
</Table.Root>Features
- Displays records with native table semantics.
- Supports controlled sorting and row selection.
- Pins the first column and checkbox column during horizontal scrolling.
- Supports responsive row sizes, wrapped text, and truncated values.
- Shows loading rows, empty states, and a custom footer.
Anatomy
<Table.Root ariaLabel="Team members">
<Table.Header>
<Table.Row>
<Table.SelectionCell ariaLabel="Select all members" checked={false} />
<Table.HeaderCell>Name</Table.HeaderCell>
<Table.HeaderCell>Role</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.SelectionCell ariaLabel="Select Alex Chen" checked={false} />
<Table.Cell rowHeader>Alex Chen</Table.Cell>
<Table.Cell>Designer</Table.Cell>
</Table.Row>
{/* With no records */}
<Table.Empty>No members yet.</Table.Empty>
</Table.Body>
</Table.Root>Sizes
size sets row height and cell padding and accepts responsive values. The default is sm.
| Invoice | Amount |
|---|---|
| INV-001 | $120.00 |
<Table.Root ariaLabel="Invoices" size={{ xs: "sm", md: "md" }}>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Invoice</Table.HeaderCell>
<Table.HeaderCell align="end">Amount</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell rowHeader>INV-001</Table.Cell>
<Table.Cell align="end">$120.00</Table.Cell>
</Table.Row>
</Table.Body>
</Table.Root>Sorting and Selection
Table doesn't sort or select data. Reorder records using the direction passed to onSort, and store selected row IDs so selection survives sorting and paging.
| Role | ||
|---|---|---|
| Alex Chen | Designer | |
| Mia Rossi | Engineer | |
| Jordan Park | Product manager |
"use client";
import { Table, type TableSortDirection } from "@vortexlabs/vortex";
import { type ReactElement, useState } from "react";
const members = [
{ id: "alex", name: "Alex Chen", role: "Designer" },
{ id: "mia", name: "Mia Rossi", role: "Engineer" },
{ id: "jordan", name: "Jordan Park", role: "Product manager" },
];
export const TableSelectionExample = (): ReactElement => {
const [direction, setDirection] = useState<TableSortDirection | false>(false);
const [selected, setSelected] = useState<string[]>([]);
const selectedIds = new Set(selected);
const rows = [...members].sort((a, b) =>
direction
? a.name.localeCompare(b.name) * (direction === "asc" ? 1 : -1)
: 0,
);
return (
<Table.Root ariaLabel="Team members">
<Table.Header>
<Table.Row>
<Table.SelectionCell
ariaLabel="Select all members"
checked={selected.length === members.length}
indeterminate={
selected.length > 0 && selected.length < members.length
}
onChange={({ checked }): void =>
setSelected(checked ? members.map((member) => member.id) : [])
}
/>
<Table.HeaderCell
sortDirection={direction}
onSort={({ direction }): void => setDirection(direction)}
>
Name
</Table.HeaderCell>
<Table.HeaderCell>Role</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{rows.map((member) => (
<Table.Row key={member.id} selected={selectedIds.has(member.id)}>
<Table.SelectionCell
ariaLabel={`Select ${member.name}`}
checked={selectedIds.has(member.id)}
onChange={({ checked }): void =>
setSelected((current) =>
checked
? [...current, member.id]
: current.filter((id) => id !== member.id),
)
}
/>
<Table.Cell rowHeader>{member.name}</Table.Cell>
<Table.Cell>{member.role}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
);
};Text Overflow and Scrolling
truncate shows overflowing values in a tooltip and needs width on the column header. wrap lets text span lines. Set a container height to scroll rows vertically.
| Project | Contact | Notes |
|---|---|---|
| Website | alex.chen@long-company-name.example.com | Review the updated designs before the next release. |
| Mobile app | jordan.park@long-company-name.example.com | Confirm the onboarding copy with the product team. |
<Stack width="100%" maxWidth="32rem" height="10rem">
<Table.Root ariaLabel="Project contacts" attributes={{ style: { height: "100%" } }}>
<Table.Header>
<Table.Row>
<Table.HeaderCell width="10rem">Project</Table.HeaderCell>
<Table.HeaderCell width="12rem">Contact</Table.HeaderCell>
<Table.HeaderCell>Notes</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell rowHeader>Website</Table.Cell>
<Table.Cell truncate>alex.chen@long-company-name.example.com</Table.Cell>
<Table.Cell wrap>Review the updated designs before the next release.</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell rowHeader>Mobile app</Table.Cell>
<Table.Cell truncate>jordan.park@long-company-name.example.com</Table.Cell>
<Table.Cell wrap>Confirm the onboarding copy with the product team.</Table.Cell>
</Table.Row>
</Table.Body>
</Table.Root>
</Stack>Loading and Footer
loading replaces body rows with skeletons and hides the footer. footer holds summaries or pagination controls.
| Name | Role |
|---|---|
<Table.Root ariaLabel="Team members" loading loadingRows={3} footer={<Text>3 members</Text>}>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Name</Table.HeaderCell>
<Table.HeaderCell>Role</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body />
</Table.Root>Empty State
Table.Empty shows a message across all columns. Place it in Table.Body when there are no records.
| Name | Role |
|---|---|
No results Try a different search or clear the filters. |
<Table.Root ariaLabel="Search results">
<Table.Header>
<Table.Row>
<Table.HeaderCell>Name</Table.HeaderCell>
<Table.HeaderCell>Role</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Empty>
<Icon src={Search} size={6} color="subtle" />
<Stack gap={1}>
<Text size="body-sm-desktop" weight="medium">No results</Text>
<Text size="body-sm-desktop" color="subtle">Try a different search or clear the filters.</Text>
</Stack>
<Button variant="secondary" size="sm">Clear search</Button>
</Table.Empty>
</Table.Body>
</Table.Root>Use the TanStack Table integration for column definitions, sorting, selection, and pagination managed together.
Accessibility
ariaLabel or ariaLabelledby.rowHeader on the cell that names each record.hideLabel instead of an empty heading for action columns.