Open this component in Storybook
The <dsgn-table> component renders a real <table> — column headers with
scope="col", a row header per body row with scope="row", and aria-sort on
sortable columns — from columns and rows data.
It is not form-associated and has no selection. When row selection lands it will use checkboxes inside rows, not make the table a form control.
Usage
<dsgn-table caption="Members" caption-meta="3 people"></dsgn-table>
<script type="module">
import '@ds-gn/ui/table';
const table = document.querySelector('dsgn-table');
table.columns = [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'role', label: 'Role', sortable: true },
{ key: 'joined', label: 'Joined', numeric: true, sortable: true },
];
table.rows = [
{ id: 'alex', name: 'Alex Karpov', role: 'Owner', joined: '2026-01-14' },
{ id: 'hana', name: 'Hana Sato', role: 'Member', joined: '2026-08-12' },
{ id: 'lena', name: 'Lena Fischer', role: 'Member', joined: '2026-10-02' },
];
</script>
In React:
import { Table } from '@ds-gn/ui/react';
<Table
caption="Members"
columns={columns}
rows={rows}
sort={sort}
onDsgnSort={(event) => setSort(event.detail)}
/>;
Playground
Sort contract
The table never sorts data. It reports what the user asked for and shows the state you pass back:
- A user clicks a sortable header.
- The table fires
dsgn-sortwithdetail: { column, direction }. A column that is not the current sort asks forascending; the currentascendingcolumn asks fordescending; the currentdescendingcolumn asks forascendingagain. There is no unsorted step. - You reorder
rows— on the client, or by refetching from the server — and setsortto the same{ column, direction }. - The table sets
aria-sorton that header and swaps its icon. Every other sortable header readsaria-sort="none".
If you ignore the event, nothing changes: sort is fully controlled.
table.addEventListener('dsgn-sort', (event) => {
const { column, direction } = event.detail;
const sign = direction === 'ascending' ? 1 : -1;
table.rows = [...table.rows].sort(
(a, b) => String(a[column]).localeCompare(String(b[column])) * sign,
);
table.sort = event.detail;
});
const [sort, setSort] = useState<TableSort>({
column: 'name',
direction: 'ascending',
});
const sorted = useMemo(() => sortMembers(members, sort), [members, sort]);
<Table
columns={columns}
rows={sorted}
sort={sort}
onDsgnSort={(event) => setSort(event.detail)}
/>;
Columns
columns is an array of TableColumn, exported with TableRow and
TableSort from @ds-gn/ui/table/props.
| Field | Type | Description |
|---|---|---|
key | string | Field read from each row. Also the column part of a cell slot name. |
label | string | Header text, and the sort button’s accessible name. |
align | 'start' | 'end' | Text alignment. Defaults to end for numeric columns, start otherwise. |
numeric | boolean | Numbers, dates, sizes and amounts: monospace, tabular figures, end-aligned. |
sortable | boolean | Adds a sort button and aria-sort. Give it only to columns with a meaningful order. |
rowHeader | boolean | Renders the column’s body cells as <th scope="row">. Defaults to the first column. |
pin | 'start' | 'none' | Keeps the column in view while the table scrolls. First column only; defaults to start when it is the row header. |
width | string | CSS width for the column, such as '140px'. |
hideLabel | boolean | Hides the header text visually but keeps it for assistive technology. Use it for action columns. |
wrap | boolean | Lets body text wrap. Cells stay on one line by default, which is what makes narrow tables scroll. |
hideLabel hides only the header text. When combined with sortable, the sort
button and direction icon remain visible, and the hidden text names the button
for assistive technology.
Populating cells
The table’s structure comes from columns and rows. Your light DOM holds only
the cells you want to replace, plus the empty state. A plain-text cell needs no
markup: the row value is the slot’s fallback content.
Rules
- Each child is an element with
slot="cell:<rowKey>:<columnKey>".rowKeyis the row’srow-keyfield (idby default, or the row’s index when the field is missing), andcolumnKeyis the column’skey. - Text has to be wrapped in an element. A bare text node can’t be assigned to a named slot.
- Order in the light DOM doesn’t matter. Matching is by slot name, so slotted cells follow their rows when you re-sort.
- Several children with the same slot name all render, in document order, inside that one cell.
- A child whose slot name matches no rendered cell isn’t shown. That covers a
filtered-out row, and every cell while
rowsis empty. - Keep
row-keyvalues unique and stable. Two rows with the same key share one slot name, so both cells show the same content. - When a row goes away, remove its slotted children too. They stay in the DOM, unseen, until you do.
slot="empty"renders only whenrowsis empty.
HTML
<dsgn-table id="members" caption="Members" caption-meta="2 people">
<dsgn-badge slot="cell:alex:status" variant="success">Active</dsgn-badge>
<dsgn-badge slot="cell:priya:status" variant="brand">Invited</dsgn-badge>
<dsgn-button
slot="cell:priya:actions"
appearance="ghost"
icon-position="only"
aria-label="Actions for Priya Natarajan"
></dsgn-button>
<div slot="empty">No members yet.</div>
</dsgn-table>
<script type="module">
import '@ds-gn/ui/table';
const table = document.querySelector('#members');
table.columns = [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'status', label: 'Status' },
{ key: 'actions', label: 'Actions', hideLabel: true },
];
table.rows = [
{ id: 'alex', name: 'Alex Karpov' },
{ id: 'priya', name: 'Priya Natarajan' },
];
</script>
name renders from the row data. status and actions come from the slotted
children, and Alex’s actions cell stays empty because nothing targets it.
Lit
Build the slotted children from the same rows you pass in:
html`<dsgn-table
caption="Members"
.columns=${columns}
.rows=${rows}
.sort=${sort}
@dsgn-sort=${onSort}
>
${rows.map(
(row) => html`
<dsgn-badge slot="cell:${row.id}:status">${row.status}</dsgn-badge>
<dsgn-button
slot="cell:${row.id}:actions"
appearance="ghost"
icon-position="only"
aria-label="Actions for ${row.name}"
@click=${() => openMenu(row)}
></dsgn-button>
`,
)}
<div slot="empty">No members yet.</div>
</dsgn-table>`;
React
Render slotted content as children with a slot prop:
import { Button, Table } from '@ds-gn/ui/react';
import { Fragment } from 'react';
<Table
caption="Members"
columns={columns}
rows={sorted}
sort={sort}
onDsgnSort={(event) => setSort(event.detail)}
>
{members.map((member) => (
<Fragment key={member.id}>
<span slot={`cell:${member.id}:role`}>
<RoleSelect member={member} />
</span>
<Button
slot={`cell:${member.id}:actions`}
appearance="ghost"
iconPosition="only"
aria-label={`Remove ${member.name}`}
onClick={() => remove(member)}
/>
</Fragment>
))}
<p slot="empty">No members yet.</p>
</Table>;
slothas to land on the element that is the table’s direct child. The generated@ds-gn/ui/reactwrappers pass it through to their element. Wrap any other component in an element that carriesslot, as withRoleSelectabove.- Slotted children stay in your React tree, so state, context and event handlers work as usual.
- Give icon-only buttons a row-specific
aria-label. Every row shares the same header, so a generic “Actions” can’t tell the rows apart.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| columns | TableColumn[] | [] | Column definitions in display order. Set as a property; the attribute accepts JSON. |
| rows | TableRow[] | [] | Row data, already in display order. Set as a property; the attribute accepts JSON. |
| row-key | string | 'id' | Row field that identifies a row in cell slot names. Falls back to the row index. Property: rowKey. |
| sort | TableSort | null | null | The current sort, as you applied it. Drives aria-sort and the header icons. |
| caption | string | '' | Visible title above the table. Labels the table and its scroll region. |
| caption-meta | string | '' | Secondary text beside the caption, such as a row count. Property: captionMeta. |
| label | string | '' | Accessible name when there is no caption — for a table titled by a heading outside the component. |
| size | 'sm' | 'md' | 'lg' | 'md' | Density: 36, 48 or 56px rows with 12, 16 or 20px cell padding. |
| surface | 'light' | 'dark' | 'dark' | Semantic token surface to use. |
Slots
| Slot | Description |
|---|---|
empty | Shown in one full-width cell when rows is empty. The headers stay visible. |
cell:<rowKey>:<columnKey> | Replaces the text of one cell with your own markup — a badge, an avatar, a button. |
Every cell renders row[column.key] as text unless you slot something into it.
See Populating cells for how to build that content.
Events
| Event | Detail | Description |
|---|---|---|
dsgn-sort | { column, direction } | Fired when a sortable header is clicked, with the sort to apply. Bubbles and crosses the shadow boundary. |
In React, the generated wrapper exposes this as onDsgnSort.
Methods
The component has no public methods.
CSS parts
| Part | Description |
|---|---|
container | The bordered outer box. |
caption | The title bar above the table. |
scroller | The horizontally scrolling region. |
table | The <table> element. |
header-cell | Each column header <th>. |
sort-button | The button inside a sortable header. |
row | Each body <tr>. |
cell | Each body cell, including row headers. |
row-header | Each body <th scope="row">. |
empty | The full-width cell that holds the empty slot. |
Responsive behaviour
Below its natural width the table scrolls sideways; it does not restack into
cards. The caption stays outside the scrolling area. The first column stays
pinned with a divider, casts a soft shadow once you scroll, and the side with
more content fades. Set pin: 'none' on the first column to let it scroll.
Accessibility
- The scroll region has
role="region", takes keyboard focus so it can be scrolled with arrow keys, and is named by the caption (orlabel). - Sortable headers contain a real
<button>; non-sortable headers are plain text with noaria-sort. - Give icon-only buttons in a row a specific name, such as
aria-label="Actions for Priya Natarajan", not a repeated “Actions”. aria-labelledbycannot reach across shadow roots, so a heading outside the component cannot name the table. Usecaption, or repeat the heading text inlabel.
Server rendering
columns and rows are DOM properties, so on the server the element renders
as an empty tag and fills in once it upgrades on the client. Content slotted
into cells is ordinary light DOM and can appear unstyled until then; hide it
with dsgn-table:not(:defined) { visibility: hidden; } if that matters.