Component Porchlight CSS

Data table

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

52 components 51 stable 1 experimental

Command

Search Porchlight

Data table

The .c-table-wrap + .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="c-table-wrap">
  <table class="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>

Sortable headers

<th data-sort="asc">Name <span class="c-table__sort-icon"></span></th>
<th data-sort="desc">Created <span class="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="c-table__check">
      <input type="checkbox" aria-label="Select all" />
    </th>
    <th>Account</th>
  </tr>
</thead>
<tbody>
  <tr aria-selected="true">
    <td class="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="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="c-table__detail" open>
  <td colspan="99">
    <div class="c-table__detail-inner">
      <div class="c-table__detail-content">Detail content</div>
    </div>
  </td>
</tr>

Toggle the open attribute on .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 .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 .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="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="c-table__detail" id="request-REQ-42-detail">
  <td colspan="99">
    <div class="c-table__detail-inner">
      <div class="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="c-table__sticky-col">Account</th>
...
<td class="c-table__sticky-col">Acme Corp</td>

Density modes

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

Loading state

<tbody data-loading>
  <tr>
    <td><span class="c-skeleton" data-shape="text"></span></td>
    <td><span class="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:

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

This means setting data-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 .c-table-wrap.

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

<div class="c-table-wrap" data-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.

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 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 .u-truncate utility class:

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

Class contract

SelectorRole
.c-table-wrapScroll + query container (bordered, rounded).
.c-tableThe native <table>.
th, tdCells (styled via descendant selectors).
[data-align="end"]Right-align + tabular-nums for numeric columns.
[data-align="center"]Center-align.
tbody tr:hoverRow 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 .c-table__sort-icon.
.c-table__sort-iconPure-CSS triangle arrow inside sortable <th>.
.c-table__checkNarrow centered cell for checkboxes.
.c-table__detailExpandable detail row (collapsed by default).
.c-table__detail[open]Expanded detail row.
.c-table__detail-innerAnimated wrapper (grid-rows technique).
.c-table__detail-contentInner content (padding + overflow).
.c-table__expandExpand/collapse toggle button (chevron).
.c-table__sticky-colSticky first column (pinned during scroll).
[data-density="compact"]Tighter row padding on .c-table-wrap.
tbody[data-loading]Suppresses hover during loading state.

Tokens exposed

TokenDefaultPurpose
--c-table-min48remMin table width (drives horizontal scroll).
--c-table-cell-padvar(--pl-space-3) var(--pl-space-4)Cell padding (overridden by density).
--c-table-row-min-block-sizecalc(var(--pl-control-block-size) + 0.5rem)Row height (derived from global control token).
--c-table-radiusvar(--pl-radius-xl)Corner radius (exposed for cell corner inheritance).