ComponentPorchlight CSS

Data table

Enterprise data table with sortable headers, row selection, expandable rows, sticky column, density modes, and loading states.

51 components9 patterns46 stable5 experimental

Command

Search Porchlight

Data table

The .pl-c-table-wrap + .pl-c-table pair is an enterprise data table: sticky headers, horizontal scroll with a stable scrollbar gutter, container-query padding, sortable columns, row selection, expandable detail rows, sticky first column, density modes, and loading states.

Semantic HTML

Basic table

<div class="pl-c-table-wrap" tabindex="0">
  <table class="pl-c-table">
    <thead>
      <tr>
        <th>Account</th>
        <th data-align="end">MRR</th>
      </tr>
    </thead>
    <tbody>
      <tr aria-selected="true">
        <td>Acme Ops</td>
        <td data-align="end">$2,400</td>
      </tr>
    </tbody>
  </table>
</div>

tabindex="0" on the wrap makes the horizontal scroll region keyboard operable (WCAG 2.1.1 — essential on mobile/tablet widths where the table overflows). Omit it only when the table can never overflow horizontally.

Sortable headers

<th data-sort="asc">Name <span class="pl-c-table__sort-icon"></span></th>
<th data-sort="desc">Created <span class="pl-c-table__sort-icon"></span></th>
<th>Tags</th>
<!-- no data-sort = unsortable -->

Toggle data-sort between "asc" and "desc" via JS. CSS renders the arrow.

Row selection (checkbox column)

<thead>
  <tr>
    <th class="pl-c-table__check">
      <input type="checkbox" aria-label="Select all" />
    </th>
    <th>Account</th>
  </tr>
</thead>
<tbody>
  <tr aria-selected="true">
    <td class="pl-c-table__check">
      <input type="checkbox" checked aria-label="Select row" />
    </td>
    <td>Acme Ops</td>
  </tr>
</tbody>

Expandable detail rows

<tr>
  <td>...</td>
  <td>
    <button class="pl-c-table__expand" aria-expanded="false">
      <svg viewBox="0 0 24 24"><path d="m6 9 6 6 6-6" /></svg>
    </button>
  </td>
</tr>
<tr class="pl-c-table__detail" open>
  <td colspan="99">
    <div class="pl-c-table__detail-inner">
      <div class="pl-c-table__detail-content">Detail content</div>
    </div>
  </td>
</tr>

Toggle the open attribute on .pl-c-table__detail and aria-expanded on the button via JS. CSS handles the animated expand/collapse.

Clickable rows + independent details

For workflow/admin tables, keep row activation and detail disclosure as two separate controls:

  • Put a real link or button in the primary cell for the row’s main action.
  • Use .pl-c-table__expand only for the detail row. It should not navigate.
  • Keep aria-expanded on the expand button in sync with [open] or [data-open] on the following .pl-c-table__detail row.
  • If clicking the whole row is required, add app JavaScript that ignores clicks originating from interactive descendants (a, button, input, select, textarea, [role="button"]) so the disclosure button remains independent.
<tr data-row-href="/requests/REQ-42">
  <td>
    <a href="/requests/REQ-42">REQ-42</a>
  </td>
  <td>Approval needed</td>
  <td data-align="end">
    <button
      class="pl-c-table__expand"
      aria-expanded="false"
      aria-controls="request-REQ-42-detail"
    >
      <svg viewBox="0 0 24 24"><path d="m6 9 6 6 6-6" /></svg>
    </button>
  </td>
</tr>
<tr class="pl-c-table__detail" id="request-REQ-42-detail">
  <td colspan="99">
    <div class="pl-c-table__detail-inner">
      <div class="pl-c-table__detail-content">Detail content</div>
    </div>
  </td>
</tr>

Keyboard behavior: Tab reaches the row link and the disclosure button as separate stops; Enter on the link navigates; Enter/Space on the disclosure button toggles aria-expanded and the detail row.

Sticky first column

<th class="pl-c-table__sticky-col">Account</th>
...
<td class="pl-c-table__sticky-col">Acme Corp</td>

Density modes

<div class="pl-c-table-wrap" data-pl-density="compact">
  <!-- tighter row padding for high information density -->
</div>

Loading state

<tbody data-loading>
  <tr>
    <td><span class="pl-c-skeleton" data-shape="text"></span></td>
    <td><span class="pl-c-skeleton" data-shape="text"></span></td>
  </tr>
</tbody>

[data-loading] on <tbody> suppresses hover effects.

Density and row height

Row height is derived from the global --pl-control-block-size token:

--pl-c-table-row-min-block-size: calc(var(--pl-control-block-size) + 0.5rem);

This means setting data-pl-density="compact" on <body> (or any ancestor) automatically shrinks table rows, buttons, and inputs together. You no longer need to set density separately on each .pl-c-table-wrap.

For table-specific compact density (tighter cell padding + a smaller row height offset), set data-pl-density="compact" on .pl-c-table-wrap directly:

<div class="pl-c-table-wrap" data-pl-density="compact">
  <!-- cell padding tightened + row height offset reduced to +0.25rem -->
</div>

Row selection

Apply aria-selected="true" on <tr> for selected rows — Porchlight styles these with an accent-tinted background and a left accent bar. This is the semantically correct pattern for keyboard-navigable row selection:

<tbody>
  <tr aria-selected="true">
    <td>…</td>
  </tr>
  <tr>
    <td>…</td>
  </tr>
</tbody>

No custom .is-selected class is needed — aria-selected is built in.

For multi-row workflows, place a .pl-c-toolbar immediately above the table, announce only the short selected count with aria-live="polite", and keep the checkboxes plus aria-selected rows as the source of truth. The

composition recipes guide

includes the full bulk-selection action pattern.

For short editable values, compose a read view with a sibling native form instead of turning the table cell itself into a custom widget. The same guide documents focus entry, cancel restoration, save announcements, and validation for framework-neutral inline editing.

Responsive columns

When a table overflows on narrow containers, hide non-essential columns via a container query on the table wrapper (it is already a query container):

@layer app {
  @container pl-c-table-wrap (inline-size < 50rem) {
    .col-optional {
      display: none;
    }
  }
}
<th class="col-optional">Last seen</th>
<td class="col-optional">2h ago</td>

For cell content that should truncate instead of wrapping, apply the built-in .pl-u-truncate utility class:

<td class="pl-u-truncate" style="max-inline-size: 12rem;">
  Very long email address…
</td>

Class contract

Selector Role
.pl-c-table-wrap Scroll + query container (bordered, rounded).
.pl-c-table The native <table>.
th, td Cells (styled via descendant selectors).
[data-align="end"] Right-align + tabular-nums for numeric columns.
[data-align="center"] Center-align.
tbody tr:hover Row hover wash (scanning aid).
tbody tr[aria-selected="true"] Selected row (accent wash).
th[data-sort] Sortable header (cursor + hover).
th[data-sort="asc"|"desc"] Shows up/down arrow via .pl-c-table__sort-icon.
.pl-c-table__sort-icon Pure-CSS triangle arrow inside sortable <th>.
.pl-c-table__check Narrow centered cell for checkboxes.
.pl-c-table__detail Expandable detail row (collapsed by default).
.pl-c-table__detail[open] Expanded detail row.
.pl-c-table__detail-inner Animated wrapper (grid-rows technique).
.pl-c-table__detail-content Inner content (padding + overflow).
.pl-c-table__expand Expand/collapse toggle button (chevron).
.pl-c-table__sticky-col Sticky first column (pinned during scroll).
[data-pl-density="compact"] Tighter row padding on .pl-c-table-wrap.
tbody[data-loading] Suppresses hover during loading state.

Tokens exposed

Token Default Purpose
--pl-c-table-min 48rem Min table width (drives horizontal scroll).
--pl-c-table-cell-pad var(--pl-space-3) var(--pl-space-4) Cell padding (overridden by density).
--pl-c-table-row-min-block-size calc(var(--pl-control-block-size) + 0.5rem) Row height (derived from global control token).
--pl-c-table-radius var(--pl-radius-xl) Corner radius (exposed for cell corner inheritance).