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__expandonly for the detail row. It should not navigate. - Keep
aria-expandedon the expand button in sync with[open]or[data-open]on the following.c-table__detailrow. - 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
| Selector | Role |
|---|---|
.c-table-wrap | Scroll + query container (bordered, rounded). |
.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 .c-table__sort-icon. |
.c-table__sort-icon | Pure-CSS triangle arrow inside sortable <th>. |
.c-table__check | Narrow centered cell for checkboxes. |
.c-table__detail | Expandable detail row (collapsed by default). |
.c-table__detail[open] | Expanded detail row. |
.c-table__detail-inner | Animated wrapper (grid-rows technique). |
.c-table__detail-content | Inner content (padding + overflow). |
.c-table__expand | Expand/collapse toggle button (chevron). |
.c-table__sticky-col | Sticky 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
| Token | Default | Purpose |
|---|---|---|
--c-table-min | 48rem | Min table width (drives horizontal scroll). |
--c-table-cell-pad | var(--pl-space-3) var(--pl-space-4) | Cell padding (overridden by density). |
--c-table-row-min-block-size | calc(var(--pl-control-block-size) + 0.5rem) | Row height (derived from global control token). |
--c-table-radius | var(--pl-radius-xl) | Corner radius (exposed for cell corner inheritance). |