RRenDSv0.13.0

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.

  1. Toolbar (.ren-table-toolbar) — top bar with search input ([data-table-search]) and bulk actions.
  2. Wrapper (.ren-table-wrapper) — horizontal scroll container. Required for sticky / pinned to anchor against.
  3. Header (.ren-table-header on <thead>) — sticky by default. Sortable columns add .ren-th-sortable + data-column.
  4. Body (.ren-table-body on <tbody>) — rows. Selection column uses .ren-table-select; pinned column uses .ren-th-pinned / .ren-td-pinned.
  5. Pagination (.ren-table-pagination) — info text + prev/next/page-size controls. The JS keeps it in sync with data-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
AtlasRenActive
HermesAnaPending
IrisCarlosArchived
OrionSofíaActive
LyraDiegoPending
VegaLucíaActive
NovaTomásArchived
<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

Density

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: 60px via inline style or theme override.

Toggle at runtime by adding / removing the class. Sticky header / pinned column offsets adjust automatically.

Sticky header

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>
Pinned column

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>
Empty state

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>
Loading state

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>
Virtualization

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

AttributeTypeDefaultNotes
data-page-sizenumber10Rows per page in the built-in client pager. Set 0 to disable pagination.
data-loadingbooleanfalseShows the shimmer overlay and disables row interaction.

CSS classes

ClassEffect
.ren-tableApply on the <table>. Adds borders, padding, hover, focus rings.
.ren-table-wrapperRequired scroll container. Anchors sticky header and pinned columns.
.ren-table-headerOn <thead>. Sticky by default once the wrapper scrolls.
.ren-table-bodyOn <tbody>. Hosts the row hover and selection styles.
.ren-th / .ren-tdHeader / body cells. Match the densitiy variant on the host.
.ren-th-sortableMarks a column sortable. Pair with data-column="key".
.ren-th-pinned / .ren-td-pinnedSticky column. Offsets handled by the CSS — do not set left: yourself.
.ren-th-resizeDrag handle on header for column resize.
.ren-table-selectOn th/td hosting the selection checkbox column.
.ren-trBody row. Required for selection state styling.
.ren-table-toolbarTop bar above the table. Holds the search input + bulk actions.
.ren-table-toolbar-searchOptional wrapper around the search field for grouping with bulk actions.
.ren-table-paginationFooter 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-controlsButtons cluster: prev, next, page-size <select>.
.ren-table-pagination-buttonStyle for prev / next buttons. Real <button>, 44 px touch target.
.ren-table-emptyEmpty / no-results state inside the body.
.ren-table-empty-icon / -title / -descriptionComposable empty-state parts.
.ren-table-loading-overlayShimmer layer rendered while data-loading is set.
.ren-table-compact / .ren-table-comfortableDensity variants on the host or table.

Data attributes (consumer-controlled)

AttributeWherePurpose
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-searchinputMarker that wires the input to the table's filter pipeline.
data-page-prev / data-page-nextbuttonPager 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)

StateWhereMeaning
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

EventDetail
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.

TokenDefaultNotes
--ren-table-border-colorvar(--color-border)All horizontal / vertical lines.
--ren-table-cell-paddingvar(--space-3)Inline + block padding for every cell.
--ren-table-row-heightautoForce a fixed row height across the table.
--ren-table-header-bgvar(--color-surface-sunken)Header row background.
--ren-table-header-colorvar(--color-text-muted)Header text.
--ren-table-header-sizevar(--text-xs)Header font size.
--ren-table-header-weightvar(--weight-semibold)Header font weight.
--ren-table-hover-bgvar(--color-fill-hover)Row hover.
--ren-table-selected-bgvar(--color-accent-subtle)Selected row background.
--ren-table-stripe-bgtransparentOptional zebra striping. Set to var(--color-surface-raised) for stripes.

Inclusive by default

Accessibility

Keyboard

TabCycles into the toolbar, then into headers, then into the body, then into pagination.
Enter / SpaceOn a sortable <th>: cycles asc → desc → none. On a checkbox cell: toggles selection.
/ Inside the body: moves focus to the same column in the row above / below.
Shift + clickOn a row checkbox: selects the range between the last clicked row and the current one.

Required ARIA

  • Sortable <th> sets aria-sort="ascending" | "descending" | "none" mirroring data-sort. The JS owns this — you don't touch aria-sort manually.
  • Selection checkboxes are real <input type="checkbox"> with aria-label ("Select all" / "Select row N"). Rows reflect state via aria-selected="true".
  • Pagination buttons are real <button> with 44 px touch targets. Disabled uses the disabled attribute, not opacity alone.
  • Loading state pairs data-loading with an aria-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>