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__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.pl-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="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). |