SolutionsPackagesComponentsCLI — Coming soonResourcesDocumentationStorybookChangelogBlogAboutSign up freeGet early access

Documentation

Table

Semantic data table with sortable columns, density sizes, an empty state, and horizontal scroll with a pinned first column.

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:

  1. A user clicks a sortable header.
  2. The table fires dsgn-sort with detail: { column, direction }. A column that is not the current sort asks for ascending; the current ascending column asks for descending; the current descending column asks for ascending again. There is no unsorted step.
  3. You reorder rows — on the client, or by refetching from the server — and set sort to the same { column, direction }.
  4. The table sets aria-sort on that header and swaps its icon. Every other sortable header reads aria-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.

FieldTypeDescription
keystringField read from each row. Also the column part of a cell slot name.
labelstringHeader text, and the sort button’s accessible name.
align'start' | 'end'Text alignment. Defaults to end for numeric columns, start otherwise.
numericbooleanNumbers, dates, sizes and amounts: monospace, tabular figures, end-aligned.
sortablebooleanAdds a sort button and aria-sort. Give it only to columns with a meaningful order.
rowHeaderbooleanRenders 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.
widthstringCSS width for the column, such as '140px'.
hideLabelbooleanHides the header text visually but keeps it for assistive technology. Use it for action columns.
wrapbooleanLets 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>". rowKey is the row’s row-key field (id by default, or the row’s index when the field is missing), and columnKey is the column’s key.
  • 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 rows is empty.
  • Keep row-key values 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 when rows is 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>;
  • slot has to land on the element that is the table’s direct child. The generated @ds-gn/ui/react wrappers pass it through to their element. Wrap any other component in an element that carries slot, as with RoleSelect above.
  • 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

PropTypeDefaultDescription
columnsTableColumn[][]Column definitions in display order. Set as a property; the attribute accepts JSON.
rowsTableRow[][]Row data, already in display order. Set as a property; the attribute accepts JSON.
row-keystring'id'Row field that identifies a row in cell slot names. Falls back to the row index. Property: rowKey.
sortTableSort | nullnullThe current sort, as you applied it. Drives aria-sort and the header icons.
captionstring''Visible title above the table. Labels the table and its scroll region.
caption-metastring''Secondary text beside the caption, such as a row count. Property: captionMeta.
labelstring''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

SlotDescription
emptyShown 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

EventDetailDescription
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

PartDescription
containerThe bordered outer box.
captionThe title bar above the table.
scrollerThe horizontally scrolling region.
tableThe <table> element.
header-cellEach column header <th>.
sort-buttonThe button inside a sortable header.
rowEach body <tr>.
cellEach body cell, including row headers.
row-headerEach body <th scope="row">.
emptyThe 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 (or label).
  • Sortable headers contain a real <button>; non-sortable headers are plain text with no aria-sort.
  • Give icon-only buttons in a row a specific name, such as aria-label="Actions for Priya Natarajan", not a repeated “Actions”.
  • aria-labelledby cannot reach across shadow roots, so a heading outside the component cannot name the table. Use caption, or repeat the heading text in label.

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.

© 2026 LightLeap Labs LLC. All rights reserved. · Terms of Service · Privacy Policy · Cookies · Contact · Support