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
Dependencies
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
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
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, ornative-table.css's.eun-tableclass) is alreadyoverflow: auto, so whenever it's narrower than its content, it scrolls horizontally with no extra setup on your part, on any viewport. sticky-first-columnkeeps 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/inlinemax-heightso 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: 500pxwould 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-drivencolumns[].renderswap, 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-keyon everyeun-table-itemonce 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-heightwhenever you turn onsticky-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 owneun-table-items and pair witheun-paginationas needed - Combining
sticky-first-columnandsticky-last-columnon 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-tablegenerates itseun-table-headerfrom. Setting this (even to[]) is what puts the table in "managed" mode.data: the row objects to render, oneeun-table-itemper entry.rowKey: computes each row's stable identity from its own data, the "managed" equivalent of hand-settingrow-keyon aeun-table-item.rowAction: a single row-end button/dropdown, computed per row, the "managed" equivalent of slotting aeun-button/eun-dropdown-buttonintoslot="action".selectedKeys: reads/writes the live selection from outside, for a fully controlled table, working the same whether the table is light-DOM-authored orcolumns/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 columns/data used end to end,
with no eun-table-item in sight.
Table <eun-table>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| checkbox-always-visible | boolean | false | Keeps 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-visible | boolean | false | Keeps 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-header | boolean | false | Pins 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-column | boolean | false | Pins the first data column to the start edge |
| sticky-last-column | boolean | false | Pins the last data column to the end edge |
| clear-on-escape | boolean | false | Whether `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-label | string | 'selected' | Text shown after the count in the action bar's badge |
| select-all-label | string | 'Select all' | Label of the action bar's select-all button while not every row is selected |
| deselect-all-label | string | 'Deselect all' | Label of the action bar's select-all button once every row is selected |
| select-all-aria-label | string | 'Select all rows' | Accessible label for the header's select-all checkbox while not every row is selected |
| deselect-all-aria-label | string | 'Deselect all rows' | Accessible label for the header's select-all checkbox once every row is selected |
| select-row-aria-label | string | 'Select row' | Accessible label prefix for each row's own checkbox (its 1-based row number is appended) |
| toolbar-aria-label | string | 'Selection actions' | Accessible label for the floating action bar itself |
| close-action-bar-aria-label | string | 'Clear selection' | Accessible label for the action bar's close button |
| skeleton-rows | number | 6 | Number of placeholder rows rendered while `loading` |
| clickable | boolean | false | Shows 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-size | number | 20 | Number of rows per page: both the unit `eunloadmore` requests are made in, and the chunk size `virtualize` mounts/unmounts as a whole |
| virtualize-threshold | number | 100 | Total known row count above which `virtualize` actually starts detaching pages |
| buffer-pages | number | 1 | Number of extra pages kept mounted before and after the page currently in view |
| has-more | boolean | false | Whether more rows can still be obtained ; the `infinite` sentinel never fires `eunloadmore` while this is `false` |
| loading-more | boolean | false | Whether 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-margin | string | '400px' | How far before the physical end of the table `eunloadmore` triggers, as a CSS-like margin |
| loading-more-label | string | 'Loading more rows…' | Accessible label passed to the trailing `eun-loader` |
| selectable | boolean | false | Enables row selection (checkboxes, select-all, the floating action bar) |
| loading | boolean | false | Shows `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 |
| syncing | boolean | false | Shows 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 |
| infinite | boolean | false | Enables 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 |
| virtualize | boolean | false | Detaches `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
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| actions | Array<EunomiaTableAction> | [] | Custom actions rendered in the floating action bar's center region |
| columns | Array<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 |
| data | Array<T> | [] | The rows to render, in "managed" mode (see `columns`) |
| rowAction | EunomiaTableRowActionDef<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) => string | — | Computes each row's stable key, in "managed" mode ; defaults to a DOM-order fallback, same caveat as `eun-table-item`'s own `row-key` |
| selectedKeys | Array<string> | [] | The currently selected rows' keys ; settable from outside for a "controlled" table, always reflecting the live selection either way |
Slots
| Name | Description |
|---|---|
| (default) | One `eun-table-header` followed by any number of `eun-table-item`s |
Events
| Name | Type | Description |
|---|---|---|
| euntableselectionchange | TableSelectionChangeEvent | Emitted whenever the selection changes, from any source |
| euntableaction | TableActionEvent | Emitted when a floating action bar action is activated |
| eunloadmore | LoadMoreEvent | Dispatched 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
CSS custom properties
| Name | Description |
|---|---|
| --table-border-color | Set a custom border color |
| --table-background | Set a custom background color |
| --table-max-height | Set a custom max height (with scroll) ; `none` by default |
| --table-skeleton-stagger | Delay between each skeleton row's entrance animation, as a CSS time (e.g. `60ms`) |
| --table-skeleton-duration | Duration of each skeleton row's own entrance animation, as a CSS time (e.g. `320ms`) |
| --table-loader-color | Set a custom color for the trailing `eun-loader` shown while `loadingMore` (an `infinite` table) |
| --table-sync-overlay-color | Set a custom highlight color for the shimmer sweep shown while `syncing` |
| --table-sync-duration | Set 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
Implementation methods
The exact same two rows, built with each method, side by side.
Light-DOM
<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 Lovelace | Active |
| Alan Turing | Away |
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:
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>
table.columns = [
{ key: "index", label: "#", width: "64px" },
{ key: "name", label: "Name", width: "2fr" },
{ key: "status", label: "Status", width: "120px" },
];
Native table + CSS uses table-layout: fixed, so a <colgroup> is what
actually controls widths, and an unsized <col> shares whatever space is
left, same as an unset eun-table-column width.
<colgroup>
<col style="width: 64px" />
<col style="width: 2fr" />
<col style="width: 120px" />
</colgroup>
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>
columns/data mode has no sticky-bottom field of its own: unlike
row-key, it's not something a plain data object naturally carries (a
totals row isn't really "a row of the same shape" to begin with). Two
ways to still pin one: keep that one row hand-authored (mix a single
light-DOM eun-table-item alongside a columns/data-managed table,
since eun-table only regenerates children it owns, so an extra sibling
you append yourself is left alone), or reach into the generated row after
data is set and flip its stickyBottom property directly, same as
you'd reach into any other generated child:
table.columns = [
{ key: "team", label: "Team", width: "180px" },
{ key: "q1", label: "Q1", width: "130px" },
{ key: "q2", label: "Q2", width: "130px" },
{ key: "q3", label: "Q3", width: "130px" },
{ key: "q4", label: "Q4", width: "130px" },
{ key: "owner", label: "Owner", width: "160px" },
];
table.rowKey = (row) => row.id;
table.data = [
{
id: "1",
team: "Platform",
q1: "$120k",
q2: "$128k",
q3: "$135k",
q4: "$140k",
owner: "Ada Lovelace",
},
// ...13 more rows...
{
id: "total",
team: "Total",
q1: "$938k",
q2: "$976k",
q3: "$1,025k",
q4: "$1,069k",
owner: "—",
},
];
// eun-table regenerates its own light DOM synchronously inside the
// `data` setter, so the generated rows already exist right after this :
const totalRow = table.querySelector('eun-table-item[row-key="total"]');
if (totalRow) {
totalRow.stickyBottom = true;
totalRow.style.fontWeight = "600";
}
native-table.css reads the exact same custom property names <eun-table>
itself does (--table-border-color, --table-background,
--table-header-background, --table-row-hover-background,
--table-row-selected-background, --table-sticky-row-background,
--table-sticky-column-background, --table-max-height), so a single
theme override applies across whichever method(s) you mix on a page. The
CSS classes it defines were already loaded above, in "The same table,
three ways", reused here as-is for this bigger demo. Click a row above to
toggle aria-selected, purely illustrative, since there's no selection
behavior here (no checkbox, no action bar, no selectedKeys), just the
CSS this stylesheet applies once that attribute (or the .is-selected
class) is present. Wiring an actual selection flow (checkboxes, a
bulk-action toolbar) on top of a native <table> is exactly what
<eun-table> (the "Light DOM"/"Programmatic API" methods above) is for.
<link rel="stylesheet" href="native-table.css" />
<div class="eun-table-scroll" style="max-height: 320px;">
<table
class="eun-table eun-table--sticky-header eun-table--sticky-first-column eun-table--sticky-last-column"
>
<colgroup>
<col style="width: 180px" />
<col style="width: 130px" />
<col style="width: 130px" />
<col style="width: 130px" />
<col style="width: 130px" />
<col style="width: 160px" />
</colgroup>
<thead>
<tr>
<th scope="col">Team</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
<th scope="col">Q3</th>
<th scope="col">Q4</th>
<th scope="col">Owner</th>
</tr>
</thead>
<tbody>
<tr>
<td>Platform</td>
<td>$120k</td>
<td>$128k</td>
<td>$135k</td>
<td>$140k</td>
<td>Ada Lovelace</td>
</tr>
<!-- ...13 more rows... -->
<tr class="eun-table-pinned-bottom" style="font-weight: 600;">
<td>Total</td>
<td>$938k</td>
<td>$976k</td>
<td>$1,025k</td>
<td>$1,069k</td>
<td>—</td>
</tr>
</tbody>
</table>
</div>
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.
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 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
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.
eun-table
behavior: native-table.css is a visual skin only, with no
checkbox, no eun-table-action-bar, and no
selectedKeys. Switch to "Light DOM" or "Programmatic API"
above to see this example.
<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>
selectable/actions/selectedKeys/euntableaction all work identically
whether the table is light-DOM-authored or columns/data-managed: none
of them are markup-mode-specific.
const table = document.querySelector("#table");
table.columns = [
{ key: "name", label: "Name", width: "1fr" },
{
key: "status",
label: "Status",
width: "120px",
render: (value) =>
html`<eun-tag
severity=${value === "Active"
? "success"
: value === "Away"
? "warning"
: "critical"}
>${value}</eun-tag
>`,
},
];
table.data = [
{ id: "1", name: "Ada Lovelace", status: "Active" },
{ id: "2", name: "Grace Hopper", status: "Active" },
{ id: "3", name: "Alan Turing", status: "Away" },
{ id: "4", name: "Katherine Johnson", status: "Offline" },
];
table.rowKey = (row) => row.id;
table.selectable = true;
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"
}
});
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.
native-table.css is a visual skin
only, with no such reveal transition built in. Add your own trailing
<td> and hover/focus CSS if you need this without
eun-table. Switch to "Light DOM" or "Programmatic API"
above to see this example.
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
<!-- 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>
One rowAction config, table-wide: unlike the markup path, it's a single
shape for every row at once (options/disabled still take the row
itself, so content can still differ per row, though the shape itself,
button or dropdown, can't). See the "Properties" table on the API tab for
the full shape:
// Several choices : a dropdown ("options" computed per row)
table.rowAction = {
type: "dropdown",
label: "Row actions",
icon: "more_vert",
options: (row) => [
{ key: "edit", label: "Edit" },
{ key: "archive", label: "Archive", disabled: row.status === "Archived" },
],
onSelect: (optionKey, row) => handleRowAction(optionKey, row),
};
// Exactly one thing to do : a plain button — the Overview tab's own demo
// uses this shape
table.rowAction = {
type: "button",
label: "Send reminder",
icon: "mail",
onClick: (row) => sendReminder(row),
};
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.
Last clicked row key: —
eun-table behavior:
a plain <table> has no `clickable` of its own, so add
cursor: pointer yourself on whichever rows you wire a click
handler onto. Switch to "Light DOM" or "Programmatic API" above to see
this example.
<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>
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;
table.clickable = true;
table.addEventListener("euntablerowclick", (event) =>
openDetails(event.rowKey),
);
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.
<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>
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;
table.selectable = true;
table.checkboxAlwaysVisible = true;
table.actionAlwaysVisible = true;
table.rowAction = {
type: "dropdown",
label: "Row actions",
icon: "more_vert",
options: () => [
{ key: "edit", label: "Edit" },
{ key: "archive", label: "Archive" },
],
onSelect: (optionKey, row) => handleRowAction(optionKey, row),
};
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<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.
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
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 |
|---|---|
|
|
|
<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>
function taskCell(value) {
const wrapper = document.createElement("div");
wrapper.style.display = "flex";
wrapper.style.alignItems = "center";
wrapper.style.gap = "8px";
const icon = document.createElement("eun-icon");
icon.name = "check_circle";
icon.setAttribute("size", "18");
const label = document.createElement("span");
label.textContent = value;
wrapper.append(icon, label);
return wrapper; // or: html`<div style="...">...</div>` if you have Lit's html in scope
}
function tagsCell() {
const wrapper = document.createElement("div");
const status = document.createElement("eun-tag");
status.severity = "success";
status.textContent = "Done";
const version = document.createElement("eun-tag");
version.color = "light";
version.textContent = "v2.4";
wrapper.append(status, version);
return wrapper;
}
table.columns = [
{
key: "task",
label: "Task",
width: "1fr",
render: (value) => taskCell(value),
},
{ key: "tags", label: "Tags", width: "140px", render: () => tagsCell() },
];
table.data = [{ id: "1", task: "Ship release notes" }];
A cell is just a <td>, so arbitrary markup inside it needs nothing special
either.
<td>
<div style="display: flex; align-items: center; gap: 8px;">
<eun-icon name="check_circle" size="18"></eun-icon>
<span>Ship release notes</span>
</div>
</td>
<td>
<eun-tag severity="success">Done</eun-tag>
<eun-tag color="light">v2.4</eun-tag>
</td>
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.
eun-table behavior: a
plain <table> has no shimmering-placeholder rendering
path of its own, so render your own placeholder rows (or none at all)
while a native-table request is in flight. Switch to "Light DOM" or
"Programmatic API" above to see this example.
<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;
});
loading/skeletonRows are shared config properties, set the same way
regardless of how the table's rows themselves are produced, so columns
can already be set (skeleton column count is read from it) while data
is still empty and loading is true.
table.columns = [
{ key: "name", label: "Name", width: "1fr" },
{ key: "status", label: "Status", width: "120px" },
];
table.selectable = true;
table.loading = true;
table.skeletonRows = 5;
fetchRows().then((rows) => {
table.data = rows;
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.
eun-table behavior: a plain
<table> has no shimmer-overlay rendering path of its
own, so layer your own semi-transparent overlay (or none at all) while a
native-table refresh is in flight. Switch to "Light DOM" or "Programmatic
API" above to see this example.
<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>
async function refresh() {
table.syncing = true;
table.data = await fetchLatestRows();
table.syncing = false;
}
setInterval(refresh, 30_000);
--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
eunloadmore event:
infinite: dispatcheseunloadmoreonce the user scrolls near the end of the table's own scroll container andhasMoreistrue, showing a trailingeun-loaderwhileloadingMoreistrue.pageSize/rootMargin/loadingMoreLabeltune the request size, trigger distance, and accessible label.virtualize: once the known row count passesvirtualizeThreshold(default100), detacheseun-table-itempages 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.pageSizedoubles as the virtualization chunk size, whilebufferPagescontrols 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.
eun-table-item rows right now, out of 200:
—
eun-table behavior: detaching
offscreen rows is exactly the kind of DOM bookkeeping
native-table.css deliberately doesn't do (no JavaScript at
all). Render only the rows you actually want in the DOM yourself if a
native table ever needs to hold this many rows. Switch to "Light DOM" or
"Programmatic API" above to see this example.
<eun-table virtualize virtualize-threshold="200" style="max-height: 360px;">
<eun-table-header>...</eun-table-header>
<!-- all 200 rows, appended upfront -->
</eun-table>
virtualize/virtualizeThreshold/pageSize/bufferPages are shared
config properties: eun-table virtualizes its own generated rows in
managed mode exactly the same way it virtualizes hand-authored ones.
table.columns = [
{ key: "index", label: "#", width: "64px" },
{ key: "name", label: "Name", width: "1fr" },
{ key: "role", label: "Role", width: "180px" },
{ key: "city", label: "City", width: "140px" },
];
table.rowKey = (row) => row.id;
table.virtualize = true;
table.virtualizeThreshold = 200;
table.data = allTwoHundredRows; // appended upfront, same as the markup path
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.
eun-table behavior:
wire your own IntersectionObserver (or a "Load more"
button) against the native table's own scroll container if you need
this without eun-table. Switch to "Light DOM" or
"Programmatic API" above to see this example.
<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;
});
Same event, same hasMore/loadingMore flags, and only the append target
changes, from light-DOM eun-table-items to data itself.
table.columns = [
{ key: "index", label: "#", width: "80px" },
{ key: "result", label: "Result", width: "1fr" },
];
table.rowKey = (row) => row.id;
table.infinite = true;
table.hasMore = true;
table.pageSize = 20;
table.data = firstPageOfRows;
table.addEventListener("eunloadmore", async (event) => {
const response = await fetch(
`/api/results?page=${event.page}&pageSize=${event.pageSize}`,
);
const { rows, hasMore } = await response.json();
table.data = [...table.data, ...rows];
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
--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 (Name ↔ Role ↔ Status 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>
Same custom properties, set the same way: they're plain CSS, unaffected by which input mode generated the markup underneath.
table.columns = [
{ key: "name", label: "Name", width: "1fr" },
{ key: "role", label: "Role", width: "100px" },
{ key: "status", label: "Status", width: "120px" },
];
table.data = [
{ id: "1", name: "Ada Lovelace", role: "Lead", status: "Active" },
{ id: "2", name: "Alan Turing", role: "Research", status: "Away" },
{ id: "3", name: "Grace Hopper", role: "Principal", status: "Active" },
];
table.rowKey = (row) => row.id;
table.selectable = true;
table.stickyFirstColumn = true;
table.style.setProperty(
"--table-border-color",
"var(--eun-color-primary-300, #UNMAPPED)",
);
table.style.setProperty(
"--table-header-background",
"var(--eun-color-primary-50, #9ab4ff)",
);
table.style.setProperty(
"--table-sticky-column-background",
"var(--eun-color-primary-50, #9ab4ff)",
);
table.style.setProperty(
"--table-sticky-row-background",
"var(--eun-color-primary-100, #85a4ff)",
);
table.style.setProperty(
"--table-column-border-color",
"var(--eun-color-primary-200, #6f94ff)",
);
table.style.setProperty(
"--table-sticky-border-color",
"var(--eun-color-primary-500, #3d6fff)",
);
<table
class="eun-table eun-table--sticky-first-column"
style="
--table-border-color: var(--eun-color-primary-300, #UNMAPPED);
--table-header-background: var(--eun-color-primary-50, #9ab4ff);
color: var(--eun-color-primary-700, #3662e0);
--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);
"
>
<!-- ... -->
<tr class="eun-table-pinned-bottom" style="font-weight: 600;">
<td>Total</td>
<td>3</td>
</tr>
</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 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.
eun-table
with a hand-wired eun-table-action-bar. See "Method 3:
native table and CSS" above and
eun-table, over your
own markup. Switch to "Light DOM" or "Programmatic API" above to see
this example.
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>
The checkbox column becomes a columns[].render entry instead of a
hand-authored eun-table-cell, while everything past that (the Set, the
eun-table-action-bar wiring) is identical to the markup path, since it
only ever deals with the generated eun-table-items, indistinguishable
from hand-authored ones. The one difference: a columns entry's label
is plain text only (no nested <span class="sr-only">), so this header
cell reads "Select" visibly rather than staying visually hidden: trade
that for a render-free select column if you need the exact same
sr-only treatment, or rename the column to something already meant to be
seen.
table.columns = [
{
key: "select",
label: "Select",
width: "48px",
render: (_value, row) => {
const checkbox = document.createElement("eun-checkbox");
checkbox.ariaLabel = `Select ${row.name}`;
return checkbox;
},
},
{ key: "name", label: "Name", width: "1fr" },
{
key: "status",
label: "Status",
width: "120px",
render: (value) =>
html`<eun-tag severity=${value === "Active" ? "success" : "warning"}
>${value}</eun-tag
>`,
},
];
table.data = [
{ id: "1", name: "Ada Lovelace", status: "Active" },
{ id: "2", name: "Alan Turing", status: "Away" },
{ id: "3", name: "Grace Hopper", status: "Active" },
];
table.rowKey = (row) => row.id;
// eun-table regenerates its own light DOM synchronously inside the
// `data` setter, so the generated rows already exist right after this —
// the exact same wiring as the markup path from here on :
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.onClose = () => {
selected.clear();
rows.forEach((row) => (row.querySelector("eun-checkbox").checked = false));
sync();
};
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
eun-table follows the full WAI-ARIA
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
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.