Eunomia v1.0.0-beta.2
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

Table

Table is a row-and-column grid with hover-reveal row selection, a floating bulk-action bar, sticky header, columns, and rows, and configurable column widths. It's buildable three different ways, covered in full further down this page: hand-authored markup, a data array handed to two properties, or a plain native table skinned with CSS alone, with no custom element involved. Five independent pieces of it are documented on their own pages too: the header, the column, the item (a single row), the cell, and the action bar.

Dependencies

eun-checkbox · renders the header's select-all control and every row's own checkbox, once selectable is set
Overview API Examples Accessibility

Scroll the table above (12 rows, bounded to 360px) to see the first column stay pinned. Hover or focus a row to reveal its checkbox and its "more" menu, then select one or more to see the floating action bar appear with a live count, "Select all", and the Message/Archive actions wired up above. This is the columns/data (programmatic) mode. See the Examples tab for the exact same result built with light-DOM markup instead, and everywhere else on this page for smaller, single-purpose versions of each individual piece (sticky columns, row-end actions, the action bar, ...). (sticky-header itself is shown separately, further down: see "Sticky header, columns, and a pinned row", since a sticky header never renders its own select-all checkbox, which would make for a confusing first look at selectable here.)

When to use

Reach for table whenever rows need genuinely custom content, such as a status tag, an avatar, a nested button, or several icons in one cell, rather than plain stringified values, and whenever you want selection, the floating action bar, a sticky header, columns, or a pinned row, or full keyboard grid navigation. Between its two data-driven input modes:

  • Hand-authored markup, when content varies row to row in ways a single per-column renderer doesn't fit comfortably, such as wildly different cell shapes, hand-placed slots, or a CMS-driven page assembling rows as literal HTML.
  • A data array handed to two properties, when rows come from an array you already have, like an API response or a store, and you'd rather hand the table that array than imperatively build and diff each row yourself. Cell content stays just as free-form through a per-column render function.

Reach for the native table method instead (see "The same table, three ways" in the Examples tab) when none of that behavior is actually needed: a static pricing table, a printable or exportable report, a page where semantic table markup itself matters, such as assistive technology that specifically expects it or copy-paste into a spreadsheet, and you just want the same visual language, for free, with zero JavaScript.

It is not form-associated and has no notion of sorting, filtering, or pagination of its own, since it's a display-and-selection surface only. Sort your own data and pair it with pagination for paged data, while the column's own sort indicator, a presentation hook only, is built for exactly this kind of consumer-driven sort UI.

Install & usage

npm install @eunomia/elements
import "@eunomia/elements/table.js";
import "@eunomia/elements/table-header.js";
import "@eunomia/elements/table-column.js";
import "@eunomia/elements/table-item.js";
import "@eunomia/elements/table-cell.js";

Light-DOM markup

<eun-table selectable>
  <eun-table-header>
    <eun-table-column width="1fr">Name</eun-table-column>
    <eun-table-column width="120px">Status</eun-table-column>
  </eun-table-header>
  <eun-table-item row-key="1">
    <eun-table-cell>Ada Lovelace</eun-table-cell>
    <eun-table-cell
      ><eun-tag severity="success">Active</eun-tag></eun-table-cell
    >
  </eun-table-item>
</eun-table>

<script type="module">
  const table = document.querySelector("eun-table");
  table.addEventListener("euntableselectionchange", (event) => {
    console.log(event.selectedKeys);
  });
</script>

Programmatic columns + data (importing table.js alone is enough, see the note below)

<eun-table id="table" selectable></eun-table>

<script type="module">
  const table = document.querySelector("#table");
  table.columns = [
    { key: "name", label: "Name", width: "1fr" },
    { key: "status", label: "Status", width: "120px" },
  ];
  table.data = [{ id: "1", name: "Ada Lovelace", status: "Active" }];
  table.rowKey = (row) => row.id;
  table.addEventListener("euntableselectionchange", (event) => {
    console.log(event.selectedKeys);
  });
</script>

Native <table> + CSS (no JS, no custom element, see "The same table, three ways" in the Examples tab)

<link
  rel="stylesheet"
  href="node_modules/@eunomia/elements/dist/features/table/native-table.css"
/>

<table class="eun-table">
  <colgroup>
    <col />
    <col style="width: 120px" />
  </colgroup>
  <thead>
    <tr>
      <th scope="col">Name</th>
      <th scope="col">Status</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Ada Lovelace</td>
      <td>Active</td>
    </tr>
  </tbody>
</table>

Nothing is imported for you automatically, the same rule every feature in this library follows, so import every tag you actually reference. In light-DOM mode that's eun-table-header, eun-table-column, eun-table-item, eun-table-cell, plus eun-checkbox once selectable is set (it renders the header's select-all control and every row's own checkbox) and whatever you put inside a cell or a slot="action".

In columns/data mode, eun-table generates eun-table-header/ eun-table-column/eun-table-item/eun-table-cell itself, so importing eun-table alone already registers those four as an implementation detail of that generation, but eun-checkbox (once selectable) is still a separate import either way, and so is anything a columns[].render or rowAction produces beyond what eun-table already needs internally for rowAction itself (eun-button/eun-dropdown-button/eun-icon, also already pulled in for the same reason).

Either of the first two modes also registers eun-table-action-bar by importing table.js, since it creates one directly, portaled to document.body, whenever a row is selected. The native table method has no dependencies at all: it's a plain stylesheet, no custom elements, no JavaScript.

See the action bar's own page for what it renders internally.

Responsive behavior

None of the three methods collapse into a different (e.g. card-based) layout at narrow widths: a data table's own columns are rarely safe to just drop, so the strategy is the same one spreadsheets and most serious data-grid libraries use:

  • Horizontal scroll, always available for free. Every method's own outer element (eun-table's :host, or native-table.css's .eun-table class) is already overflow: auto, so whenever it's narrower than its content, it scrolls horizontally with no extra setup on your part, on any viewport.
  • sticky-first-column keeps the identifying column (a name, an ID) in view while the rest scrolls underneath: the one column you almost never want to lose sight of on a phone-width screen. Combine with a bounded --table-max-height/inline max-height so long tables also get a bounded, scrollable vertical extent instead of pushing the rest of the page down.
  • The checkbox column (once selectable) stays reachable at any scroll position too, on any viewport. See "Sticky header, columns, and a pinned row" above.
  • The floating action bar adapts its own width below 600px: its desktop min-width: 500px would otherwise force it wider than a typical phone screen, so below that breakpoint it switches to explicit side insets and wraps its three regions onto more than one line if the content still doesn't fit at that width, rather than overflowing.
  • For a genuinely different narrow-width presentation (stacking each row's cells into a labeled card, for instance), build that yourself on top of columns/data (a media-query-driven columns[].render swap, or an entirely separate narrow-viewport template) since that's a content-shape decision specific to your own data, not something a general-purpose table component can make for you.

Guidance

  • Always set a stable row-key on every eun-table-item once your rows can be sorted, filtered, or paginated: the DOM-order fallback used when it's unset only stays correct while row order never changes
  • Keep row-end actions (slot="action") to a single button or dropdown per row: it's revealed only on hover/focus, so more than one control there is hard to discover and awkward to reach by keyboard
  • Reach for --table-max-height whenever you turn on sticky-header/sticky-bottom: without a bounded height there's nothing for the table's own scroll container to actually scroll
  • Set an aria-label (a plain, native HTML attribute, no special property needed) on <eun-table> itself whenever a page has more than one table, so a screen reader announces which one ("grid, Team members") instead of just "grid"
  • Expecting built-in sort/filter/pagination on <eun-table>: none of that is in scope, so re-render your own eun-table-items and pair with eun-pagination as needed
  • Combining sticky-first-column and sticky-last-column on a very narrow table: the two sticky bands can end up overlapping the table's own scrollable middle
  • Skipping the row-end action's own aria-label: an icon-only trigger with no accessible name is invisible to a screen reader, same as any icon-only button anywhere else in the library
  • Reaching for the native-table CSS method expecting any selection, action-bar, or roving-tabindex behavior: it's a visual skin only, so use <eun-table> (methods 1 or 2) for any of that

Live testing

Properties

The table below splits into an Attributes section (settable as plain HTML attributes, e.g. selectable/sticky-header) and a Properties section (JS-only, no matching HTML attribute). That second section is the entire surface eun-table needs to run in fully programmatic mode, with zero hand-authored eun-table-header/eun-table-item/eun-table-cell markup:

  • columns: the column definitions (key/label/width/align/ sort/render) eun-table generates its eun-table-header from. Setting this (even to []) is what puts the table in "managed" mode.
  • data: the row objects to render, one eun-table-item per entry.
  • rowKey: computes each row's stable identity from its own data, the "managed" equivalent of hand-setting row-key on a eun-table-item.
  • rowAction: a single row-end button/dropdown, computed per row, the "managed" equivalent of slotting a eun-button/eun-dropdown-button into slot="action".
  • selectedKeys: reads/writes the live selection from outside, for a fully controlled table, working the same whether the table is light-DOM-authored or columns/data-managed.
  • actions: the floating action bar's own custom actions, likewise working identically in either mode.

Every other property below (selectable, the sticky flags, loading, infinite/virtualize, ...) configures behavior shared by both modes. See Examples: The same table, three ways for columns/data used end to end, with no eun-table-item in sight.

Table <eun-table>

Attributes

NameTypeDefaultDescription
checkbox-always-visiblebooleanfalseKeeps every row's own checkbox visible at all times instead of only on hover/focus/selection ; the header's own select-all checkbox is already always visible regardless of this (see `stickyHeader`)
action-always-visiblebooleanfalseKeeps every row's own row-end action (`slot="action"`, or a `rowAction`-generated one) visible at all times instead of only on hover/focus ; still cedes to the floating action bar once a row is selected, same as the hover-only default
sticky-headerbooleanfalsePins the header to the top of the table's own scroll container ; once pinned, the header never renders the select-all checkbox (even while `selectable`) or any hover styling, since it stays permanently visible rather than only appearing on hover/focus
sticky-first-columnbooleanfalsePins the first data column to the start edge
sticky-last-columnbooleanfalsePins the last data column to the end edge
clear-on-escapebooleanfalseWhether `Escape` clears the selection while the action bar is shown ; off by default so it never fights a consumer's own global `Escape` handling
selected-labelstring'selected'Text shown after the count in the action bar's badge
select-all-labelstring'Select all'Label of the action bar's select-all button while not every row is selected
deselect-all-labelstring'Deselect all'Label of the action bar's select-all button once every row is selected
select-all-aria-labelstring'Select all rows'Accessible label for the header's select-all checkbox while not every row is selected
deselect-all-aria-labelstring'Deselect all rows'Accessible label for the header's select-all checkbox once every row is selected
select-row-aria-labelstring'Select row'Accessible label prefix for each row's own checkbox (its 1-based row number is appended)
toolbar-aria-labelstring'Selection actions'Accessible label for the floating action bar itself
close-action-bar-aria-labelstring'Clear selection'Accessible label for the action bar's close button
skeleton-rowsnumber6Number of placeholder rows rendered while `loading`
clickablebooleanfalseShows a pointer cursor on a row while it has no row-end action content of its own ; purely visual; a row click already fires `euntablerowclick` regardless of this or any row-end action (see `eun-table-item`)
page-sizenumber20Number of rows per page: both the unit `eunloadmore` requests are made in, and the chunk size `virtualize` mounts/unmounts as a whole
virtualize-thresholdnumber100Total known row count above which `virtualize` actually starts detaching pages
buffer-pagesnumber1Number of extra pages kept mounted before and after the page currently in view
has-morebooleanfalseWhether more rows can still be obtained ; the `infinite` sentinel never fires `eunloadmore` while this is `false`
loading-morebooleanfalseWhether a `eunloadmore` request is currently in flight ; set to `true` right before dispatching the event, left to the consumer to reset once new rows are appended
root-marginstring'400px'How far before the physical end of the table `eunloadmore` triggers, as a CSS-like margin
loading-more-labelstring'Loading more rows…'Accessible label passed to the trailing `eun-loader`
selectablebooleanfalseEnables row selection (checkboxes, select-all, the floating action bar)
loadingbooleanfalseShows `skeletonRows` shimmering placeholder rows in place of the real ones: the header stays visible, only `eun-table-item`s are hidden ; set back to `false` once real data has arrived
syncingbooleanfalseShows a shimmering overlay sweeping across every already-visible `eun-table-item`, without hiding any of them (unlike `loading`) : for a background refresh of data the user can already see, rather than an initial load with nothing to show yet. Reflects `aria-busy` on the table itself while set
infinitebooleanfalseEnables scroll-triggered pagination : dispatches `eunloadmore` once the user scrolls near the end of the table's own scroll container and `hasMore` is `true`, showing a trailing `eun-loader` while `loadingMore` is `true`. Independent of `virtualize`: combine both for `eun-infinite-list` parity, or use `infinite` alone to paginate without detaching any DOM
virtualizebooleanfalseDetaches `eun-table-item` pages far from the current scroll position once the known row count exceeds `virtualizeThreshold`, replacing each with a single full-width spacer sized to its last measured height, the same mechanism `eun-infinite-list` uses for its own items, adapted to this table's CSS Subgrid rows. Independent of `infinite`: useful on its own for a very large, already-fully-loaded dataset

Import the exact TypeScript type behind any property above, see Types.

Properties

JS-only — no matching HTML attribute, set these from a script or a template binding.

NameTypeDefaultDescription
actionsArray<EunomiaTableAction>[]Custom actions rendered in the floating action bar's center region
columnsArray<EunomiaTableColumnDef<T>>Programmatic alternative to hand-authoring `eun-table-header`/`eun-table-column` : when set, `eun-table` generates that exact markup itself from `columns`/`data`. Setting this puts the table in "managed" mode: it owns its own light DOM from then on ; don't also hand-author `eun-table-header`/`eun-table-item` children
dataArray<T>[]The rows to render, in "managed" mode (see `columns`)
rowActionEunomiaTableRowActionDef<T>A single row-end action (button or dropdown), in "managed" mode: the data-driven equivalent of a `slot="action"` element
rowKey(row: T, index: number) => stringComputes each row's stable key, in "managed" mode ; defaults to a DOM-order fallback, same caveat as `eun-table-item`'s own `row-key`
selectedKeysArray<string>[]The currently selected rows' keys ; settable from outside for a "controlled" table, always reflecting the live selection either way

Slots

NameDescription
(default)One `eun-table-header` followed by any number of `eun-table-item`s

Events

NameTypeDescription
euntableselectionchangeTableSelectionChangeEventEmitted whenever the selection changes, from any source
euntableactionTableActionEventEmitted when a floating action bar action is activated
eunloadmoreLoadMoreEventDispatched when `infinite` is set, more rows are needed, and `hasMore` is `true` ; `loadingMore` is already `true` by the time this fires

Every event above follows the same naming convention, covered in Events.

CSS custom properties

NameDescription
--table-border-colorSet a custom border color
--table-backgroundSet a custom background color
--table-max-heightSet a custom max height (with scroll) ; `none` by default
--table-skeleton-staggerDelay between each skeleton row's entrance animation, as a CSS time (e.g. `60ms`)
--table-skeleton-durationDuration of each skeleton row's own entrance animation, as a CSS time (e.g. `320ms`)
--table-loader-colorSet a custom color for the trailing `eun-loader` shown while `loadingMore` (an `infinite` table)
--table-sync-overlay-colorSet a custom highlight color for the shimmer sweep shown while `syncing`
--table-sync-durationSet a custom duration for the `syncing` shimmer sweep, as a CSS time (default `1.6s`)

eun-table-header's, eun-table-column's, eun-table-item's, eun-table-cell's, and eun-table-action-bar's own properties/events are documented on their own pages. See Header: API, Column: API, Item: API, Cell: API, and Action bar: API.

Implementation methods

The exact same two rows, built with each method, side by side.

Light-DOM

Name Status Ada Lovelace Active Alan Turing Away
<eun-table>
  <eun-table-header>
    <eun-table-column width="1fr">Name</eun-table-column>
    <eun-table-column width="120px">Status</eun-table-column>
  </eun-table-header>
  <eun-table-item row-key="1">
    <eun-table-cell>Ada Lovelace</eun-table-cell>
    <eun-table-cell>Active</eun-table-cell>
  </eun-table-item>
  <eun-table-item row-key="2">
    <eun-table-cell>Alan Turing</eun-table-cell>
    <eun-table-cell>Away</eun-table-cell>
  </eun-table-item>
</eun-table>

Programmatic

Set from a plain <script>, as everywhere else on this page:

const table = document.querySelector("#method-2-demo");
table.columns = [
  { key: "name", label: "Name", width: "1fr" },
  { key: "status", label: "Status", width: "120px" },
];
table.data = [
  { id: "1", name: "Ada Lovelace", status: "Active" },
  { id: "2", name: "Alan Turing", status: "Away" },
];
table.rowKey = (row) => row.id;

Or, since columns/data/rowKey are plain JS properties, bound directly in markup: the exact same values, written as property bindings instead of imperative assignment, inside a Lit (or Lit-based framework) template:

import { html } from "lit";

const columns = [
  { key: "name", label: "Name", width: "1fr" },
  { key: "status", label: "Status", width: "120px" },
];

function renderTeamTable(team) {
  return html`
    <eun-table
      .columns=${columns}
      .data=${team}
      .rowKey=${(row) => row.id}
    ></eun-table>
  `;
}

Native table with CSS

Name Status
Ada LovelaceActive
Alan TuringAway

Same 1fr-equivalent/120px column split as methods 1 and 2 above: the stylesheet uses table-layout: fixed, so a <colgroup> is what actually controls widths here (an unsized <col> shares whatever space is left, same as an unset eun-table-column width).

<link rel="stylesheet" href="native-table.css" />

<table class="eun-table">
  <colgroup>
    <col />
    <col style="width: 120px" />
  </colgroup>
  <thead>
    <tr>
      <th scope="col">Name</th>
      <th scope="col">Status</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Ada Lovelace</td>
      <td>Active</td>
    </tr>
    <tr>
      <td>Alan Turing</td>
      <td>Away</td>
    </tr>
  </tbody>
</table>

More on sticky/columns/selection for each method below: every example from here on shows the one method you pick:

Show every example below as Light DOM Programmatic API Native table + CSS

eun-table's light-DOM and columns/data methods render the exact same DOM either way (see the class doc's own explanation on the API tab), so picking between them below only ever changes the code sample shown, not the live demo above it: both produce the identical table you're already looking at. Native table + CSS genuinely is a different demo each time (no eun-table, no shadow DOM), so it swaps in its own. A section whose feature has no native-table.css counterpart at all (selection, the action bar, row-end actions, loading, infinite scroll/virtualization: it's a pure visual skin, no custom element and no JavaScript at all, see "Method 3: native table and CSS" above) shows a short note instead, in place of a demo, while "Native table + CSS" is selected.

Column widths

width accepts any valid CSS grid track (a fixed length, a percentage, or fr), fed verbatim into the shared column template, and an unset width falls back to a flexible minmax(0, 1fr) track.

# Name Status 1 Ada Lovelace Active 2 Alan Turing Away
<eun-table-column width="64px">#</eun-table-column>
<eun-table-column width="2fr">Name</eun-table-column>
<eun-table-column width="120px">Status</eun-table-column>

Sticky elements

sticky-header, sticky-first-column, and sticky-last-column are independent booleans on eun-table itself, while sticky-bottom is set on whichever individual eun-table-item(s) should pin to the bottom (a totals row, say), since the consumer marks it, rather than eun-table guessing "last row" from DOM order. Stacking order, top to bottom: every sticky layer (the header, sticky-bottom row, sticky first/last column, and the corner where a sticky column meets the sticky header) always wins over the checkbox column, which in turn always wins over an ordinary, non-sticky cell, so a row's checkbox reliably renders in front of scrolling cell content passing beneath it (even while not hovered) without ever covering an actual sticky element. The sticky header specifically never renders its own select-all checkbox or any hover styling, even while selectable, since it stays permanently visible rather than only appearing on hover/focus like an item's own checkbox, so it wouldn't make sense as a persistent floating control. A sticky-bottom row is unaffected and keeps its checkbox/hover as usual. Give the table a bounded height (here via inline style, or --table-max-height) so there's something to scroll: 14 rows and 6 wide columns below, specifically so both directions actually need to scroll. Drag or scroll vertically to see the header (and the Total row) stay pinned, and horizontally to see the Team and Owner columns stay pinned at either edge while the quarters scroll underneath.

Team Q1 Q2 Q3 Q4 Owner Platform $120k $128k $135k $140k Ada Lovelace Design $40k $42k $45k $48k Hedy Lamarr Sales $80k $88k $95k $102k Grace Hopper Research $95k $97k $101k $104k Alan Turing Data Science $60k $64k $70k $73k Katherine Johnson Infrastructure $70k $71k $75k $79k Radia Perlman Product $50k $53k $55k $58k Dorothy Vaughan Quality $35k $36k $38k $40k Mary Jackson Security $45k $47k $49k $52k Shafi Goldwasser Compilers $55k $56k $58k $60k Frances Allen Architecture $65k $67k $70k $72k Margaret Hamilton Distinguished Eng. $90k $92k $95k $98k Barbara Liskov Engineering Mgmt $85k $86k $88k $90k Dorothy Vaughan Networking $48k $49k $51k $53k Radia Perlman Total $938k $976k $1,025k $1,069k
<eun-table
  sticky-header
  sticky-first-column
  sticky-last-column
  style="max-height: 320px;"
>
  <eun-table-header>
    <eun-table-column width="180px">Team</eun-table-column>
    <eun-table-column width="130px">Q1</eun-table-column>
    <eun-table-column width="130px">Q2</eun-table-column>
    <eun-table-column width="130px">Q3</eun-table-column>
    <eun-table-column width="130px">Q4</eun-table-column>
    <eun-table-column width="160px">Owner</eun-table-column>
  </eun-table-header>
  <eun-table-item row-key="1">
    <eun-table-cell>Platform</eun-table-cell>
    <eun-table-cell>$120k</eun-table-cell>
    <eun-table-cell>$128k</eun-table-cell>
    <eun-table-cell>$135k</eun-table-cell>
    <eun-table-cell>$140k</eun-table-cell>
    <eun-table-cell>Ada Lovelace</eun-table-cell>
  </eun-table-item>
  <!-- ...13 more rows... -->
  <eun-table-item row-key="total" sticky-bottom style="font-weight: 600;">
    <eun-table-cell>Total</eun-table-cell>
    <eun-table-cell>$938k</eun-table-cell>
    <eun-table-cell>$976k</eun-table-cell>
    <eun-table-cell>$1,025k</eun-table-cell>
    <eun-table-cell>$1,069k</eun-table-cell>
    <eun-table-cell></eun-table-cell>
  </eun-table-item>
</eun-table>

Hover transition

Hovering a row fades its background in rather than snapping straight to it, over --table-row-transition-duration (200ms by default). The row's own background, its checkbox cell, and any sticky first/last column cell all animate through that exact same duration, so nothing on the row drifts out of sync with anything else mid-fade. Set the variable to 0s to remove the transition outright (an instant snap instead), whether for a prefers-reduced-motion override or simply a preference for no motion at all.

None of this ever risks a visual shift near a pinned row, by construction rather than by careful tuning: a sticky-bottom row never reacts to hover in the first place (see above), and every hover effect elsewhere on a row is a background-color fade only, never a transform or a size change, so nothing here ever moves or resizes anything, hovered or not. A hovered row scrolling toward (or away from) the pinned Total row above can't ever visually overlap or misalign with it either way. That's also why the sticky demo above is safe to hover anywhere, mid-scroll, with no special case to watch for.

Disable the hover transition (instant snap) Team Budget Engineering $120k Design $40k Total $160k

This demo sets --table-row-hover-background to a more visibly-tinted color on purpose, purely so the fade (and, once disabled, the snap) reads clearly on the page. The library's own default (unset) hover tint is a much more neutral, subtle grey, meant to read as a quiet "you're over a row" cue rather than a loud one, consistent with every other untouched hover state across the rest of this design system.

<eun-table style="--table-row-transition-duration: 0s;">
  <!-- every row's own hover now snaps instead of fading -->
</eun-table>

Row selection

selectable reveals a checkbox on hover/focus at the start of each row, and once at least one is checked, eun-table-action-bar mounts at the bottom of the viewport with a live count, "Select all"/"Deselect all", and any actions you pass. Check a few rows below: the table itself stays right where it is, since the bar always floats at the bottom of the viewport, not this box (see Action bar for that element on its own, with several more configurations). selectedKeys is also settable from outside for a fully controlled selection.

actions is the same array either way: set on eun-table itself (as below) or on a standalone eun-table-action-bar (see Action bar: A dropdown action), and each entry is a plain button by default, or a eun-dropdown-button the moment it carries its own dropdown array of options, so mix both shapes freely in the same list, as below (Message is a plain button, Export is a dropdown). A dropdown entry's own choice comes back as event.optionKey on euntableaction, alongside the entry's own key.

Name Status Ada Lovelace Active Grace Hopper Active Alan Turing Away Katherine Johnson Offline
<eun-table id="table" selectable>
  <eun-table-header>
    <eun-table-column width="1fr">Name</eun-table-column>
    <eun-table-column width="120px">Status</eun-table-column>
  </eun-table-header>
  <eun-table-item row-key="1">
    <eun-table-cell>Ada Lovelace</eun-table-cell>
    <eun-table-cell
      ><eun-tag severity="success">Active</eun-tag></eun-table-cell
    >
  </eun-table-item>
  <!-- ...3 more rows... -->
</eun-table>

<script type="module">
  const table = document.querySelector("#table");
  table.actions = [
    { key: "message", label: "Message", icon: "mail" },
    {
      key: "export",
      label: "Export",
      icon: "file_download",
      dropdown: [
        { key: "csv", label: "Export as CSV" },
        { key: "pdf", label: "Export as PDF" },
      ],
    },
    { key: "archive", label: "Archive", variant: "critical", icon: "archive" },
  ];
  table.addEventListener("euntableaction", (event) => {
    if (event.key === "archive") {
      archiveRows(table.selectedKeys);
    } else if (event.key === "export") {
      exportSelection(event.optionKey, table.selectedKeys); // "csv" | "pdf"
    }
  });
</script>

Row-end actions

A trailing dropdown or button at the end of each row, revealed on hover/focus and hidden once any row is selected (it cedes to the action bar). The two shapes below are independent per row, not two flavors of the same thing, so nothing stops one row from using a dropdown (several choices) and another a single plain button (exactly one thing to do), in the very same table. Hover or focus either row below to reveal its own control.

Name Status Ada Lovelace Active Alan Turing Away

Slot a eun-button or eun-dropdown-button directly into slot="action", letting each row differ if it needs to (a dropdown for most rows, a single plain button for another). See Item: Row-end action, dropdown vs. plain button for both side by side.

<!-- Several choices : a dropdown -->
<eun-table-item row-key="1">
  <eun-table-cell>Ada Lovelace</eun-table-cell>
  <eun-dropdown-button
    slot="action"
    size="xs"
    appearance="ghost"
    icon="more_vert"
    aria-label="Row actions"
    options='[{"key":"edit","label":"Edit"},{"key":"archive","label":"Archive"}]'
  ></eun-dropdown-button>
</eun-table-item>

<!-- Exactly one thing to do : a plain button -->
<eun-table-item row-key="2">
  <eun-table-cell>Alan Turing</eun-table-cell>
  <eun-button
    slot="action"
    size="xs"
    appearance="ghost"
    aria-label="Send reminder"
  >
    <eun-icon name="mail" size="16"></eun-icon>
  </eun-button>
</eun-table-item>

Clickable rows

A row already fires euntablerowclick on a plain click outside its own checkbox/action cell and outside selection mode, whether or not it (or the table at all) has a row-end action. Nothing about a row looks clickable on its own though, since a mouse cursor doesn't change over plain content by default. clickable adds exactly that: a pointer cursor over any row that has no row-end action content of its own, since a row that does already has its own, separately affordanced control, and doesn't need a second, table-wide hint layered on top of it. Click a row below (there's no row-end action here at all) to see it fire.

Name Status Ada Lovelace Active Alan Turing Away

Last clicked row key:

<eun-table clickable>
  <eun-table-header>
    <eun-table-column width="1fr">Name</eun-table-column>
    <eun-table-column width="120px">Status</eun-table-column>
  </eun-table-header>
  <eun-table-item row-key="1">
    <eun-table-cell>Ada Lovelace</eun-table-cell>
    <eun-table-cell
      ><eun-tag severity="success">Active</eun-tag></eun-table-cell
    >
  </eun-table-item>
  <!-- ...more rows... -->
</eun-table>

<script type="module">
  document
    .querySelector("eun-table")
    .addEventListener("euntablerowclick", (event) => openDetails(event.rowKey));
</script>

Still fires identically on a row that does carry a row-end action (see "Row-end actions" above): clickable only changes the cursor, on rows without one, never the event itself. Combine it with selectable too if you like: once at least one row is checked, a click anywhere on a row toggles selection instead of firing euntablerowclick, the same precedence selectable already has on its own (see "Row selection and action bar" above).

Always-visible actions

Both the checkbox column and a row-end action default to hover/focus-reveal (see the two sections above): set checkboxAlwaysVisible/ actionAlwaysVisible on eun-table itself to keep either one, or both, permanently visible instead, useful for touch screens (no :hover to reveal anything on), for a table whose rows are so sparse that "what can I even do here" shouldn't depend on finding the right pixel to point at, or simply as a deliberate design choice. Both still cede to the floating action bar once a row is actually selected, same as the hover-only default: this only changes when they're revealed, not the rest of their behavior.

Name Status Ada Lovelace Active Alan Turing Away
<eun-table selectable checkbox-always-visible action-always-visible>
  <eun-table-header>
    <eun-table-column width="1fr">Name</eun-table-column>
    <eun-table-column width="120px">Status</eun-table-column>
  </eun-table-header>
  <eun-table-item row-key="1">
    <eun-table-cell>Ada Lovelace</eun-table-cell>
    <eun-table-cell
      ><eun-tag severity="success">Active</eun-tag></eun-table-cell
    >
    <eun-dropdown-button
      slot="action"
      size="xs"
      appearance="ghost"
      icon="more_vert"
      aria-label="Row actions"
      options='[{"key":"edit","label":"Edit"},{"key":"archive","label":"Archive"}]'
    ></eun-dropdown-button>
  </eun-table-item>
  <!-- ...more rows... -->
</eun-table>

Reordering rows

eun-table has no row-reordering of its own, but a row is just a plain DOM sibling like any other, so createDragReorder (the framework-agnostic drag-and-drop utility) attaches to it exactly the way it would to a plain <li>. Grab a row by its handle (the grip icon, in a dedicated narrow leading column) and drag it up or down, or focus the handle and press Space, then the arrow keys, then Space again. See the utility's own page for the full keyboard pattern and every option below.

Name Team Ada Lovelace Platform Alan Turing Research Grace Hopper Compilers Katherine Johnson Data Margaret Hamilton Flight software

Escalation order:

The handle lives inside its own eun-table-cell, not directly on the row: that's what keeps it from fighting eun-table's own grid keyboard navigation. A row's ArrowUp/ArrowDown handling (moving the roving focus to the row above/below) only reacts when the keydown's target is one of the row's own cell wrappers directly (reached via Tab), while a keydown from a button nested a level inside a cell never matches that, so the two never contend for the same arrow keys.

<eun-table id="roster" aria-label="On-call escalation order">
  <eun-table-header>
    <eun-table-column width="56px" aria-label="Reorder"></eun-table-column>
    <eun-table-column width="1fr">Name</eun-table-column>
    <eun-table-column width="160px">Team</eun-table-column>
  </eun-table-header>
  <eun-table-item row-key="1">
    <eun-table-cell>
      <button
        type="button"
        class="row-drag-handle"
        aria-label="Reorder Ada Lovelace"
      >
        <eun-icon name="drag_indicator"></eun-icon>
      </button>
    </eun-table-cell>
    <eun-table-cell>Ada Lovelace</eun-table-cell>
    <eun-table-cell>Platform</eun-table-cell>
  </eun-table-item>
  <!-- ...more rows... -->
</eun-table>
import { createDragReorder } from "@eunomia/elements";

const table = document.querySelector("#roster");

createDragReorder(table, {
  items: () => Array.from(table.querySelectorAll(":scope > eun-table-item")),
  handle: ".row-drag-handle",
  onReorder: ({ order }) => {
    // `order` is the rows themselves, already in their new DOM order —
    // read back whatever identifies each one (here, `row-key`) into your
    // own escalation-order data.
    escalationOrder = order.map((row) => row.getAttribute("row-key"));
  },
});
createDragReorder measures real, currently-mounted neighbors: infinite/virtualize (see below) detach rows far from the current scroll position from the DOM entirely, which it has no way to account for. Reach for it on a bounded, fully-rendered table only (like the one above), the same restriction as `eun-infinite-list`'s own reordering example.

Works identically in columns/data mode: same DOM either way, so put the handle in a columns[].render for the leading column instead of a eun-table-cell, and even over a plain native <table>'s own <tr>s (items: () => Array.from(tbody.children)). createDragReorder only ever needs a container and its direct children, never eun-table itself.

Custom cell content

Any markup at all (an icon-and-text pairing, several tags side by side), since a cell is just eun-table-cell's own default slot. Nothing here is special-cased, since it's the same slot every other example on this page already uses for plain text. columns[].render (in columns/data mode) produces exactly the same output from a plain data object instead: same icon, same text, same two tags, driven by a function instead of hand-written markup.

Task Tags
Ship release notes
Done v2.4
<eun-table-item row-key="1">
  <eun-table-cell>
    <div style="display: flex; align-items: center; gap: 8px;">
      <eun-icon name="check_circle" size="18"></eun-icon>
      <span>Ship release notes</span>
    </div>
  </eun-table-cell>
  <eun-table-cell>
    <eun-tag severity="success">Done</eun-tag>
    <eun-tag color="light">v2.4</eun-tag>
  </eun-table-cell>
</eun-table-item>

Loading

loading swaps every real eun-table-item for skeletonRows (default 6) shimmering eun-table-skeleton-row placeholders: the header stays visible, only the body rows are hidden, and each placeholder row matches the real grid's own column tracks (select/data/action) so nothing shifts once real data replaces it. Rows fade/slide in with a short per-row stagger rather than all at once, an effect you can override with --table-skeleton-stagger (a CSS time, e.g. 100ms) or disable it (0ms) for a flat appearance.

Name Email Status
<eun-table selectable loading skeleton-rows="5">
  <eun-table-header>
    <eun-table-column width="1fr">Name</eun-table-column>
    <eun-table-column width="120px">Status</eun-table-column>
  </eun-table-header>
  <!-- real eun-table-item rows, added once data arrives -->
</eun-table>
table.loading = true;
fetchRows().then((rows) => {
  rows.forEach((row) => table.append(renderRow(row))); // your own eun-table-item rendering
  table.loading = false;
});

Syncing

loading (above) is for an initial load: there's nothing real to show yet, so every row is replaced by placeholders. syncing is for the opposite case, a background refresh of data the user can already see (polling, a manual "Refresh" click, revalidating after a mutation), so it does the opposite thing: every real eun-table-item stays exactly as visible as it was, and a shimmering highlight sweeps across each one on top, the same visual language as a loading skeleton, without hiding the data it's layered over. The table itself also reflects aria-busy="true" while syncing, on top of whatever the sweep already conveys visually.

Name Email Status Ada Lovelace adalovelace@gmail.com Active Alan Turing alan.turing2@hotmail.com Away Grace Hopper grace.hopper@yahoo.fr Active
<eun-table syncing>
  <eun-table-header>
    <eun-table-column width="1fr">Name</eun-table-column>
    <eun-table-column width="120px">Status</eun-table-column>
  </eun-table-header>
  <!-- real, already-loaded eun-table-item rows: they stay exactly as they
       were, the sweep only renders on top of them -->
</eun-table>

<script type="module">
  const table = document.querySelector("eun-table");
  async function refresh() {
    table.syncing = true;
    const rows = await fetchLatestRows();
    reconcileRows(table, rows); // your own diff/patch of the existing rows
    table.syncing = false;
  }
  setInterval(refresh, 30_000);
</script>

--table-sync-overlay-color sets the sweep's own peak color (a translucent dark tint by default, tuned for a light surface, so set a translucent light tint instead on a dark one) ; the gradient never fully fades to nothing between sweeps, only between this color and a dimmer version of itself, so the row never reads as "nothing is happening" between two passes. --table-sync-duration sets the sweep's own duration (default 1.6s). Both, like every other table custom property, apply identically whichever composition method built the row underneath.

Infinite scroll and virtualization

Two independent booleans, combinable: the exact same idea as eun-infinite-list, adapted to this table's CSS Subgrid rows and reusing its very same eunloadmore event:

  • infinite: dispatches eunloadmore once the user scrolls near the end of the table's own scroll container and hasMore is true, showing a trailing eun-loader while loadingMore is true. pageSize/rootMargin/loadingMoreLabel tune the request size, trigger distance, and accessible label.
  • virtualize: once the known row count passes virtualizeThreshold (default 100), detaches eun-table-item pages far from the current scroll position and replaces each with a single full-width spacer sized to its last measured height, keeping the live DOM small regardless of how many rows are known. pageSize doubles as the virtualization chunk size, while bufferPages controls how many extra pages stay mounted around the current one.

Virtualize

200 rows, all appended upfront: no has-more/infinite needed, virtualize alone keeps the live DOM small. virtualize-threshold="20" is lowered here purely so the effect is visible within a small demo, since the default (100) is fine for real use. The counter above the table is not part of the component: it's this demo's own MutationObserver, counting how many of the 200 rows are actually real eun-table-item elements right now (a .eun-table-row-spacer stands in for every virtualized-out page). Scroll and watch it move: it never grows much past what's needed to cover the visible area plus buffer, no matter how far you scroll into the 200.

Real eun-table-item rows right now, out of 200:
# Name Role City
<eun-table virtualize virtualize-threshold="200" style="max-height: 360px;">
  <eun-table-header>...</eun-table-header>
  <!-- all 200 rows, appended upfront -->
</eun-table>

Infinite scroll

Only the first page (10 rows) is slotted, and the bare has-more attribute opts into fetching more (false, the default, would never trigger eunloadmore at all). Scrolling to the bottom shows the trailing loader and fires eunloadmore, this demo answers it with a simulated ~700ms "request" that appends 10 more rows, four times, then sets hasMore to false. --table-loader-color tints that trailing eun-loader here: it inherits from currentColor, so this is color set on its wrapper rather than a eun-loader-specific property.

# Result
<eun-table id="results" infinite has-more page-size="20">
  <eun-table-header>...</eun-table-header>
  <!-- first page of eun-table-item rows -->
</eun-table>
table.addEventListener("eunloadmore", async (event) => {
  const response = await fetch(
    `/api/results?page=${event.page}&pageSize=${event.pageSize}`,
  );
  const { rows, hasMore } = await response.json();
  table.append(...rows.map(renderRow)); // your own row rendering
  table.loadingMore = false;
  table.hasMore = hasMore;
});

Custom appearance

Every visual piece is a CSS custom property: border/background colors, cell text color, hover/selected tints, the trailing loader's own tint, the action bar's own surface/shadow (see Action bar: Customized). Set them inline, or scope them in your own stylesheet, same property names across all three composition methods, so one override applies everywhere you use them (see Cell: API for --table-cell-color/--table-cell-align, and this page's own "API" tab for the full list on eun-table itself, including --table-loader-color/--table-skeleton-stagger/--table-skeleton-duration/ --table-sync-overlay-color/--table-sync-duration).

Grid lines

Every row already has its own bottom border by default (--table-border-color) ; --table-column-border-color adds the other half, a vertical divider between every column, unset (transparent, no divider) until you do. Set both to the same tone and every cell ends up with all four of its own edges visible, a plain spreadsheet-style grid, with no other override needed.

Name Role Status Ada Lovelace Lead Active Alan Turing Research Away Grace Hopper Principal Active
<eun-table
  style="--table-column-border-color: var(--eun-border-color, #dee0e7);"
>
  <!-- --table-border-color already draws the horizontal lines by default -->
</eun-table>

Pair it with --table-sticky-border-color (see the sticky demo further up) for a bolder divider specifically on a sticky header/column/pinned-row edge, distinct from this plain grid's own lighter lines.

Name Role Status Ada Lovelace Lead Active Alan Turing Research Away Grace Hopper Principal Active Total 3

The sticky Name column and the pinned Total row each pick up their own tint (--table-sticky-column-background/--table-sticky-row-background), distinct from the header's --table-header-background and the ordinary rows' own background, so a pinned edge stays visually identifiable even once several other overrides are in play. Both fall back to the row/header background they'd otherwise use, so setting only one of them tints just that one sticky band, leaving everything else exactly as before. Scroll the demo horizontally/vertically to see both stay pinned with their own color while the rest scrolls underneath.

Two more tokens add actual border lines, both invisible (transparent) until set : --table-column-border-color draws a light vertical divider between every column (NameRoleStatus above) ; --table-sticky-border-color draws a bolder one specifically on a sticky element's own edge (the header's bottom edge, the pinned row's top edge, and the sticky Name column's trailing edge) and wins over --table-column-border-color wherever the two would otherwise overlap (the sticky Name column doesn't get a second, fainter divider on top of its own bolder one). Unset either one on its own and that specific divider just doesn't render, same "opt in or the table looks exactly as before" rule every other custom property on this page follows.

<eun-table
  selectable
  sticky-first-column
  style="
    --table-border-color: var(--eun-color-primary-300, #UNMAPPED);
    --table-header-background: var(--eun-color-primary-50, #9ab4ff);
    --table-cell-color: var(--eun-color-primary-700, #3662e0);
    --table-row-hover-background: var(--eun-color-primary-bg-hover, rgba(37, 99, 235, 0.08));
    --table-row-selected-background: var(--eun-color-primary-100, #85a4ff);
    --table-sticky-column-background: var(--eun-color-primary-50, #9ab4ff);
    --table-sticky-row-background: var(--eun-color-primary-100, #85a4ff);
    --table-column-border-color: var(--eun-color-primary-200, #6f94ff);
    --table-sticky-border-color: var(--eun-color-primary-500, #3d6fff);
  "
>
  <eun-table-header>...</eun-table-header>
  <eun-table-item row-key="1">...</eun-table-item>
  <!-- ... -->
  <eun-table-item row-key="total" sticky-bottom style="font-weight: 600;">
    <eun-table-cell>Total</eun-table-cell>
    <eun-table-cell>3</eun-table-cell>
  </eun-table-item>
</eun-table>

Building your own selection UI with a standalone action bar

selectable (see "Selection and the floating action bar" above) already wires a checkbox column and eun-table-action-bar end to end. Reach for this instead only when that automatic wiring doesn't fit: selection state that already lives in an external store, only some rows allowed to be selected, or a bar whose chrome needs to diverge from the default (a different layout of its three regions, say) beyond what its own CSS custom properties already cover. eun-table-action-bar is a fully standalone element (see Action bar): nothing about it depends on eun-table at all, it just happens to be what eun-table itself mounts once selectable is set.

This example keeps eun-table's own header/columns/cells (and their own CSS custom properties: --table-header-background, --table-border-color, ...) for the table itself, but leaves selectable unset: each row gets a hand-slotted eun-checkbox instead of the automatic select column, tracked in a plain Set, and eun-table-action-bar is created and updated by hand whenever that set changes, the exact same properties (selectedCount/totalCount/actions/onSelectAll/onClose) and events (euntableaction) eun-table itself would drive automatically, just wired directly instead. Its default slot also takes plain markup alongside actions (a "View activity" link below, next to "Archive") for whatever an actions entry (a button or a dropdown, always) can't express on its own.

Select Name Status Ada Lovelace Active Alan Turing Away Grace Hopper Active

The action bar below is repositioned within this preview box for the demo only (position: absolute instead of its own default fixed); in real usage it's always centered at the bottom of the viewport, exactly like it is once eun-table mounts it for you.

<eun-table id="table">
  <eun-table-header>
    <eun-table-column width="48px"
      ><span class="sr-only">Select</span></eun-table-column
    >
    <eun-table-column width="1fr">Name</eun-table-column>
    <eun-table-column width="120px">Status</eun-table-column>
  </eun-table-header>
  <eun-table-item row-key="1">
    <eun-table-cell
      ><eun-checkbox aria-label="Select Ada Lovelace"></eun-checkbox
    ></eun-table-cell>
    <eun-table-cell>Ada Lovelace</eun-table-cell>
    <eun-table-cell
      ><eun-tag severity="success">Active</eun-tag></eun-table-cell
    >
  </eun-table-item>
  <!-- ...more rows... -->
</eun-table>

<!-- hidden by default ; eun-table-action-bar's own :host now honors the
     standard hidden attribute directly, same as any other element -->
<eun-table-action-bar id="bar" hidden>
  <!-- the default slot renders after the actions[] buttons/dropdowns —
       anything at all, not just another action -->
  <eun-link href="/activity">View activity</eun-link>
</eun-table-action-bar>

<script type="module">
  const table = document.querySelector("#table");
  const bar = document.querySelector("#bar");
  const rows = [...table.querySelectorAll("eun-table-item")];
  const selected = new Set();

  function sync() {
    bar.hidden = selected.size === 0;
    bar.selectedCount = selected.size;
    bar.totalCount = rows.length;
  }

  rows.forEach((row) => {
    const checkbox = row.querySelector("eun-checkbox");
    checkbox.addEventListener("eunchange", () => {
      checkbox.checked ? selected.add(row.rowKey) : selected.delete(row.rowKey);
      sync();
    });
  });

  bar.actions = [
    { key: "archive", label: "Archive", variant: "critical", icon: "archive" },
  ];
  bar.onSelectAll = () => {
    /* select/deselect every row, same as above */
  };
  bar.onClose = () => {
    selected.clear();
    rows.forEach((row) => (row.querySelector("eun-checkbox").checked = false));
    sync();
  };
  bar.addEventListener("euntableaction", (event) => {
    if (event.key === "archive") {
      archiveRows([...selected]);
    }
  });
</script>

Everything visual is still just CSS custom properties: restyle the base table with eun-table's own (--table-header-background, --table-border-color, --table-sticky-row-background, --table-sticky-column-background, ...) and the bar with its own --table-action-bar-* set (see Action bar: Customized). The two are independent surfaces, so nothing stops the bar from carrying a brand accent while the table itself stays neutral, or vice versa.

eun-table follows the full WAI-ARIA grid pattern: role="grid" on the table itself, role="row" on the header and every item, role="columnheader"/role="gridcell" on their cells, and 2D roving-tabindex keyboard navigation across the whole grid, header included. This is a deliberate step further than a plain <table> + scattered ARIA attributes, so a keyboard user gets the same arrow-key navigation a mouse user gets by pointing.

Keyboard interactions

Key Action
Tab / Shift+Tab Enters/exits the grid at its single roving cell, then, once on a cell with focusable content (a checkbox, a button, a link), moves into/out of that content next
ArrowLeft / ArrowRight Moves the roving focus to the previous/next cell in the current row
ArrowUp / ArrowDown Moves the roving focus to the same column in the previous/next row (the header counts as row 0)
Home / End Moves to the first/last cell of the current row
Ctrl+Home / Ctrl+End Moves to the first cell of the header / last cell of the last row
Space (a row, selectable) Toggles that row's selection
Space (the header's select-all cell) Toggles select all / deselect all
Enter / Space (a row's action button/dropdown, once reached via Tab) Activates it: native button/dropdown behavior, nothing custom
Escape (action bar open, clear-on-escape set) Clears the selection, off by default so it never fights a page's own global Escape handling

Cells are read/act surfaces, not inline-editable ones: arrow keys move the roving stop to a cell's own wrapper, and Tab from there enters whatever focusable content that cell holds: the WAI-ARIA grid pattern's own allowance for a simpler model in grids that aren't a spreadsheet-style editor. A cell holding only static text (no focusable content) is simply skipped by Tab, reached only by the arrow keys.

Hover-reveal content stays reachable by keyboard

Unlike a plain :hover-only reveal, both the checkbox and the row-end action also reveal on :focus-within: a keyboard user tabbing toward either sees it appear before activating it, the same as a mouse user hovering does. Once any row is selected, the row-end action cedes to the floating action bar (opacity: 0) rather than sitting hidden-but-focusable underneath it. checkboxAlwaysVisible/actionAlwaysVisible (see "Always-visible checkbox and row-end action" in the Examples tab) sidestep hover/focus-reveal entirely for either one, useful in particular for touch screens, where :hover never triggers on its own at all.

The floating action bar

eun-table-action-bar is role="toolbar" with its own aria-label (see Action bar: Accessibility). It's portaled directly onto document.body (not a descendant of eun-table in the DOM), so it always renders above everything else regardless of any overflow/transform on an ancestor: the same technique used for this library's own tooltips/dropdown menus. Its selection-count badge carries its own accessible label, turning it into a live region: screen readers hear the new count every time it changes, not just once on first encounter.

Method 3: native table

native-table.css is intentionally a visual skin only: it adds no ARIA of its own, since a plain <table>/<thead>/<tbody>/<tr>/<th>/<td> already carries correct table semantics natively, with no roving-tabindex grid pattern to layer on top (there's nothing to select or act on without JavaScript you write yourself). The one thing the stylesheet can't do for you: give every header <th> a scope="col" yourself, so assistive tech associates each column's cells with its header: see the usage comment at the top of native-table.css, and every code sample on this page already does it.