Data table
Shows records in a table that sorts, selects, runs row actions and loads more. Formats typed columns: currency, percent, dates, links, addresses.
| Actions | |||||
|---|---|---|---|---|---|
Nov 14, 2026 | Value Proposition | $140,000.00 | |||
Oct 30, 2026 | Closed Won | $235,000.00 | |||
Dec 5, 2026 | Qualification | $15,000.00 | |||
Jan 18, 2027 | Id. Decision Makers | $75,000.00 | |||
Nov 2, 2026 | Prospecting | $60,000.00 |
Installation
The CLI installs the Radix or Base UI file to match your project, with what it builds on: button, checkbox, dropdown-menu, output-field, skeleton, spinner, table.
If your project doesn't have Wisty UI's theme yet, add it once with npx shadcn@latest add @wisty/cosmos. Until then, components keep shadcn's default look. Installation has the details.
When to use
Use it
- People read, sort and act on a list of records, like a list view of opportunities or a related list of contacts.
- People select rows for a bulk action, like deleting or reassigning several records, or each row has its own actions, like Edit and Delete.
- Values are Salesforce field types: currency, percent, numbers, dates, date-times, times of day, relative times, URLs, emails, phone numbers, checkboxes, addresses and names. Set
meta.typeand the table formats, aligns and sorts them. - A column needs its own format, like a currency's code, a precise percent, a short date or a URL shown as a label in a new tab, as
lightning-datatable'stypeAttributesset it. - The records come in pages, like a GraphQL query with a cursor. The table shows Load more, or loads on scroll.
- The list runs to hundreds or thousands of rows. Give the table a height and pass
virtualize. - It's the table
lightning-datatabledraws: Salesforce's column types, resizable columns, row actions, loading more and virtualization.
Don't use it
- The data is a few read-only rows that don't sort or select, like a summary in a card. Use Table, which is plain markup.
- People edit cells in place or move between cells with the arrow keys. The data table doesn't do either yet. Inline edit and grid keyboard navigation are planned.
- People filter, hide, pin or group columns. Put filters in your own toolbar above the table and filter
datayourself. The table has no column visibility, pinning or grouping. - People page through numbered pages. Salesforce tables load more instead of paging, so there are no page controls. If you need them, put Pagination below the table and pass one page as
data. - The rows are a tree. The data table has no nested rows.
Usage
The page owns the table. Call useDataTable in a client component, with columns built by createDataTableColumnHelper, and render <DataTable table={table} />. The page reads the selection and handles the actions from the same table, so its own buttons sit beside the table, as the Selection and Row actions examples show.
"use client"
import {
createDataTableColumnHelper,
DataTable,
useDataTable,
} from "@/components/wisty/data-table"type Opportunity = { id: string; name: string; amount: number | null }
// Outside the component, so the columns keep their identity.
const col = createDataTableColumnHelper<Opportunity>()
const columns = col.columns([
col.accessor("name", {
header: "Opportunity name",
meta: { rowHeader: true },
}),
col.accessor("amount", { header: "Amount", meta: { type: "currency" } }),
])
export function OpportunitiesTable({
opportunities,
}: {
opportunities: Opportunity[]
}) {
const table = useDataTable({
columns,
data: opportunities,
getRowId: (row) => row.id,
})
return <DataTable table={table} aria-label="Opportunities" />
}Columns are TanStack Table column definitions; meta takes the data table's own keys, listed in the API reference. Sorting, selection and column widths are TanStack state: pass initialState to start sorted or sized, or state with its change callback to control them (the Sorting and Resizing examples). A callback alone works too: the table keeps the state and calls you after each change.
Salesforce's GraphQL API returns fields as { value, displayValue }. Read .value with a meta.type, so the table formats and sorts the raw value, as an accessor function with an id:
col.accessor((row) => row.Amount.value, {
id: "Amount",
header: "Amount",
meta: { type: "currency", currencyCode: "EUR" },
})A column's meta also takes Output field's format and link keys, so a column reads as a record's field of the same type. The cell's title and the sort keep the value:
col.accessor("website", {
header: "Website",
meta: { type: "url", linkLabel: "Visit site", linkTarget: "_blank" },
})A label that differs by row, like each account's name on its website, is a custom cell that draws OutputFieldCompactValue from @/components/wisty/output-field:
col.accessor("website", {
header: "Website",
meta: { type: "url" },
cell: ({ row, getValue }) => (
<OutputFieldCompactValue
type="url"
value={getValue()}
linkLabel={row.original.name}
linkTarget="_blank"
/>
),
})showRowNumberColumn numbers the rows before the checkboxes, as list views do. The numbers count the rows shown, so sorting or filtering data renumbers them from 1. When data is one page of a longer list, like the third page of ten, pass the rows before it as rowNumberOffset and the first row reads 21.
To sort on the server, pass manualSorting and an onSortingChange that fetches; the state carries each column's id, like [{ id: "Amount", desc: true }]. To add your own meta keys, merge into DataTableColumnMeta with declare module "@/components/wisty/data-table".
Examples
Sorting
Sorted by amount, highest first, with `initialState.sorting` and the empty amount last, and a click on a header sorts by it.
Burlington Textiles Weaving Plant Generator | Burlington Textiles Corp of America | Oct 30, 2026 | $235,000.00 |
|---|---|---|---|
Acme - 1,200 Widgets | Acme | Nov 14, 2026 | $140,000.00 |
Edge Emergency Generator | Edge Communications | Jan 18, 2027 | $75,000.00 |
GenePoint Lab Generators | GenePoint | Nov 2, 2026 | $60,000.00 |
Dickenson Mobile Generators | Dickenson plc | Dec 5, 2026 |
Selection
Shift ranges and select all, capped at three rows by `maxRowSelection`, closed opportunities disabled by `enableRowSelection`, and a Delete button.
Acme - 1,200 Widgets | Value Proposition | $140,000.00 | |
|---|---|---|---|
Burlington Textiles Weaving Plant Generator | Closed Won | $235,000.00 | |
Dickenson Mobile Generators | Qualification | $15,000.00 | |
Edge Emergency Generator | Id. Decision Makers | $75,000.00 | |
GenePoint Lab Generators | Prospecting | $60,000.00 |
Row numbers
A number before each row's checkbox from `showRowNumberColumn`, counting rows in the order shown, so sorting renumbers them.
| Row Number | |||||
|---|---|---|---|---|---|
| 1 | Burlington Textiles Weaving Plant Generator | Burlington Textiles Corp of America | Oct 30, 2026 | $235,000.00 | |
| 2 | Dickenson Mobile Generators | Dickenson plc | Dec 5, 2026 | $15,000.00 | |
| 3 | Edge Emergency Generator | Edge Communications | Jan 18, 2027 | $75,000.00 | |
| 4 | Express Logistics Standby Generator | Express Logistics and Transport | Nov 21, 2026 | $220,000.00 | |
| 5 | GenePoint Lab Generators | GenePoint | Nov 2, 2026 | $60,000.00 | |
| 6 | Grand Hotels Kitchen Generator | Grand Hotels & Resorts Ltd | Dec 12, 2026 | $15,000.00 | |
| 7 | Pyramid Emergency Generators | Pyramid Construction Inc. | Feb 3, 2027 | $100,000.00 | |
| 8 | United Oil Installations | United Oil & Gas Corp. | Nov 9, 2026 | $270,000.00 | |
| 9 | University of AZ Portable Generators | University of Arizona | Jan 7, 2027 | $50,000.00 | |
| 10 | sForce SLA | sForce | Dec 19, 2026 | $120,000.00 |
Single selection
Radios for picking one contact, with no select all, set with `maxRowSelection: 1`.
| Choose a row to select | |||
|---|---|---|---|
Rose Gonzalez | SVP, Procurement | ||
Sean Forbes | CFO | ||
Jack Rogers | VP, Facilities | ||
Pat Stumuller | SVP, Administration and Finance |
Row actions
A `rowActions` function builds each row's menu when it opens, with Delete disabled on closed opportunities, and `onRowAction` handles the choice.
| Actions | |||
|---|---|---|---|
Acme - 1,200 Widgets | Value Proposition | $140,000.00 | |
Burlington Textiles Weaving Plant Generator | Closed Won | $235,000.00 | |
Dickenson Mobile Generators | Qualification | $15,000.00 | |
Edge Emergency Generator | Id. Decision Makers | $75,000.00 |
Column types
Every `meta.type`, time included, with an amount's code from `currencyDisplay`, a short `dateStyle` and a URL as its `linkLabel` in a new tab.
Acme - 1,200 Widgets | USD 140,000.00 | 60% | 1,200 | 11/14/26 | Sep 2, 2026, 10:10 AM | , Oct 13, 2026, 8:00 AM |
|---|---|---|---|---|---|---|
Edge Emergency Generator | USD 75,000.50 | 25% | 1/18/27 | Aug 21, 2026, 9:30 AM | , Oct 12, 2026, 10:00 AM |
Rose Gonzalez | 9:30 AM | 312 Constitution Place, Austin, TX 78767, USA | True | |||
|---|---|---|---|---|---|---|
Jack Rogers | 2:00 PM | 525 S. Lexington Ave, Burlington, NC 27215, USA |
Wrap and clip
Long text clipped with a title, or wrapped to three lines with `meta.wrapText`, switched from header menus built with `meta.headerActions`.
Burlington Textiles Corp of America | Textile maker with plants in North Carolina and Georgia. Replacing its backup power across three weaving plants before the winter peak. | Send the revised quote for the third plant and confirm the install window with facilities. |
|---|---|---|
Edge Communications | Regional carrier. Standby generators for two exchanges. | Site survey on Thursday. |
United Oil & Gas Corp. | Refinery upgrade with a long approval chain.
Procurement reviews every vendor each quarter. | Wait for procurement's review, then schedule a call with the plant manager and the CFO. |
Resizing
Resizing within `minSize` and `maxSize`, an amount fixed by `enableResizing: false`, and widths the page keeps in `state.columnSizing` and resets.
Acme | Value Proposition | $140,000.00 | |
Burlington Textiles Corp of America | Closed Won | $235,000.00 | |
Dickenson plc | Qualification | $15,000.00 | |
Edge Communications | Id. Decision Makers | $75,000.00 | |
GenePoint | Prospecting | $60,000.00 |
Loading and empty
Placeholder rows from `isLoading` while the first page loads, and the page's own words in `empty` when nothing matches.
| No opportunities match your search. | |||
Load more
Five opportunities and a Load more button for the next five, from `hasNextPage` and `onLoadMore`, keeping the selection.
Acme Generators 1 | Prospecting | $10,000.00 | |
|---|---|---|---|
Burlington Textiles Generators 2 | Qualification | $47,000.00 | |
Dickenson Generators 3 | Proposal | $84,000.00 | |
Edge Communications Generators 4 | Negotiation | $121,000.00 | |
Express Logistics Generators 5 | Prospecting | $158,000.00 |
Load on scroll
A table with a height that scrolls under its header and, with `loadMoreOnScroll`, loads the next page as its last rows come into view.
Acme Generators 1 | Prospecting | $10,000.00 | |
|---|---|---|---|
Burlington Textiles Generators 2 | Qualification | $47,000.00 | |
Dickenson Generators 3 | Proposal | $84,000.00 | |
Edge Communications Generators 4 | Negotiation | $121,000.00 | |
Express Logistics Generators 5 | Prospecting | $158,000.00 | |
GenePoint Generators 6 | Qualification | $195,000.00 | |
Grand Hotels Generators 7 | Proposal | $232,000.00 | |
Pyramid Construction Generators 8 | Negotiation | $19,000.00 | |
Acme Generators 9 | Prospecting | $56,000.00 | |
Burlington Textiles Generators 10 | Qualification | $93,000.00 | |
Dickenson Generators 11 | Proposal | $130,000.00 | |
Edge Communications Generators 12 | Negotiation | $167,000.00 | |
Express Logistics Generators 13 | Prospecting | $204,000.00 | |
GenePoint Generators 14 | Qualification | $241,000.00 | |
Grand Hotels Generators 15 | Proposal | $28,000.00 | |
Pyramid Construction Generators 16 | Negotiation | $65,000.00 | |
Acme Generators 17 | Prospecting | $102,000.00 | |
Burlington Textiles Generators 18 | Qualification | $139,000.00 | |
Dickenson Generators 19 | Proposal | $176,000.00 | |
Edge Communications Generators 20 | Negotiation | $213,000.00 |
Virtualized
10,000 rows with selection and row menus, rendering only the rows in view with `virtualize`.
| Actions | ||||
|---|---|---|---|---|
$5,000.00 | Backup power for Acme: three sites, installed in phases over two quarters, with a service plan for each. | |||
$12,919.00 | Standby generator. | |||
$20,838.00 | Standby generator. | |||
$28,757.00 | Standby generator. | |||
$36,676.00 | Standby generator. | |||
$44,595.00 | Backup power for GenePoint: three sites, installed in phases over two quarters, with a service plan for each. | |||
$52,514.00 | Standby generator. | |||
$60,433.00 | Standby generator. | |||
$68,352.00 | Standby generator. | |||
$76,271.00 | Standby generator. | |||
$84,190.00 | Backup power for Acme: three sites, installed in phases over two quarters, with a service plan for each. | |||
$92,109.00 | Standby generator. | |||
$100,028.00 | Standby generator. | |||
$107,947.00 | Standby generator. | |||
$115,866.00 | Standby generator. | |||
$123,785.00 | Backup power for GenePoint: three sites, installed in phases over two quarters, with a service plan for each. | |||
$131,704.00 | Standby generator. | |||
$139,623.00 | Standby generator. | |||
$147,542.00 | Standby generator. | |||
$155,461.00 | Standby generator. | |||
$163,380.00 | Backup power for Acme: three sites, installed in phases over two quarters, with a service plan for each. | |||
$171,299.00 | Standby generator. | |||
$179,218.00 | Standby generator. | |||
$187,137.00 | Standby generator. | |||
$195,056.00 | Standby generator. | |||
$202,975.00 | Backup power for GenePoint: three sites, installed in phases over two quarters, with a service plan for each. | |||
$210,894.00 | Standby generator. | |||
$218,813.00 | Standby generator. | |||
$226,732.00 | Standby generator. | |||
$234,651.00 | Standby generator. |
API reference
DataTable
| Prop | Type | Default | Description |
|---|---|---|---|
table* | DataTableInstance<TData, any> | The table from useDataTable. | |
isLoading | boolean | false | A first load: placeholder rows while there are none. |
hasNextPage | boolean | false | More rows can load: shows Load more, or loads on scroll. |
isFetchingNextPage | boolean | false | A next page is loading. |
onLoadMore | () => void | Loads the next page. The page appends its rows to data. | |
loadMoreOnScroll | boolean | false | Loads the next page on scroll instead of with the button, which stays as a retry. |
virtualize | boolean | { overscan?: number; estimateRowHeight?: number } | false | Renders only the rows in view. Needs a height on DataTable. |
empty | React.ReactNode | What the table shows when it has no rows. |
Also takes every <table> (opens in a new tab) prop.
useDataTablehook
| Parameter | Type | Default |
|---|---|---|
options* | UseDataTableOptions<TData> | |
selector | (state: TableState<Features>) => TSelected |
Returns DataTableInstance<TData, TSelected>.
createDataTableColumnHelperfunction
Returns DataTableColumnHelper<TData>.
DataTableColumnTypetype
type DataTableColumnType = OutputFieldTypeDataTableRowActiontype
An item in a row's actions menu.
| Member | Type | Default | Description |
|---|---|---|---|
value* | string | Identifies the action in onRowAction: "edit", "delete". | |
label* | string | The item's text. | |
disabled | boolean | Shows the item but doesn't let it run. |
DataTableHeaderActiontype
An item in a column header's actions menu.
| Member | Type | Default | Description |
|---|---|---|---|
value* | string | Identifies the action in onHeaderAction: "wrap", "clip". | |
label* | string | The item's text. | |
disabled | boolean | Shows the item but doesn't let it run. | |
checked | boolean | Shows a check before the label when true, and room for one when false. |
DataTableColumnMetatype
What a column's meta takes. To add your own keys, edit this interface, or merge into it with declare module "@/components/wisty/data-table".
| Member | Type | Default | Description |
|---|---|---|---|
type | DataTableColumnType | How values format, align and sort. Defaults to "text". | |
currencyCode | string | ISO 4217 code for "currency" columns. Defaults to "USD". | |
label | string | The column's name in menus and announcements when header is a function. | |
rowHeader | boolean | Renders cells as row headers (<th scope="row">), which name each row. | |
wrapText | boolean | Wraps long values instead of clipping them. | |
wrapTextMaxLines | number | With wrapText, the lines a cell shows before it clips. | |
align | "start" | "center" | "end" | Overrides the type's alignment. Numbers align to the end. | |
headerClassName | string | Classes on the column's header cell. | |
cellClassName | string | Classes on the column's body cells. | |
headerActions | readonly DataTableHeaderAction[] | An actions menu in the column header, handled by onHeaderAction. | |
fractionDigits | number | Digits after the decimal point, a field's scale, as both the fewest and the most. Unset, Intl's default. | |
minimumFractionDigits | number | The fewest digits after the decimal point, over fractionDigits. | |
maximumFractionDigits | number | The most digits after the decimal point, over fractionDigits; under the minimum, it reads as the minimum. | |
currencyDisplay | "symbol" | "code" | "name" | A currency as its symbol ("$"), its code ("USD") or its name ("US dollars"). Defaults to "symbol". | |
dateStyle | OutputFieldDateStyle | The day's style in "date" and "datetime" values. Defaults to "medium": "Jun 18, 2026". | |
timeStyle | OutputFieldDateStyle | The time's style in "datetime" and "time" values. Defaults to "short": "3:30 PM". | |
hour12 | boolean | true for a 12-hour clock, false for 24 hours. Unset, the locale's own clock. | |
timeZoneName | "short" | "long" | The zone's name after a "datetime" value, "EDT" or "Eastern Daylight Time". Unset, none. | |
linkLabel | string | Words the link shows instead of the value, like "Visit website". The value stays its address. | |
linkTarget | "_blank" | "_self" | "_parent" | "_top" | Where the link opens; "_blank" adds rel="noopener noreferrer". Defaults to the same tab. | |
linkTitle | string | The link's hover text. |
Featurestype
| Member | Type | Default |
|---|---|---|
rowSortingFeature* | TableFeature | |
rowSelectionFeature* | TableFeature | |
columnSizingFeature* | TableFeature | |
columnResizingFeature* | TableFeature | |
dataTableFeature* | TableFeature | |
sortedRowModel* | (table: Table<any, any>) => () => RowModel<any, any> | |
columnMeta* | DataTableColumnMeta |
DataTableRowtype
type DataTableRow<TData extends RowData> = Row<Features, TData>DataTableColumntype
type DataTableColumn<TData extends RowData> = Column<Features, TData, unknown>DataTableColumnDeftype
type DataTableColumnDef<TData extends RowData, TValue = unknown> = ColumnDef<
Features,
TData,
TValue
>DataTableInstancetype
type DataTableInstance<
TData extends RowData,
TSelected = TableState<Features>,
> = ReactTable<Features, TData, TSelected>DataTableColumnHelpertype
TanStack's column helper for this table, except that a column with an accessor function needs an id: it's the id the sorting state carries.
type DataTableColumnHelper<TData extends RowData> = Omit<
ColumnHelper<Features, TData>,
"accessor"
> & {
accessor: <
TAccessor extends AccessorFn<TData> | DeepKeys<TData>,
TValue extends (TAccessor extends AccessorFn<TData, infer TReturn>
? TReturn
: TAccessor extends DeepKeys<TData>
? DeepValue<TData, TAccessor>
: never),
>(
accessor: TAccessor,
column: TAccessor extends AccessorFn<TData>
? DisplayColumnDef<Features, TData, TValue> & { id: string }
: IdentifiedColumnDef<Features, TData, TValue>
) => TAccessor extends AccessorFn<TData>
? AccessorFnColumnDef<Features, TData, TValue>
: AccessorKeyColumnDef<Features, TData, TValue>
}UseDataTableOptionstype
type UseDataTableOptions<TData extends RowData> = Omit<
TableOptions<Features, TData>,
"features" | "getRowId"
> & {
getRowId: (
originalRow: TData,
index: number,
parent?: DataTableRow<TData>
) => string
}SLDS mapping
DataTable is the SLDS table as lightning-datatable renders it, on Table, with no cell buffer. Row headers are th scope="row", and cells that don't wrap clip with slds-truncate. The row number column is lightning-datatable's, each number slds-row-number in small, weak text. The select column holds Checkbox, and the actions button opens Dropdown Menu. The on-scroll spinner is SLDS's small spinner. SLDS has no Load more button or placeholder rows: Load more is slds-button_neutral, and placeholder rows are Skeleton.
Known gaps:
- No grid: the table isn't
role="grid"and has no arrow keys between cells. Every control is a tab stop. - A resize's guide line stops at the bottom of the visible rows. SLDS draws it 100vh tall.
- After a resize, widths stay in pixels when the container changes size, so the table scrolls sideways or leaves space at its end.
lightning-datatablescales the columns to fit again. - Wrap text and Clip text aren't built into header menus, as
lightning-datatableadds them. The Wrap and clip example builds them withmeta.headerActions. - The arrow, chevrons and check are lucide's, where SLDS draws
utility:arrowdown,utility:downandutility:check. - The text the table adds ("Row Number", "Select all rows", "Choose a row to select", "Select …", "Show actions for …", "Show … column actions", "Sorted by …", "No items to display.", "Load more", "Loading") is English.
| Prop | Value | SLDS | Notes |
|---|---|---|---|
(no props) | default (added) | slds-table slds-table_bordered slds-table_fixed-layout, slds-is-sortable, slds-th__action | Fixed layout: columns with a size keep it, the rest share the width. 8px in every cell, the row's ends too, as lightning-datatable has no cell buffer. Every header with data sorts. |
enableColumnResizing | true (default) (added) | slds-table_resizable-cols, slds-is-resizable, slds-resizable__handle, slds-resizable__divider, slds-resizable__input | A divider at each data header's end that drags, or follows the mouse after a click, and a range input that the arrow keys move 10px. After a resize the other columns keep their widths. false turns it off. |
enableResizing | false (column) (added) | lightning-datatable fixedWidth | The column keeps its size. The select and actions columns never resize. |
minSize, maxSize | (px) (added) | lightning-datatable min-column-width, max-column-width | A column's bounds, 50px and 1000px by default. |
showRowNumberColumn | true (added) | lightning-datatable show-row-number-column, slds-row-number slds-text-body_small slds-text-color_weak | A 3.25rem first column of row numbers in small weak text, 20px in, wider from three digits. rowNumberOffset sets the number before the first row. |
enableRowSelection | true (added) | slds-checkbox in lightning-datatable's 2rem column, slds-is-selected | A checkbox per row with Shift ranges, and a select-all checkbox with SLDS's mixed dash. |
maxRowSelection | (n) (added) | lightning-datatable max-row-selection | Caps single, Shift-range and select-all changes in display order. At the cap the other checkboxes are disabled. |
maxRowSelection | 1 (added) | slds-radio, Choose a row to select | Radios with one tab stop instead of checkboxes, and no select all. |
rowActions | (set) (added) | slds-button_icon-border slds-button_icon-x-small, slds-dropdown | A 50px column with a 20px button in the middle of each row's cell that opens a menu. |
meta.type | number, currency, percent (added) | slds-text-align_right | Formatted with Intl and aligned to the end. Other types align to the start. |
meta.wrapText | true (added) | slds-cell-wrap, slds-line-clamp | Wraps instead of clipping (slds-truncate with a title). wrapTextMaxLines clamps it. |
meta.headerActions | (set) (added) | slds-has-button-menu, slds-th__action-button | A 20px menu button 12px from the header cell's end. |
className | h-* or max-h-* (added) | slds-table_header-fixed_container, slds-table_header-fixed | The table scrolls inside its container, under sticky header cells. |
isLoading | true (added) | (none) | Placeholder rows on a first load, and aria-busy. |
onLoadMore | (set) (added) | (none) | A neutral Load more button below the table while hasNextPage is true. |
loadMoreOnScroll | true (added) | lightning-datatable enable-infinite-loading | Loads the next page as the last rows come into view, with SLDS's spinner below them. |
virtualize | true (added) | (none) | Renders only the rows in view. Needs a height. |
Keyboard interactions
| Keys | Action |
|---|---|
| Space | On a checkbox, selects or clears its row. On select all, selects every loaded row, or clears them. |
| Shift+Space, Shift+click | On a row's checkbox, selects or clears every row from the last one you checked, rows out of view included. |
| Up Arrow, Down Arrow | On a radio, selects the row above or below. |
| Enter, Space | On a sort button, sorts by the column, then reverses it. On an actions button, opens its menu. |
| Arrow keys | On a column divider, widen or narrow the column by 10px. Left widens in a right-to-left table. |
| Page Up, Page Down | On a column divider, widen or narrow the column by 100px. |
| Home, End | On a column divider, make the column its narrowest or widest. |
| Escape | While you drag a divider, or move one after clicking it, puts the column back. |
Tab moves through select all, each sort button, header menu button and column divider, then each row's checkbox, links and actions button. With virtualize, the focused row and its neighbours stay rendered, so Tab continues in order after you scroll away, and a row stays while its menu is open. Menus take Dropdown Menu's keys.
Accessibility
- Name the table with
aria-labeloraria-labelledbyonDataTable. - Mark the name column
meta.rowHeader, or passgetRowLabel. Its value names each row's controls: "Select Acme", "Show actions for Acme". Without one, they name the row by its position: "Select row 1". - Give a column whose
headeris a function ameta.label. It names the sort button, the header menu, the resize slider and the sort announcement. - Sortable headers carry
aria-sort, and a polite live region says the new sort, like "Sorted by Amount, ascending". Each column divider is a slider named "Amount column width" whose value reads "180 pixels". - The table has
aria-busywhileisLoadingorisFetchingNextPage; placeholder rows are hidden from screen readers. A virtualized table carriesaria-rowcountandaria-rowindex, but find in page and printing see only the rendered rows. - In Lightning Blue, a link in a selected row is 4.17:1, below WCAG AA's 4.5:1. SLDS's own stylesheet does the same.
Responsive behavior
- The table fills its container. Columns with a
sizekeep it; the rest share what's left, at least theirminSizeeach. When they don't fit, the table scrolls sideways inside its border. - After a resize, every column keeps its width and the table is as wide as its columns.
- The row number column widens when the numbers reach three digits, so a number never clips.
- Values clip with an ellipsis and a title unless the column sets
wrapText. Decide per column: names clip, descriptions wrap withwrapTextMaxLines. - Without a height, the table grows with its rows. With one (
className="h-96"ormax-h-[60vh]), it scrolls inside itself under a header that stays in place.
Performance
- Rows re-render only when their own data, the columns, the locale or their selection changes. Define
columnsoutside the component, or inuseMemo, or every row re-renders on every render. - Sorting runs in the browser unless you pass
manualSorting. 10,000 rows sort in about 150ms to 320ms in development builds. - Without
virtualize, every row renders. Use it from a few hundred rows: 10,000 rows keep about 30 in the page. Rendering all of 2,000 rows takes 1.5s to 5s in development builds. - A resize re-renders no rows: the width changes on the table's columns directly, and the page re-renders only as the drag starts and ends. With
columnResizeMode: "onChange",columnSizingand the page update on every frame instead.
Rules
- Call
table.resetRowSelection()after a bulk delete removes selected rows. The table keeps the ids of rows that leavedata, so a selection survives a refetch or a new page. - Give the table a height before passing
virtualizeorloadMoreOnScroll. - Select all covers the rows that are loaded, never every record on the server. A bulk action on the selection reaches only those.
- A
timecolumn's values are times of day, like"09:30:00.000Z", and sort by the clock. An amount past 15 digits shows every digit when it's a decimal string, but two that differ only past the 15th may sort as equal. - A
relativecolumn ("3 hours ago") counts fromnow, an ISO instant. Pass it when the page renders on the server, or the current time is read at render and the server and the browser can differ. - An
addressvalue is{ street, city, state, postalCode, country }and anamevalue is{ salutation, firstName, middleName, lastName, suffix }. A cell shows either on one line ("312 Constitution Place, Austin, TX 78767, USA"), sorts by that text, and treats one with no parts as empty. Arelativevalue is an instant and sorts by it; its date and time are in the cell's title and read after the text. - Set
enableResizing: falseon a column that must keep itssize. To keep widths across visits, save them withonColumnSizingChange. Restore widths from the server throughinitialState.columnSizing; read widths fromlocalStorageonly after the page hydrates, and pass them throughstate.columnSizing. - Don't add
rowNumber,selectoractionscolumns; the table adds them, and a column of yours with one of those ids replaces it. Don't put buttons inheader; usemeta.headerActions. - Never pass
asChildorrender.