Eunomia v1.1.0
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

Table

Table wraps a native <table>. You write ordinary <thead>, <tbody>, <tfoot>, <tr>, <th>, and <td> elements, put any content in the cells (tags, icons, buttons, dropdown buttons, links), and eun-table styles them and adds row selection with a floating action bar, row end actions revealed on hover, clickable rows, sticky header, footer, and columns, loading placeholders, and scroll-driven pagination. The table keeps its native semantics, so assistive technology reads it as a real table, and it renders before any script runs.

Dependencies

eun-checkbox · renders the select-all control and every row's own checkbox, once selectable is set eun-skeleton · renders the placeholder rows shown while loading eun-loader · renders the trailing indicator shown while loadingMore
Overview API Examples Accessibility
Name Role Department Status Actions
Ada LovelaceEngineering LeadEngineeringActive
Grace HopperPrincipal EngineerEngineeringActive
Alan TuringResearch ScientistResearchAway
Katherine JohnsonData ScientistResearchOffline
Margaret HamiltonSoftware ArchitectEngineeringActive
Radia PerlmanNetwork EngineerInfrastructureActive
Hedy LamarrProduct ManagerProductAway
Barbara LiskovDistinguished EngineerEngineeringActive

Scroll the table above to see the header and the first column stay pinned. Hover or focus a row to reveal its checkbox and its menu, then select one or more rows to bring up the floating action bar with a live count, "Select all", and three custom actions.

When to use

Reach for table to show records in rows and columns, whether they're a static reference (a pricing grid, a legal retention schedule) or data a user acts on (selecting rows for a bulk action, opening a row, triggering a per-row menu). A table with none of its optional behaviors is a plain, read-only table, so the same element covers both.

Write the <table> yourself whenever you render your rows from a template (a server page, a framework component, a CMS). Hand eun-table a columns/data pair instead when your rows are an array in a plain script and you'd rather not build the markup by hand.

Table has no sorting, filtering, or pagination of its own. Sort your own data, reflect it with aria-sort on the header cell, and pair the table with pagination for paged data.

Install & usage

npm install @eunomia/elements
import "@eunomia/elements/table.js";
<eun-table selectable aria-label="Team members">
  <table>
    <thead>
      <tr>
        <th scope="col">Name</th>
        <th scope="col" style="width: 120px">Status</th>
      </tr>
    </thead>
    <tbody>
      <tr data-row-key="ada">
        <td>Ada Lovelace</td>
        <td><eun-tag severity="success">Active</eun-tag></td>
      </tr>
    </tbody>
  </table>
</eun-table>

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

Importing table.js registers everything the table renders on its own: the selection checkboxes, the action bar, the loading placeholders, and the buttons generated by rowAction. Import every other element you put in a cell yourself, such as tag.js or dropdown-button.js.

Every text the table renders (the checkbox labels, the action bar, the loading announcements) follows locale, or the page default set with setDefaultLocale(). Set one of the *-label attributes to override a single text.

Server rendering

The table is in the light DOM, so it's already readable before the component loads. To have it look final too, link table-ssr.css: it holds the exact rules eun-table applies once registered. Until then, it also reserves what the component adds on upgrade, so the table doesn't move: the selection column while selectable is set, the loading rows while loading is set (skeleton-rows from 1 to 12), and --table-max-height.

<link
  rel="stylesheet"
  href="node_modules/@eunomia/elements/dist/ssr/table-ssr.css"
/>
import "@eunomia/elements/table-ssr.css";

Responsive behavior

eun-table is its own scroll container, so a table wider than the space it's given scrolls horizontally on any viewport with no extra setup. sticky-first-column keeps the identifying column in view while the rest scrolls underneath, the selection column stays pinned too, and --table-max-height bounds the height so long tables scroll vertically instead of pushing the page down. The floating action bar spans the screen and wraps onto more than one line below 600px, and the table keeps that room free under itself so the bar never hides its last rows.

While the table scrolls sideways, a soft shadow marks each edge with more columns past it, drawn beside any sticky column. A scrolling table can also take keyboard focus and be scrolled with the arrow keys, announced by its <caption> or aria-label.

The table below is held to a phone's width. Scroll it sideways: the order number stays pinned, a shadow shows the columns left on each side, and the header stays in view as it scrolls down. Tab to it to scroll it with the arrow keys.

Recent orders
Order Customer City Total Date Status
A-1042Ada LovelaceLondon€ 389.002026-10-01Shipped
A-1043Alan TuringManchester€ 459.002026-10-02Pending
A-1044Grace HopperNew York€ 1,120.002026-10-03Shipped
A-1045Hedy LamarrVienna€ 612.502026-10-04Cancelled
A-1046Katherine JohnsonHampton€ 248.902026-10-05Shipped
A-1047Margaret HamiltonBoston€ 975.002026-10-06Pending
<eun-table sticky-header sticky-first-column style="--table-max-height: 240px;">
  <table>
    <caption>
      Recent orders
    </caption>
    <thead>
      <tr>
        <th scope="col">Order</th>
        <!-- … more columns, wider than the table … -->
        <th scope="col">Status</th>
      </tr>
    </thead>
    <tbody>
      <tr data-row-key="A-1042">
        <th scope="row">A-1042</th>
        <!-- … -->
      </tr>
    </tbody>
  </table>
</eun-table>

Guidance

  • Give every header cell scope="col", and the identifying cell of each row <th scope="row">
  • Set a stable data-row-key on every body row once rows can be sorted, filtered, or paginated
  • Name the table with a <caption> or an aria-label whenever a page holds more than one
  • Keep a row end action to a single button or dropdown button with its own aria-label
  • Set --table-max-height whenever the header or footer is sticky, so the table has something to scroll
  • Building layouts with a table: it's for tabular data only
  • Putting a row end action without a matching header cell: the columns would no longer line up
  • Pinning both the first and the last column on a narrow table: the two sticky bands can cover the scrolling middle
  • Expecting built-in sort, filter, or pagination: sort your own rows and pair the table with eun-pagination

Live testing

Properties

Table <eun-table>

Attributes

NameTypeDefaultDescription
row-numbersbooleanfalseShows each body row's 1-based position in a generated leading column, after the selection column
row-number-aria-labelstring'Row number'Accessible label of the row number column's header ; translated from `locale` while unset
checkbox-always-visiblebooleanfalseKeeps every row's own checkbox visible at all times instead of only on hover/focus/selection
action-always-visiblebooleanfalseKeeps every row end action (a `data-table-action` cell's content) visible at all times instead of only on hover/focus ; still hidden while rows are selected
sticky-headerbooleanfalsePins the `<thead>` to the top of the table's own scroll container
sticky-footerbooleanfalsePins the `<tfoot>` to the bottom of the table's own scroll container
sticky-first-columnbooleanfalsePins the first data column to the start edge
sticky-last-columnbooleanfalsePins the last data column, and the row end action column, to the end edge
hide-action-barbooleanfalseKeeps the floating action bar from showing while rows are selected, for selection controls of your own built on selectedKeys and euntableselectionchange
action-bar-viewport-centered-below'mobile' | 'tablet' | 'laptop' | 'desktop' | number—Centers the action bar on the screen instead of the table below this width, a breakpoint name or a number of pixels. --table-action-bar-viewport-centered-below sets it from CSS too
collapsible-action-barbooleanfalseShrinks the action bar's count badge and select-all button to their number and icon when the bar lacks room
clear-on-escapebooleanfalseWhether `Escape` clears the selection while rows are selected
selected-labelstring'selected'Text shown after the count in the action bar's badge ; translated from `locale` while unset
select-all-labelstring'Select all'Label of the action bar's select-all button while not every row is selected ; translated from `locale` while unset
deselect-all-labelstring'Deselect all'Label of the action bar's select-all button once every row is selected ; translated from `locale` while unset
select-all-aria-labelstring'Select all rows'Accessible label for the header's select-all checkbox while not every row is selected ; translated from `locale` while unset
deselect-all-aria-labelstring'Deselect all rows'Accessible label for the header's select-all checkbox once every row is selected ; translated from `locale` while unset
select-row-aria-labelstring'Select row'Accessible label prefix for each row's own checkbox, followed by the row header's text, or its 1-based row number without one ; translated from `locale` while unset
toolbar-aria-labelstring'Selection actions'Accessible label for the floating action bar itself ; translated from `locale` while unset
close-action-bar-aria-labelstring'Clear selection'Accessible label for the action bar's close button ; translated from `locale` while unset
skeleton-rowsnumber6Number of placeholder rows rendered while `loading`
page-sizenumber20Number of rows per page, for both `eunloadmore` and `virtualize`
virtualize-thresholdnumber100Row count above which `virtualize` starts detaching pages
buffer-pagesnumber1Number of extra pages kept mounted before and after the page in view
has-morebooleanfalseWhether more rows can still be obtained
loading-morebooleanfalseWhether a `eunloadmore` request is in flight ; set to `true` right before the event fires, reset by the consumer once rows are appended
root-marginstring'400px'How far before the 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` ; translated from `locale` while unset
selectablebooleanfalseEnables row selection (generated checkbox column, select-all, the floating action bar)
loadingbooleanfalseShows `skeletonRows` placeholder rows in place of the body rows ; the header stays visible
syncingbooleanfalseSweeps a shimmer across every visible body row without hiding any, for a background refresh. Sets `aria-busy` on the table while set
density'compact' | 'comfortable'—Tightens or loosens the vertical padding of every row, header included. Left unset, rows keep the default spacing. The padding custom properties still take precedence
clickablebooleanfalseShows a pointer cursor on body rows ; a row click fires `euntablerowclick` and follows the row's `data-table-row-link` link regardless, which stays the keyboard way to do the same
infinitebooleanfalseEnables scroll-triggered pagination : dispatches `eunloadmore` once the user scrolls near the end of the table, in its own scroll container or the page, and `hasMore` is `true`
virtualizebooleanfalseDetaches body row pages far from the current scroll position once the row count exceeds `virtualizeThreshold`, each replaced by a spacer row of the same height

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 authoring the `<table>` : when set, `eun-table` renders the native table itself from `columns`/`data`, and owns its light DOM from then on
dataArray<T>[]The rows to render, in "managed" mode (see `columns`)
rowActionEunomiaTableRowActionDef<T>—A single row end action (button or dropdown), in "managed" mode
rowKey(row: T, index: number) => string—Computes each row's stable key, in "managed" mode ; defaults to a DOM-order fallback
selectedKeysArray<string>[]The currently selected rows' keys ; settable from outside for a "controlled" table, without firing euntableselectionchange
loadingBeforeUpgrade—
resolvedLocale—This instance's own `locale` when set, the shared default locale otherwise. Used for `Intl` formatting, so dates and numbers follow the same locale as dictionary.
dictionary—This instance's locale-resolved string dictionary — see the class doc for the resolution order.

Slots

NameDescription
(default)A native `<table>`, with a `<thead>`, a `<tbody>`, and an optional `<caption>` and `<tfoot>`
emptyShown below the header while the body has no row and the table isn't loading, such as a "No results" message

Events

NameTypeDescription
euntableselectionchangeTableSelectionChangeEventEmitted whenever the user changes the selection, through a checkbox, select-all, a row click, the action bar, or Escape. Not emitted when selectedKeys is set
euntableactionTableActionEventEmitted when a floating action bar action is activated
euntablerowclickTableRowClickEventEmitted on a body row click outside its checkbox, its action cell, and any interactive content, while no row is selected ; cancelable, `preventDefault()` keeps the row's `data-table-row-link` link from being followed
eunloadmoreLoadMoreEventDispatched when `infinite` is set, more rows are needed, and `hasMore` is `true`

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

CSS custom properties

NameDescription
--table-border-colorSets the border color of the frame and the rows
--table-border-widthSets the border width of the frame and the row dividers
--table-border-radiusSets the corner radius of the frame
--table-empty-paddingSets the padding around the empty slot's content
--table-empty-colorSets the text color of the empty slot's content
--table-backgroundSets the background color of the frame
--table-max-heightSets a max height, scrolling past it ; `none` by default
--table-scroll-shadowSets the color of the shadow on an edge with more columns to scroll to
--table-scroll-shadow-sizeSets the width of that shadow ; `12px` by default
--table-focus-outlineSets the focus outline color of the frame, focusable once it scrolls
--table-caption-colorSets the text color of the `<caption>`
--table-caption-font-sizeSets the font size of the `<caption>`
--table-caption-font-weightSets the font weight of the `<caption>`
--table-header-backgroundSets the background color of the header cells
--table-footer-backgroundSets the background color of the footer cells
--table-row-backgroundSets the background color of the body cells
--table-row-hover-backgroundSets the tint of a hovered or focused body row
--table-row-selected-backgroundSets the tint of a selected body row
--table-row-dragging-backgroundSets the tint of a row being reordered with `createDragReorder`
--table-row-transition-durationSets the duration of the hover and selection tint transitions
--table-header-colorSets the text color of the header cells
--table-column-colorSets the text color of the header cells, while `--table-header-color` is unset
--table-column-font-weightSets the font weight of the header cells
--table-header-padding-blockSets the vertical padding of the header cells, the body cells' own by default
--table-header-padding-inlineSets the horizontal padding of the header cells, the body cells' own by default
--table-header-font-sizeSets the font size of the header cells, the body cells' own by default
--table-header-text-transformSets the text transform of the header cells, such as uppercase
--table-header-letter-spacingSets the letter spacing of the header cells
--table-cell-colorSets the text color of the cells
--table-cell-padding-blockSets the vertical padding of the cells. Takes precedence over density
--table-cell-padding-inlineSets the horizontal padding of the cells
--table-cell-font-sizeSets the font size of the cells
--table-row-min-heightSets a minimum height for every body row, so rows keep the same height whatever their content
--table-column-border-colorSets the color of a vertical divider between columns ; transparent by default
--table-sticky-column-backgroundSets the background color of a sticky column's cells, and of the selection column
--table-sticky-row-backgroundSets the background color of the footer cells while `stickyFooter`
--table-sticky-border-colorSets the color of the edges of a sticky header, footer, or column
--table-checkbox-backgroundSets the background color of an unchecked selection checkbox
--table-checkbox-border-colorSets the border color of an unchecked selection checkbox
--table-checkbox-hoverSets the background color of an unchecked selection checkbox while hovered
--table-checkbox-sizeSets the size of the selection checkboxes
--table-checkbox-border-radiusSets the corner radius of the selection checkboxes
--table-action-colorSets the text and icon color of a row end action
--table-action-icon-sizeSets the icon size of a row end action button, which also sets its line height
--eun-table-select-col-widthSets the width of the selection column ; 40px by default
--eun-table-row-number-col-widthSets the width of the row number column ; 48px by default
--table-row-number-colorSets the text color of the row numbers
--eun-table-action-col-widthSets the width of the row end action column, which stays fixed so a sticky last column lines up with it ; 56px by default, enough for an icon-only button
--table-skeleton-row-heightSets the height of the placeholder rows shown while loading, the row minimum height by default. Also reserves their room before eun-table is defined
--table-skeleton-staggerDelay between each placeholder row's entrance animation, as a CSS time (e.g. `60ms`)
--table-skeleton-durationDuration of each placeholder row's entrance animation, as a CSS time (e.g. `320ms`)
--table-loader-colorSets the color of the trailing `eun-loader` shown while `loadingMore`
--table-sync-overlay-colorSets the highlight color of the `syncing` shimmer sweep
--table-sync-durationSets the duration of the `syncing` shimmer sweep, as a CSS time (default `1.6s`)

CSS shadow parts

NameDescription
frameThe bordered scroll container around the table

Markup conventions

Markup Meaning
<tr data-row-key="…"> The row's stable identity, used for selection and euntablerowclick. Falls back to its DOM order
<th> / <td data-table-action> The row end action column, revealed on hover or focus and hidden while rows are selected
data-table-row-link The row's main link, which a click anywhere on the row follows
<tfoot> Summary rows (totals), kept out of the selection, pinned with sticky-footer
slot="empty" The message shown while the body has no row, such as "No results"
<th scope="row"> A row header, announced along with every cell of its row
aria-sort on a <th> The column's sort state, presentation only
.eun-table-visually-hidden Hides a label visually while keeping it for assistive technology, such as the action column's name

eun-table generates the selection column itself: a <th data-table-select> holding the select-all checkbox in the header, a <td data-table-select> holding each row's checkbox, and an empty one in each footer row. While loading, it also adds a <tbody data-table-skeleton> of placeholder rows.

The eun-table-action-bar API is documented on its own page.

Data

Read-only table

With none of its optional behaviors, eun-table is a styled native table: no focus stop, no generated cell, nothing to interact with. A <caption> names it, and shows above the table's frame while staying part of the table for assistive technology.

Data retention
Data Purpose Retention
Contact formAnswering your request3 years
OrdersAccounting obligations10 years
Analytics cookiesAudience measurement13 months
<eun-table>
  <table>
    <caption>
      Data retention
    </caption>
    <thead>
      <tr>
        <th scope="col">Data</th>
        <th scope="col">Purpose</th>
        <th scope="col" style="width: 160px">Retention</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row">Contact form</th>
        <td>Answering your request</td>
        <td>3 years</td>
      </tr>
    </tbody>
  </table>
</eun-table>

Programmatic columns and data

Set columns, data, and rowKey, and eun-table renders the same native table itself. A column's render returns any content, such as a template, a node, or a string, and rowAction adds a row end button or dropdown button computed per row. Setting columns hands the table's light DOM over to eun-table, so don't also write rows by hand.

const table = document.querySelector("eun-table");
table.columns = [
  { key: "name", label: "Name" },
  { key: "role", label: "Role", width: "200px" },
  { key: "status", label: "Status", width: "120px", render: statusTag },
];
table.data = rows;
table.rowKey = (row) => row.id;
table.rowAction = {
  type: "dropdown",
  label: "Row actions",
  icon: "more_horiz",
  options: () => [
    { key: "edit", label: "Edit" },
    { key: "delete", label: "Delete" },
  ],
  onSelect: (key, row) => console.log(key, row.name),
};

In a Lit template, bind the same values as properties:

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

Columns

Column widths and alignment

Columns follow the native table layout: they size to their content and share the remaining width. Set a width on a header cell, or use a <colgroup>, to fix one, and text-align on the cells to align a column. A width still shrinks when space runs out; set a min-width on the header cell instead to keep the column wide, so the table scrolls horizontally.

Product Quantity Price
Mixer tap2€ 189.00
Shower column1€ 459.00
<table>
  <colgroup>
    <col />
    <col style="width: 120px" />
    <col style="width: 140px" />
  </colgroup>
  <thead>
    <tr>
      <th scope="col">Product</th>
      <th scope="col" style="text-align: end">Quantity</th>
      <th scope="col" style="text-align: end">Price</th>
    </tr>
  </thead>
  ...
</table>

Sticky header, footer, and columns

sticky-header pins the <thead>, sticky-footer pins the <tfoot>, and sticky-first-column and sticky-last-column pin the first and last data columns. They stick to eun-table itself, so bound its height with --table-max-height.

Month Orders Returns Revenue
January1204€ 18,400
February982€ 15,100
March1346€ 21,250
April1423€ 22,900
May1515€ 24,300
June1274€ 19,800
Total77224€ 121,750
<eun-table
  sticky-header
  sticky-footer
  sticky-first-column
  style="--table-max-height: 220px;"
>
  <table>
    <thead>
      ...
    </thead>
    <tbody>
      ...
    </tbody>
    <tfoot>
      <tr>
        <th scope="row">Total</th>
        <td>772</td>
        <td>24</td>
        <td>€ 121,750</td>
      </tr>
    </tfoot>
  </table>
</eun-table>

Sticky columns

When the columns are wider than the table, scroll it horizontally: sticky-first-column keeps the first data column pinned to the start edge, right after the selection column, and sticky-last-column keeps the last data column pinned to the end edge, right before the row end action column, which stays pinned with it.

Order Customer City Items Total Payment Date Status Actions
A-1042Ada LovelaceLondon2€ 389.00Card2026-10-01Shipped
A-1043Alan TuringManchester1€ 459.00Transfer2026-10-02Pending
A-1044Grace HopperNew York4€ 1,120.00Card2026-10-03Shipped
A-1045Hedy LamarrVienna3€ 612.50PayPal2026-10-04Cancelled
<eun-table selectable sticky-first-column sticky-last-column>
  <table>
    <thead>
      <tr>
        <th scope="col" style="min-width: 160px">Order</th>
        <!-- … more columns, wider than the table … -->
        <th scope="col" style="min-width: 160px">Status</th>
        <th scope="col" data-table-action>
          <span class="eun-table-visually-hidden">Actions</span>
        </th>
      </tr>
    </thead>
    <tbody>
      <tr data-row-key="A-1042">
        <th scope="row">A-1042</th>
        <!-- … -->
        <td><eun-tag severity="success">Shipped</eun-tag></td>
        <td data-table-action>…</td>
      </tr>
    </tbody>
  </table>
</eun-table>

Row selection

selectable adds a checkbox column. A row's checkbox shows on hover or focus, and stays visible once any row is selected. On touch screens and up to tablet width, where there's no hover to reveal it, every checkbox is always shown. While rows are selected, a click anywhere on a row toggles it, and the floating action bar shows the count, a "Select all" toggle, your actions, and a close button. Set clear-on-escape to also clear the selection with Escape.

NameStatus
Ada LovelaceActive
Alan TuringAway
Grace HopperActive

Selected: none

<eun-table selectable clear-on-escape>
  <table>
    <thead>
      ...
    </thead>
    <tbody>
      <tr data-row-key="ada">
        ...
      </tr>
      <tr data-row-key="alan">
        ...
      </tr>
    </tbody>
  </table>
</eun-table>

<script type="module">
  const table = document.querySelector("eun-table");
  table.actions = [
    { key: "export", label: "Export", icon: "cloud_download" },
    {
      key: "status",
      label: "Set status",
      dropdown: [
        { key: "active", label: "Active" },
        { key: "away", label: "Away" },
      ],
    },
  ];
  table.addEventListener("euntableaction", (event) => {
    console.log(event.key, event.optionKey, table.selectedKeys);
  });
</script>

Set selectedKeys to control the selection from outside. Like a native field's value, setting it doesn't fire euntableselectionchange, which only reports the user's own changes, so a controlled table can write back what the event reported without looping.

table.addEventListener("euntableselectionchange", (event) => {
  setSelection(event.selectedKeys);
});
// later, from your own state
table.selectedKeys = selection;

Action bar on narrow screens

When the actions don't fit, the action bar's center scrolls sideways. Add collapsible-action-bar to shrink its count badge and select-all button to their number and icon first, once the bar lacks room. See Action bar: Narrow screens.

<eun-table selectable collapsible-action-bar></eun-table>

Custom selection controls

hide-action-bar keeps the floating action bar from showing, for selection controls of your own, such as a toolbar above the table. The checkboxes, row clicks, and clear-on-escape keep working the same. Build the controls on selectedKeys and euntableselectionchange: the event reports the user's changes, and since setting selectedKeys doesn't fire it, refresh your controls right after setting it.

Select all Clear Archive
NameStatus
Ada LovelaceActive
Alan TuringAway
Grace HopperActive
Katherine JohnsonOffline
<div class="selection-toolbar">
  <span id="count"></span>
  <eun-button id="select-all">Select all</eun-button>
  <eun-button id="clear">Clear</eun-button>
  <eun-button id="archive" color="critical">Archive</eun-button>
</div>
<eun-table selectable hide-action-bar clear-on-escape>
  <table>
    ...
  </table>
</eun-table>

<script type="module">
  const table = document.querySelector("eun-table");
  const refresh = () => {
    const selected = table.selectedKeys.length;
    count.textContent = `${selected} selected`;
    archive.disabled = selected === 0;
  };
  // Only the user's own changes fire the event
  table.addEventListener("euntableselectionchange", refresh);
  // Setting selectedKeys doesn't, so refresh right after
  clear.addEventListener("click", () => {
    table.selectedKeys = [];
    refresh();
  });
</script>

Rows

Row numbers

row-numbers shows the position of each row in a narrow column before the data, right after the selection checkboxes when the table is selectable. Numbers follow the current order of the rows, so they update on their own when rows are sorted, reordered, or loaded with infinite scroll. The column stays pinned to the start edge, its header reads "#" and is announced as "Row number", a label row-number-aria-label replaces.

NameStatus
Ada LovelaceActive
Grace HopperActive
Linus TorvaldsAway
<eun-table row-numbers>
  <table>
    …
  </table>
</eun-table>

Row end actions

Mark the last cell of each row with data-table-action and put a single button or dropdown button in it. The action shows on hover or focus, and is hidden and inert while rows are selected, ceding its place to the action bar. Give the column a header cell too, with a visually hidden name, so the columns line up and the column is announced.

The button can sit in an eun-tooltip naming it, which is shown and hidden along with it. While its dropdown menu is open, the row keeps its hover look, so the action stays visible as the pointer moves onto the menu.

File Size Actions
report.pdf 2.4 MB
photo.jpg 840 KB
<tr data-row-key="photo">
  <td>photo.jpg</td>
  <td>840 KB</td>
  <td data-table-action>
    <eun-dropdown-button
      size="xs"
      appearance="ghost"
      icon="more_horiz"
      aria-label="Actions for photo.jpg"
    >
      <eun-dropdown-item slot="items" label="Rename"></eun-dropdown-item>
      <eun-dropdown-item slot="items" label="Delete"></eun-dropdown-item>
    </eun-dropdown-button>
  </td>
</tr>

On touch screens and up to tablet width, every action is always shown, since there's no hover to reveal it. action-always-visible keeps every action visible on larger screens too, and checkbox-always-visible does the same for the selection checkboxes.

Clickable rows

A click on a body row fires euntablerowclick with its key, unless it lands on the checkbox, the action cell, or interactive content such as a link or a button. Mark the row's main link with data-table-row-link and the row click follows it too, keeping Ctrl/Cmd for a new tab. That link is how keyboard and screen reader users open the row: the row itself is never a focus stop, since a row has no role to activate. clickable shows a pointer cursor. Call preventDefault() on euntablerowclick to handle the click yourself instead of following the link.

OrderCustomerStatus
A-1042Ada LovelaceShipped
A-1043Alan TuringPending

Opened: none

<eun-table clickable>
  <table>
    <thead>
      ...
    </thead>
    <tbody>
      <tr data-row-key="A-1042">
        <th scope="row">
          <a href="/orders/A-1042" data-table-row-link>A-1042</a>
        </th>
        <td>Ada Lovelace</td>
      </tr>
    </tbody>
  </table>
</eun-table>

Reordering rows

eun-table's rows are native <tr>s, so the createDragReorder utility attaches to the <tbody> like to any list. Grab a row by its handle and drag it, or focus the handle and press Space, then the arrow keys, then Space again. The handle can be any focusable element, an eun-button here. Near the edge of the table, dragging scrolls the table itself, and the selection follows the rows through their data-row-key.

On-call escalation order
Reorder Name Team
Ada LovelacePlatform
Alan TuringResearch
Grace HopperCompilers
Katherine JohnsonData
Margaret HamiltonFlight software

Order:

<eun-table>
  <table>
    <thead>
      <tr>
        <th scope="col" style="width: 48px">
          <span class="eun-table-visually-hidden">Reorder</span>
        </th>
        <th scope="col">Name</th>
      </tr>
    </thead>
    <tbody>
      <tr data-row-key="ada">
        <td>
          <eun-button
            class="drag-handle"
            size="xs"
            appearance="ghost"
            icon="drag_indicator"
            aria-label="Reorder Ada Lovelace"
          ></eun-button>
        </td>
        <th scope="row">Ada Lovelace</th>
      </tr>
    </tbody>
  </table>
</eun-table>
import { createDragReorder } from "@eunomia/elements";

const body = document.querySelector("eun-table tbody");
createDragReorder(body, {
  items: () => Array.from(body.rows),
  handle: ".drag-handle",
  onReorder: ({ order }) => {
    saveOrder(order.map((row) => row.dataset.rowKey));
  },
});

virtualize takes rows out of the DOM, which createDragReorder can't account for: keep reordering to tables whose rows are all rendered.

Loading and empty states

Loading and syncing

loading hides the body rows and shows skeleton-rows placeholder rows (6 by default), shaped after the header so nothing shifts once data arrives. syncing keeps the rows visible and sweeps a shimmer across them, for a background refresh, and sets aria-busy on the table.

Toggle loading Toggle syncing
NameStatus
Ada LovelaceActive
Alan TuringAway
table.loading = true;
const rows = await fetchRows();
renderRows(rows);
table.loading = false;

Empty state

Content slotted into empty shows below the header, across the whole frame, while the body has no row. It stays out of the selection, the select-all checkbox is disabled, and the columns keep their place. It's hidden while loading, so the placeholder rows show instead, and works the same in programmatic mode once data is empty. The host carries an empty attribute meanwhile, for any styling of your own.

NameStatus

No results match your filters.

<eun-table selectable>
  <table>
    <thead>
      <tr>
        <th scope="col">Name</th>
        <th scope="col">Status</th>
      </tr>
    </thead>
    <tbody></tbody>
  </table>
  <p slot="empty">No results match your filters.</p>
</eun-table>

Infinite scroll and virtualization

infinite fires eunloadmore once the user scrolls near the end of the table and has-more is set, sets loadingMore right before, and shows a trailing loader until you reset it. Append the new rows to the <tbody>, then set loadingMore back to false, and hasMore to false once there's nothing left.

#Result
table.addEventListener("eunloadmore", async (event) => {
  const { rows, hasMore } = await fetchPage(event.page + 1, event.pageSize);
  appendRows(table.querySelector("tbody"), rows);
  table.loadingMore = false;
  table.hasMore = hasMore;
});

Virtualize

virtualize keeps very long tables light by keeping only the rows near the visible area in the DOM:

  1. eun-table tracks every row of the first <tbody>, in or out of the DOM, and splits them into pages of page-size rows.
  2. Up to virtualize-threshold rows (100 by default), nothing is detached.
  3. Past it, on every scroll and resize, it finds the page at the middle of the visible area, from each page's measured height, or an average for pages never measured yet, and keeps it mounted along with buffer-pages pages on each side.
  4. Every other page is measured, its rows are detached and kept in memory, and a single spacer row of the same height takes their place, so the scrollbar doesn't jump.
  5. Once a page comes back into view, the very same row nodes are put back, with their listeners and component state intact.

Selection and select-all cover every row, mounted or not, and rows you append to the <tbody> are picked up on their own. The table must scroll by itself, so bound its height with --table-max-height: when the page scrolls instead, eun-table can't tell which rows are visible.

The demo below loads 5,000 rows by pages of 50 with infinite, and virtualize keeps about 150 real rows in the DOM at most. Scroll it and watch the counters under it.

Orders
Order Customer Total Status Actions

The complete page:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link
      rel="stylesheet"
      href="node_modules/@eunomia/elements/dist/ssr/table-ssr.css"
    />
    <script type="module">
      import "@eunomia/elements/table.js";
      import "@eunomia/elements/tag.js";
      import "@eunomia/elements/button.js";
      import "@eunomia/elements/icon.js";
    </script>
  </head>
  <body>
    <eun-table
      id="orders"
      selectable
      sticky-header
      sticky-first-column
      infinite
      has-more
      virtualize
      page-size="50"
      virtualize-threshold="200"
      buffer-pages="1"
      style="--table-max-height: 480px;"
    >
      <table>
        <caption>
          Orders
        </caption>
        <thead>
          <tr>
            <th scope="col" style="min-width: 120px">Order</th>
            <th scope="col" style="min-width: 200px">Customer</th>
            <th scope="col" style="min-width: 120px">Total</th>
            <th scope="col" style="min-width: 120px">Status</th>
            <th scope="col" data-table-action>
              <span class="eun-table-visually-hidden">Actions</span>
            </th>
          </tr>
        </thead>
        <tbody></tbody>
      </table>
    </eun-table>

    <script type="module">
      const table = document.querySelector("#orders");
      const body = table.querySelector("tbody");
      const TOTAL = 5000;

      // Stands in for a paginated API: replace it with your own fetch.
      function fetchPage(page, pageSize) {
        return new Promise((resolve) => {
          setTimeout(() => {
            const start = (page - 1) * pageSize;
            const rows = Array.from(
              { length: Math.min(pageSize, TOTAL - start) },
              (_, index) => {
                const id = start + index + 1;
                return {
                  id: `C-${id}`,
                  customer: `Customer ${id}`,
                  total: (id * 7.3) % 900,
                  shipped: id % 3 !== 0,
                };
              },
            );
            resolve({ rows, hasMore: start + pageSize < TOTAL });
          }, 400);
        });
      }

      function renderRow(order) {
        const row = document.createElement("tr");
        row.dataset.rowKey = order.id;

        const id = document.createElement("th");
        id.scope = "row";
        id.textContent = order.id;

        const customer = document.createElement("td");
        customer.textContent = order.customer;

        const total = document.createElement("td");
        total.textContent = `€ ${order.total.toFixed(2)}`;

        const status = document.createElement("td");
        const tag = document.createElement("eun-tag");
        tag.severity = order.shipped ? "success" : "warning";
        tag.textContent = order.shipped ? "Shipped" : "Pending";
        status.append(tag);

        const action = document.createElement("td");
        action.dataset.tableAction = "";
        const button = document.createElement("eun-button");
        button.size = "xs";
        button.appearance = "ghost";
        button.setAttribute("aria-label", `Open ${order.id}`);
        button.innerHTML =
          '<eun-icon name="arrow_forward" size="16"></eun-icon>';
        button.addEventListener("click", () => console.log("open", order.id));
        action.append(button);

        row.append(id, customer, total, status, action);
        return row;
      }

      function appendRows(rows) {
        const fragment = document.createDocumentFragment();
        rows.forEach((order) => fragment.append(renderRow(order)));
        body.append(fragment);
      }

      // The first page, before any scroll.
      table.loading = true;
      const first = await fetchPage(1, table.pageSize);
      appendRows(first.rows);
      table.hasMore = first.hasMore;
      table.loading = false;

      // event.page counts the requests: 1 on the first, then 2…
      // Page 1 is already loaded, so ask for event.page + 1.
      table.addEventListener("eunloadmore", async (event) => {
        const { rows, hasMore } = await fetchPage(
          event.page + 1,
          event.pageSize,
        );
        appendRows(rows);
        table.loadingMore = false;
        table.hasMore = hasMore;
      });
    </script>
  </body>
</html>

Since it moves rows in and out of the DOM, reserve virtualize for rows added with plain JavaScript or rendered by columns/data: a framework (React, Angular, Vue) re-rendering the rows would lose track of them, so virtualize inside the framework instead. The browser's find in page only sees the rows currently in the DOM, while assistive technology still gets the real row count and positions through aria-rowcount and aria-rowindex.

Customization

Density and sizing

density="compact" tightens the vertical padding of every row, header included, and density="comfortable" loosens it. For finer control, the header and the cells each have their own padding and font size custom properties, and the header can switch to capitals with --table-header-text-transform. --table-row-min-height keeps every row at the same height whatever it holds, such as a tag or a button, and the loading rows follow it. The frame's border and corners, the selection checkboxes, and the row end action icon have their own sizes too, all listed in the API tab.

SizeFlowPressure
1/2"12 L/min3 bar
3/4"20 L/min3 bar
SizeFlowPressure
1/2"12 L/min3 bar
3/4"20 L/min3 bar
<eun-table density="compact">…</eun-table>
eun-table {
  --table-header-padding-block: var(--eun-space-m);
  --table-header-font-size: var(--eun-font-size-xs);
  --table-header-text-transform: uppercase;
  --table-header-letter-spacing: 0.04em;
  --table-row-min-height: 48px;
}

Custom appearance

Every color, border, and size is a CSS custom property, listed in the API tab. --table-column-border-color adds vertical dividers between columns, and --table-header-color sets the text color of the header.

SizeFlowPressure
1/2"12 L/min3 bar
3/4"20 L/min3 bar
eun-table {
  --table-column-border-color: var(--eun-border-color);
  --table-header-background: var(--eun-color-grey-100);
  --table-header-color: var(--eun-color-primary-700);
}

A class on your own cells overrides the table's rules too, since they stay at a low specificity.

The custom properties that change colors, borders, shadows and corners, at a glance. The API tab lists every property of the component.

Variable Sets
--table-border-color Sets the border color of the frame and the rows
--table-border-width Sets the border width of the frame and the row dividers
--table-border-radius Sets the corner radius of the frame
--table-empty-color Sets the text color of the empty slot's content
--table-background Sets the background color of the frame
--table-scroll-shadow Sets the color of the shadow on an edge with more columns to scroll to
--table-scroll-shadow-size Sets the width of that shadow ; 12px by default
--table-focus-outline Sets the focus outline color of the frame, focusable once it scrolls
--table-caption-color Sets the text color of the <caption>
--table-header-background Sets the background color of the header cells
--table-footer-background Sets the background color of the footer cells
--table-row-background Sets the background color of the body cells
--table-row-hover-background Sets the tint of a hovered or focused body row
--table-row-selected-background Sets the tint of a selected body row
--table-row-dragging-background Sets the tint of a row being reordered with createDragReorder
--table-header-color Sets the text color of the header cells
--table-column-color Sets the text color of the header cells, while --table-header-color is unset
--table-cell-color Sets the text color of the cells
--table-column-border-color Sets the color of a vertical divider between columns ; transparent by default
--table-sticky-column-background Sets the background color of a sticky column's cells, and of the selection column
--table-sticky-row-background Sets the background color of the footer cells while stickyFooter
--table-sticky-border-color Sets the color of the edges of a sticky header, footer, or column
--table-checkbox-background Sets the background color of an unchecked selection checkbox
--table-checkbox-border-color Sets the border color of an unchecked selection checkbox
--table-checkbox-border-radius Sets the corner radius of the selection checkboxes
--table-action-color Sets the text and icon color of a row end action
--table-row-number-color Sets the text color of the row numbers
--table-loader-color Sets the color of the trailing eun-loader shown while loadingMore
--table-sync-overlay-color Sets the highlight color of the syncing shimmer sweep

eun-table keeps the slotted <table>'s native semantics: assistive technology meets a table with columnheader, rowheader, row, and cell roles, and announces each cell with its headers. It adds no ARIA role of its own and no grid keyboard navigation, so screen readers stay in their usual reading mode and use their own table navigation commands. A read-only table has no focus stop at all.

  • Name the table with a <caption>, or an aria-label on <eun-table> or on the <table>.
  • Give header cells scope="col", and the identifying cell of each row <th scope="row">: each row checkbox is named after it ("Select row Ada Lovelace"), falling back to the row number without one.
  • Reflect the sort state with aria-sort on the sorted column's header.
  • Name the action column with a .eun-table-visually-hidden label, and every icon-only action with its own aria-label.
  • Open a row through a real link marked data-table-row-link, never through the row click alone.

Keyboard and focus

Keyboard interactions

Key Action
Tab / Shift+Tab Moves through the checkboxes, links, buttons, and other controls in the cells, in order
Tab (after the table, rows selected) Reaches the action bar, which comes right after the table
Enter (a row link) Opens the row, like a click on the row
Space (a checkbox) Toggles that row, or every row from the header's checkbox
Space, arrows, Space (a reorder handle) Picks the row up, moves it, and drops it ; Escape puts it back
Escape (rows selected, clear-on-escape) Clears the selection

Content revealed on hover stays reachable

The checkboxes and the row end actions also show on :focus-within, so a keyboard user sees them appear when tabbing in, exactly like a mouse user on hover. On touch screens, which have no hover, and up to tablet widths, they're always shown. While rows are selected, the action cells are made inert, so a hidden action is never focusable. checkbox-always-visible and action-always-visible show them at all times. Unchecked checkboxes keep a border contrasted enough to be seen on any row tint.

Loading and long tables

While loading or syncing, the table carries aria-busy. With infinite, every page of rows that arrives is announced politely ("20 more rows loaded"). With virtualize, the table carries aria-rowcount and every row its aria-rowindex, so a screen reader announces the real size of the table and the position of each row, even though only part of them are in the DOM. The count is unknown (-1) while more rows can still load.

The floating action bar

The action bar is inserted right after the table, so it comes next in the Tab order, and shown as a popover, so it renders above the page whatever its ancestors clip. It's a labelled region whose count is a live status, and when it closes while it holds focus, focus moves back to the table's first checkbox. See Action bar: Accessibility.