The list a CMS lives in — sortable headers, row selection, row actions, and the empty state that stops a blank list reading as a failure.
The list a CMS lives in. Pages, records, fields, redirects — most admin
surfaces are a list of something, so the skin claims exactly one table.
mk-table is a real <table>, on purpose: rows are data, and the
moment they become divs the reader loses column association and the
browser loses its own table semantics.
| Address | Status | Updated | Actions | |
|---|---|---|---|---|
| About us | /about | Published | 2026-08-24 |
|
| Categories | /categories | Draft | 2026-08-26 |
|
| Summer hours | /summer-hours | Scheduled | 2026-08-21 |
|
<div class="mk-table-wrap">
<table class="mk-table">
<caption class="visually-hidden">Pages in this site</caption>
<thead>
<tr>
<th scope="col" aria-sort="ascending">
<button type="button" class="mk-table__sort">Title</button>
</th>
<th scope="col">Status</th>
<th scope="col"><span class="visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody>
<tr aria-selected="true">
<th scope="row" class="fw-normal">About us</th>
<td><span class="mk-status mk-status--live">Published</span></td>
<td>
<div class="mk-table__actions">
<button type="button" class="au-btn">Edit</button>
<button type="button" class="au-btn au-btn--danger">Delete</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
| Piece | Job |
|---|---|
mk-table-wrap |
The frame — hairline border, app radius — and the overflow: a wide table scrolls inside it rather than pushing the page. |
mk-table |
The table itself: control-size type, one density, hairline row rules, hover as a faint ink wash. No size suffixes exist to write. |
caption (visually hidden) |
Names the list for assistive tech — "Pages in this site", not a table about nothing. |
th scope="col" / th scope="row" |
Headers that say which axis they head. The row's name cell is a header. |
mk-table__sort |
The click target inside a sortable header — see below. |
mk-table__actions |
A row's verbs, right-aligned so they sit under each other down the column. |
au-empty |
What renders when there are no rows — see below. |
The name column's fw-normal takes the browser's default bolding off
a th scope="row" — the one weight class markup gets to write, because
it un-designs rather than designs. A column of semibold names would read
as a design change, and this app does not get designed.
A sortable header is a button inside the <th>, and the two split the
job: the th carries aria-sort — which is what assistive tech reads —
and the button carries the click.
<th scope="col" aria-sort="descending">
<button type="button" class="mk-table__sort">Updated</button>
</th>
A selected row says so with aria-selected="true", and the skin draws
the soft accent tint from the same attribute that announces it — state
and its treatment cannot drift apart. Hover is a plain ink wash, so the
two never read as each other: ink is the pointer passing, the accent is
the app saying this one.
Status rides in a cell as mk-status — lifecycle said in a dot and a
word, a label rather than a badge. Row actions are ordinary
buttons at the one control height; delete wears the danger
spelling and nothing else does.
An empty list still has to say something — a blank table reads as a failure to load. Keep the frame, swap the body:
<div class="mk-table-wrap">
<div class="au-empty">
No pages yet. <button type="button" class="au-btn au-btn--link">Create the first one</button>
</div>
</div>
One line: what would be here, and — when there is one — the single verb that starts it.
.table, .table-sm, .table-hover — the claimed
spelling is mk-table, and hover is already in it.fw-normal on the row header is
deliberate.aria-sort is the single source of both.py-1 on a cell forks the app's rhythm at that one call site.