Tabular data component that composes qds-column-header, qds-checkbox, qds-row-actions, qds-pagination, and qds-empty-state.
Use qds-data-table for rows of records the user reads, sorts, selects, and acts on. Do not use it for layout, for a key/value detail panel (that is a description list), or for a spreadsheet-style editable grid — inline editing is a different component with a different interaction model.
Above roughly 2,000 rows in the DOM, this component will feel slow. See the 2,000-row ceiling below.
The component renders a real <table> with <thead>, <tbody>, <tr>, <th>, and <td> elements — not a grid of divs. Native table layout gives correct column sizing, correct colspan, and correct screen-reader navigation for free.
Each <th> contains one qds-column-header and carries aria-sort itself (not the header component). The <th> owns aria-sort because it has role="columnheader".
sort-mode="client": the table sorts rows itself and re-renders.
sort-mode="server": the table fires qds-sort-change and renders whatever rows it is next given, in the given order.
The same split applies to pagination (pagination-mode).
Never mix modes. A table that sorts the current page client-side while the server holds the rest produces an order that is wrong in a way users cannot see.
Select-all applies to the current page only. A select-all that silently spans 1,284 unloaded rows followed by a delete is a data-loss incident. If a select-everything affordance is needed, it is an explicit second action in a selection toolbar, which is not in this component.
Never innerHTML a cell value. Table data comes from an API; a name field containing markup must render as text. The component uses textContent for all cell values, or appends a Node the consumer built themselves via the column render function.
Sticky columns must declare a width. The table computes left/right offsets from preceding sticky columns’ widths; without a width the offset cannot be calculated.
sticky-header needs a scrolling region to stick inside. Set the height constraint on the qds-data-table element itself — for example <qds-data-table sticky-header style="max-height: 400px"> — not on an outer wrapper <div>. The table’s internal [part="scroll"] region is a flex child of the host; a height set on a wrapper around the table doesn’t reach it, so the table just renders at full content height and nothing actually scrolls.
Do not set display on qds-data-table from page-level CSS (e.g. a rule like qds-data-table { display: block } for spacing purposes). The host’s own display: flex is load-bearing for this same reason — a page-level rule with higher specificity than the component’s internal :host rule will win the cascade and silently break the scroll region’s sizing. Use margin for spacing instead.
v1 renders every row. At 40px rows, 2,000 rows is roughly 12,000 cells, which is where scrolling starts to stutter on a mid-range laptop. Mitigations in scope: textContent not innerHTML, DocumentFragment for row building, reused Intl formatter instances per column. Windowing is out of scope. If a consumer needs 10k rows before windowing exists, they paginate at 100.
<qds-data-table id="positions" density="md" selectable sticky-header></qds-data-table>
<script type="module">
const t = document.getElementById('positions');
t.columns = [
{ key: 'symbol', label: 'Symbol', sortable: true, sticky: 'start', width: '120px' },
{ key: 'name', label: 'Name', sortable: true, minWidth: '200px' },
{ key: 'website', label: 'Website', type: 'link', textKey: 'name', target: '_blank' },
{ key: 'quantity', label: 'Quantity', type: 'number', sortable: true },
{ key: 'marketValue', label: 'Market value', type: 'currency', sortable: true },
{ key: 'weight', label: 'Weight', type: 'percent', sortable: true }
];
t.rows = await loadPositions();
t.actions = [
{ id: 'view', label: 'View detail', icon: 'lucide:external-link' },
{ id: 'delete', label: 'Remove position', destructive: true }
];
</script>
| Attribute | Type | Default | Notes |
|---|---|---|---|
density |
sm | md | lg |
md |
Row density: 32/40/48px row height |
striped |
boolean | false |
Zebra striping |
sticky-header |
boolean | false |
Sticky table header |
selectable |
boolean | false |
Leading checkbox column |
selection-mode |
multiple | single |
multiple |
single renders radio-style behaviour with checkboxes |
selected-ids |
string | — | Comma-separated selected row ids |
sort-mode |
client | server |
client |
See client vs server mode |
paginate |
boolean | false |
Renders qds-pagination in the footer |
page |
number | 1 |
Current page, 1-based |
page-size |
number | 25 |
Rows per page |
total |
number | rows.length |
Total row count for pagination |
pagination-mode |
client | server |
client |
See client vs server mode |
row-id-key |
string | id |
Which field identifies a row |
row-label-key |
string | first text column | Used for accessible names in row actions and checkboxes |
empty-title |
string | — | Title for the default empty state |
empty-description |
string | — | Description for the default empty state |
loading |
boolean | false |
Renders skeleton rows, not empty state |
caption |
string | — | Visually hidden <caption> |
| Property | Type | Default | Notes |
|---|---|---|---|
columns |
QdsColumnDef[] |
[] |
Column definitions. Property only — carries functions |
rows |
QdsTableRow[] |
[] |
Row data. Property only |
actions |
QdsRowAction[] |
[] |
Row action definitions. Property only |
sort |
QdsSortState[] |
[] |
Sort state, multi-column in priority order |
selectedIds |
string[] |
[] |
Selected row ids |
columns is an array of QdsColumnDef objects. key identifies the row-data
field; label supplies the column header. Use type for standard formatting,
or render() only when the cell needs custom DOM.
| Field | Type | Notes |
|---|---|---|
key |
string | Required row-data field. For type: 'link', its value is the link URL. |
label |
string | Required header text. |
type |
text | number | currency | percent | date | link | custom |
Defaults to text. |
textKey |
string | Link-only. Row field used for link text; defaults to key. |
target |
_self | _blank | _parent | _top |
Link-only. Defaults to _self; _blank automatically adds rel="noopener noreferrer". |
format |
function | Returns plain text. |
render |
function | Returns a DOM Node or plain text for custom cells. |
Use type: 'link' for ordinary row links. The URL comes from key, and the
optional textKey supplies the visible label. The table creates the anchor,
applies QDS link and focus styling, and rejects unsafe URL schemes. Do not put
HTML in row data: it is rendered as text.
{ key: 'website', label: 'Website', type: 'link', textKey: 'name', target: '_blank' }
| Part | Element |
|---|---|
container |
Outer wrapper |
toolbar |
Toolbar slot wrapper |
scroll |
Scroll container |
table |
The table element |
thead |
Table header |
header-row |
Header row |
header-cell |
Header cell (th) |
tbody |
Table body |
row |
Body row |
row-selected |
Added to selected rows |
row-focused |
Added to focused row |
cell |
Body cell |
cell-numeric |
Added to numeric cells |
select-cell |
Selection checkbox cell |
actions-cell |
Row actions cell |
footer |
Footer wrapper |
pagination |
Pagination component |
empty |
Empty state row |
skeleton-row |
Skeleton loading row |
| Property | Default |
|---|---|
--qds-row-height |
var(--qds-control-height-md, 40px) |
--qds-row-font-size |
14px |
--qds-row-padding |
var(--theme-space-3, 12px) |
| Event | When | Detail |
|---|---|---|
qds-sort-change |
Sort changed | { component, sort: QdsSortState[] } |
qds-selection-change |
Selection changed | { component, selectedIds, row? } |
qds-row-click |
Row body clicked or Enter on focused row | { component, rowId, row } |
qds-row-action |
Relayed from qds-row-actions with row populated |
{ component, action, rowId, row } |
qds-page-change |
Relayed from qds-pagination |
{ component, page, pageSize, total } |
qds-column-resize |
Column resize committed | { component, key, width } |
| Slot | Purpose |
|---|---|
empty |
Replaces the default empty state |
toolbar |
Above the header — filters, search, bulk actions |
footer |
Below pagination |
<qds-data-table
id="positions"
density="md"
selectable
striped
paginate
page-size="10"
caption="Portfolio positions"
empty-title="No positions yet"
empty-description="Positions appear here once your portfolio is loaded."
>
<div slot="toolbar">
<input type="search" placeholder="Filter positions..." />
</div>
</qds-data-table>
<script type="module">
const table = document.getElementById('positions');
table.columns = [
{ key: 'symbol', label: 'Symbol', sortable: true, sticky: 'start', width: '120px' },
{ key: 'name', label: 'Name', sortable: true, minWidth: '200px' },
{ key: 'website', label: 'Website', type: 'link', textKey: 'name', target: '_blank' },
{ key: 'quantity', label: 'Quantity', type: 'number', sortable: true },
{ key: 'marketValue', label: 'Market value', type: 'currency', sortable: true },
{ key: 'weight', label: 'Weight', type: 'percent', sortable: true }
];
table.rows = [
{ id: '1', symbol: 'AAPL', name: 'Apple Inc.', website: 'https://www.apple.com/', quantity: 100, marketValue: 18500, weight: 0.185 },
{ id: '2', symbol: 'MSFT', name: 'Microsoft Corp.', website: 'https://www.microsoft.com/', quantity: 50, marketValue: 21250, weight: 0.213 },
{ id: '3', symbol: 'GOOGL', name: 'Alphabet Inc.', website: 'https://abc.xyz/', quantity: 30, marketValue: 4200, weight: 0.042 }
];
table.actions = [
{ id: 'view', label: 'View detail', icon: 'pi-external-link' },
{ id: 'delete', label: 'Remove position', destructive: true }
];
table.addEventListener('qds-selection-change', (e) => {
console.log('Selected:', e.detail.selectedIds);
});
table.addEventListener('qds-row-click', (e) => {
console.log('Row clicked:', e.detail.rowId, e.detail.row);
});
</script>