Pattern
Data Table Hybrid
A comprehensive, accessible <table> with sortable headers, multi-select rows, sticky header and pinned columns, density variants, integrated client-side pagination, search/filter toolbar, empty state, and a loading shimmer. The workhorse of every CRUD admin.
About
Overview
Data Table is the answer to "I have a list and the user is going to scan, sort, filter, and act on rows." It is built on real semantic <table> — never a grid of divs — so every screen reader, every keyboard, and every assistive technology already knows what to do.
Without JavaScript you get a clean styled table. The <ren-table> custom element layers on sorting, selection (including shift-click ranges), pagination, search filtering, column resize, sticky/pinned columns, and density variants. You opt in to each feature by adding the matching attribute or markup — there is no monolithic config.
When to use
- Tabular data the user will sort, filter, or scan (invoices, users, orders, logs).
- Datasets where selecting one-or-many rows triggers actions (delete, export, bulk-edit).
- Lists big enough to need pagination but small enough to fit in memory client-side.
When not to use
- Cards / tiles instead of rows. If each item has a thumbnail and 2-3 lines of body, use a Card grid.
- Tree / hierarchical view. Reach for a tree component (out of scope for v0.9).
- 2-3 key/value pairs per item. Use a
<dl>description list. - A single metric. Use a stat / KPI card.
- Server-paged datasets bigger than memory. The built-in pager is client-side; use an external pager that fetches and replaces the table rows instead of relying on
data-page-size.
Real <table>, semantically. The <ren-table> custom element wraps a native <table> in light DOM. Headers stay <th>, rows stay <tr>, cells stay <td>. Sort state, selection, and pagination all map to standard ARIA (aria-sort, aria-selected, aria-live) — you're not opting out of the platform.
Structure
Anatomy
A full-featured Data Table has five regions. All but the <table> itself are optional — drop them and the table still works.
- Toolbar (
.ren-table-toolbar) — top bar with search input ([data-table-search]) and bulk actions. - Wrapper (
.ren-table-wrapper) — horizontal scroll container. Required for sticky / pinned to anchor against. - Header (
.ren-table-headeron<thead>) — sticky by default. Sortable columns add.ren-th-sortable+data-column. - Body (
.ren-table-bodyon<tbody>) — rows. Selection column uses.ren-table-select; pinned column uses.ren-th-pinned/.ren-td-pinned. - Pagination (
.ren-table-pagination) — info text + prev/next/page-size controls. The JS keeps it in sync withdata-page-size.
Live
Demo
Click the Project or Status header to sort. Use the checkboxes to select rows. The toolbar search filters by any visible cell.
| Project | Owner | Status | |
|---|---|---|---|
| Atlas | Ren | Active | |
| Hermes | Ana | Pending | |
| Iris | Carlos | Archived | |
| Orion | Sofía | Active | |
| Lyra | Diego | Pending | |
| Vega | Lucía | Active | |
| Nova | Tomás | Archived |
<ren-table data-page-size="5">
<div class="ren-table-toolbar">
<input class="ren-input ren-input-sm" type="search"
placeholder="Search…" data-table-search>
</div>
<div class="ren-table-wrapper">
<table class="ren-table">
<thead class="ren-table-header">
<tr>
<th class="ren-th ren-table-select">
<input type="checkbox" aria-label="Select all">
</th>
<th class="ren-th ren-th-sortable" data-column="project">Project</th>
<th class="ren-th">Owner</th>
<th class="ren-th ren-th-sortable" data-column="status">Status</th>
</tr>
</thead>
<tbody class="ren-table-body">
<tr class="ren-tr" data-row-id="1">…</tr>
</tbody>
</table>
</div>
<div class="ren-table-pagination">
<span class="ren-table-pagination-info"></span>
<div class="ren-table-pagination-controls">
<button data-page-prev>‹</button>
<button data-page-next>›</button>
</div>
</div>
</ren-table>
Visual
Variants
Three densities, runtime-switchable on the host. Tighter rows mean more rows visible at once — useful in admin views.
.ren-table-compact— 36 px row height. For high-density scanning..ren-table-comfortable(default) — 48 px row height. Mainstream balance.- Spacious (60 px) — set
--ren-table-row-height: 60pxvia inline style or theme override.
Toggle at runtime by adding / removing the class. Sticky header / pinned column offsets adjust automatically.
The header is sticky by default once the wrapper scrolls. No attribute required — works the moment the body overflows.
<div class="ren-table-wrapper" style="max-height: 400px">
<table class="ren-table">…</table>
</div>
Pin the leftmost (typically selection or row label) column horizontally during scroll. Pinned offsets are managed by the CSS — never hardcode left: values.
<th class="ren-th ren-th-pinned">Name</th>
<td class="ren-td ren-td-pinned">Atlas</td>
When filtering yields zero rows, the JS swaps the body for an empty state. Provide it explicitly so you control the copy.
<div class="ren-table-empty">
<div class="ren-table-empty-icon" aria-hidden="true">🔍</div>
<h3 class="ren-table-empty-title">No matching projects</h3>
<p class="ren-table-empty-description">Try a different search term or clear filters.</p>
</div>
Set data-loading on <ren-table> to dim rows, disable interaction, and show a shimmering overlay. Combine with an aria-live="polite" announcement region for screen readers.
<ren-table data-loading>…</ren-table>
Not implemented in v0.9. The built-in body renders all visible rows at once. For datasets > ~5 000 rows, switch to an external server-side pager that fetches and replaces rows instead of trying to render everything client-side. A windowed virtualizer is on the v1.0 roadmap.
Reference
API
Custom element attributes
| Attribute | Type | Default | Notes |
|---|---|---|---|
data-page-size | number | 10 | Rows per page in the built-in client pager. Set 0 to disable pagination. |
data-loading | boolean | false | Shows the shimmer overlay and disables row interaction. |
CSS classes
| Class | Effect |
|---|---|
.ren-table | Apply on the <table>. Adds borders, padding, hover, focus rings. |
.ren-table-wrapper | Required scroll container. Anchors sticky header and pinned columns. |
.ren-table-header | On <thead>. Sticky by default once the wrapper scrolls. |
.ren-table-body | On <tbody>. Hosts the row hover and selection styles. |
.ren-th / .ren-td | Header / body cells. Match the densitiy variant on the host. |
.ren-th-sortable | Marks a column sortable. Pair with data-column="key". |
.ren-th-pinned / .ren-td-pinned | Sticky column. Offsets handled by the CSS — do not set left: yourself. |
.ren-th-resize | Drag handle on header for column resize. |
.ren-table-select | On th/td hosting the selection checkbox column. |
.ren-tr | Body row. Required for selection state styling. |
.ren-table-toolbar | Top bar above the table. Holds the search input + bulk actions. |
.ren-table-toolbar-search | Optional wrapper around the search field for grouping with bulk actions. |
.ren-table-pagination | Footer with info text and prev / next / page-size controls. |
.ren-table-pagination-info | "Showing 1-10 of 47" label. JS keeps the text in sync. |
.ren-table-pagination-controls | Buttons cluster: prev, next, page-size <select>. |
.ren-table-pagination-button | Style for prev / next buttons. Real <button>, 44 px touch target. |
.ren-table-empty | Empty / no-results state inside the body. |
.ren-table-empty-icon / -title / -description | Composable empty-state parts. |
.ren-table-loading-overlay | Shimmer layer rendered while data-loading is set. |
.ren-table-compact / .ren-table-comfortable | Density variants on the host or table. |
Data attributes (consumer-controlled)
| Attribute | Where | Purpose |
|---|---|---|
data-column | <th> | Key for sort state. Must be unique per table. |
data-row-id | <tr> | Stable id for selection state. Use the entity id, not the index. |
data-table-search | input | Marker that wires the input to the table's filter pipeline. |
data-page-prev / data-page-next | button | Pager controls. Press wires next / prev page. |
data-page-size-select | <select> | Page size dropdown. Options become page sizes. |
States (read-only, set by the JS)
| State | Where | Meaning |
|---|---|---|
data-sort | <th> | asc / desc / none. Mirrored to aria-sort. |
aria-sort | <th> | Standard ARIA mirror of data-sort. |
aria-selected | <tr> | true when the row's selection checkbox is checked. |
data-loading | <ren-table> | Set by consumer; the JS responds by showing the overlay. |
Events
| Event | Detail |
|---|---|
ren-sort | { column, direction }. Fires after sort applies. Bubbles and is composed. |
ren-select | { selected }. Fires after row selection changes. Bubbles and is composed. |
ren-filter | { value }. Fires after the toolbar search filter applies. Bubbles and is composed. |
Theming
Public token API
Override these custom properties on <ren-table> or on a class to retheme the table without writing selectors against the internals.
| Token | Default | Notes |
|---|---|---|
--ren-table-border-color | var(--color-border) | All horizontal / vertical lines. |
--ren-table-cell-padding | var(--space-3) | Inline + block padding for every cell. |
--ren-table-row-height | auto | Force a fixed row height across the table. |
--ren-table-header-bg | var(--color-surface-sunken) | Header row background. |
--ren-table-header-color | var(--color-text-muted) | Header text. |
--ren-table-header-size | var(--text-xs) | Header font size. |
--ren-table-header-weight | var(--weight-semibold) | Header font weight. |
--ren-table-hover-bg | var(--color-fill-hover) | Row hover. |
--ren-table-selected-bg | var(--color-accent-subtle) | Selected row background. |
--ren-table-stripe-bg | transparent | Optional zebra striping. Set to var(--color-surface-raised) for stripes. |
Inclusive by default
Accessibility
Keyboard
<th>: cycles asc → desc → none. On a checkbox cell: toggles selection.Required ARIA
- Sortable
<th>setsaria-sort="ascending" | "descending" | "none"mirroringdata-sort. The JS owns this — you don't toucharia-sortmanually. - Selection checkboxes are real
<input type="checkbox">witharia-label("Select all" / "Select row N"). Rows reflect state viaaria-selected="true". - Pagination buttons are real
<button>with 44 px touch targets. Disabled uses thedisabledattribute, notopacityalone. - Loading state pairs
data-loadingwith anaria-live="polite"announcement: "Loading…" → "Loaded 47 rows". - Empty state must include a visible
.ren-table-empty-title— don't rely on the icon alone. - Always provide a
<caption class="ren-sr-only">describing the table for screen readers.
Never fake a table with divs. <div role="grid"> exists but it costs you the entire native screen-reader heuristic for column / row navigation. Real <table> + <th scope="col"> + <td> is the right answer 99% of the time.
Patterns
Examples
Sorting (client-side)
Add .ren-th-sortable + data-column on the header. The component owns asc → desc → none cycling and writes data-sort + aria-sort.
<th class="ren-th ren-th-sortable" data-column="name">Name</th>
// Listen for state changes if you need to mirror to URL or analytics:
table.addEventListener('ren-sort', (e) => {
history.replaceState(null, '', `?sort=${e.detail.column}.${e.detail.direction}`);
});
Server-side sorting
Don't pre-sort the rows. Listen for ren-sort and fetch.
table.addEventListener('ren-sort', async (e) => {
table.dataset.loading = '';
const rows = await fetch(`/api/users?sort=${e.detail.column}&dir=${e.detail.direction}`)
.then(r => r.json());
renderBody(rows); // your function — replace tbody innerHTML
delete table.dataset.loading;
});
Row selection
Real checkboxes with aria-label. Listen for ren-select to enable bulk actions when at least one row is selected.
<tr class="ren-tr" data-row-id="1">
<td class="ren-td ren-table-select">
<input type="checkbox" aria-label="Select row Atlas">
</td>
…
</tr>
table.addEventListener('ren-select', (e) => {
bulkBar.hidden = e.detail.selected.length === 0;
});
Pagination
Set data-page-size on the host. The JS owns visible-row slicing and writes the "Showing 1-10 of 47" info.
<ren-table data-page-size="25">
…
<div class="ren-table-pagination">
<span class="ren-table-pagination-info"></span>
<div class="ren-table-pagination-controls">
<button data-page-prev>Previous</button>
<button data-page-next>Next</button>
<select data-page-size-select>
<option value="10">10</option>
<option value="25" selected>25</option>
<option value="50">50</option>
</select>
</div>
</div>
</ren-table>
Density
Add the class on the table (or on a wrapper to apply to many at once).
<table class="ren-table ren-table-compact">…</table>
Sticky header + pinned first column (admin combo)
<div class="ren-table-wrapper" style="max-height: 480px">
<table class="ren-table">
<thead class="ren-table-header">
<tr>
<th class="ren-th ren-th-pinned">Name</th>
<th class="ren-th">Email</th>
<th class="ren-th">Role</th>
…
</tr>
</thead>
<tbody class="ren-table-body">
<tr class="ren-tr">
<td class="ren-td ren-td-pinned">Ren</td>
<td class="ren-td">ren@example.com</td>
…
</tr>
</tbody>
</table>
</div>
Empty state on filter miss
Drop the empty state inside the wrapper — the JS swaps it in when the filter pipeline yields zero rows.
<div class="ren-table-empty" hidden>
<div class="ren-table-empty-icon" aria-hidden="true">🔍</div>
<h3 class="ren-table-empty-title">No matching projects</h3>
<p class="ren-table-empty-description">Try a different search or clear filters.</p>
</div>