<sortable-table-elemental>
Wrap a <table> and its column headers sort it. You write the table; it writes the buttons.
There is no APG pattern here, because <table> already is one β the roles, the row and column
relationships and the header associations are the element's own and nothing here replaces any of
them. What is missing is the one thing the markup has no way to say, and the
APG's sortable table example
says exactly what it is: the header text wrapped in a <button>, aria-sort on the column that
is sorted, and a note in the <caption> explaining the buttons β once, rather than repeated into
every button's name.
<sortable-table-elemental>
<table>
<caption>Peaks</caption>
<thead>
<tr>
<th>Name</th>
<th>Height</th>
<th>First climbed</th>
<th data-sort="none">Notes</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">MidΕΎor</th><td>2169</td><td data-sort-value="1890-07-02">2 Jul 1890</td><td>on the border</td></tr>
<tr><th scope="row">Δeravica</th><td>2656</td><td data-sort-value="1902-08-14">14 Aug 1902</td><td>the highest</td></tr>
<tr><th scope="row">Rtanj</th><td>1560</td><td data-sort-value="1877-05-30">30 May 1877</td><td>a pyramid</td></tr>
<tr><th scope="row">Kopaonik</th><td>2017</td><td data-sort-value="1884-06-11">11 Jun 1884</td><td>skiing</td></tr>
</tbody>
</table>
</sortable-table-elemental>
/* the rule under the header row and the stripes are the theme's; every line here is the page's */
table { border-collapse: collapse; inline-size: 100%; }
caption { margin-block-end: 0.5rem; font-weight: 600; text-align: start; }
/* a hairline under each cell and none over it β the row above already drew this row's top */
th, td { padding: 0.4rem 0.6rem; text-align: start; border-block-end: 1px solid color-mix(in srgb, CanvasText 20%, transparent); }
tbody th { font-weight: 400; }Press Height and the rows sort as numbers, not as text. Press First climbed and they sort
by the ISO dates in data-sort-value, not by the words you can see. Press the same header twice
and it turns round. Notes is marked data-sort="none" and has no button. Sort by name, then
by height: inside one height the names are still in order.
The markup
A table. That is the whole of it:
<sortable-table-elemental>
<table>
<thead><tr><th>Name</th><th>Height</th></tr></thead>
<tbody>β¦</tbody>
</table>
</sortable-table-elemental>
- Every
<th>in the header row gets a button, unless it is markeddata-sort="none"β for a column of free text, or of buttons, where sorting means nothing. data-sort-valueon a cell is what that cell sorts by. A date reads3 Aug 2026and sorts by2026-08-03; a price reads$1,200and sorts by1200.- The header row is the last row of the
<thead>, so a grouped header β a row of spanning labels over a row of real columns β puts the buttons on the columns rather than on the groups. - The rows are the first
<tbody>'s. A table with several is using them to group, and moving a row between groups would be sorting the grouping away.
What it writes
<th aria-sort="ascending"><button type="button">Name</button></th>
and, appended to the caption, off screen:
<caption>Peaks <span class="sortable-table-elemental-note">Column headers with a button sort the table by that column.</span></caption>
The caption is the table's accessible name, so that sentence is read on the end of it β measured
in Chromium, the name comes out as Peaks Column headers with a button sort the table by that column. It is there rather than in each button's name because that is the APG example's own
reasoning: a reader walking the header row should hear the column, not the same sentence about
buttons once per column. Set note-text to say it in your language, or differently.
If the table has no <caption>, one is created holding only that note. It is clipped, so the
caption box measures zero β no visible change, and the table gains an accessible name it did not
have.
There is no live region, and that is a decision rather than an omission. The example this
follows has none, and the reason it needs none is that the rows reordering is the result of
pressing the button, not a message about it β
WCAG 2.2 4.1.3 is written for
changes that happen when no control was operated. aria-sort is where the state lives, on the
column header, which is where a reader goes to find it.
There is no keyboard handling either, and for the same kind of reason: the only interactive things
here are <button>s, and every key they answer is the browser's.
How it compares
One comparator, and no type attribute anywhere.
Intl.Collator with
numeric: true puts item 2 before item 10 and 9 before 100 without being told which
column is which, sorts an ISO date correctly as text, and collates letters by the document's own
lang rather than by code point β so Δeravica lands under D in a Serbian page and where that
page's language says otherwise elsewhere. It is built per sort and pointed at
document.documentElement.lang, so a page that swaps its language swaps its collation with it.
What one comparator cannot do is a value that is not what it reads as:
| The cell reads | Sorted by its text | With data-sort-value |
|---|---|---|
$1,200 vs $900 |
$1 against $9 β the larger first, ascending |
1200 and 900 |
3 Aug 2026 |
the word 3 |
2026-08-03 |
n/a in a numeric column |
among the numbers, by the letter n | "", gathered at one end |
data-sort-value="" is an author saying this cell sorts as empty, and it is honoured β the one
place where an empty attribute has to mean something rather than nothing.
Sorting is stable, and that is the property nobody notices until it is missing. Rows with
equal keys never move, so sorting by name and then by height leaves the names in order inside each
height. Array.prototype.sort has been required to be stable since ES2019, but that only covers
one sort in one direction β the descending case reverses the comparator, and reversing it whole
would reverse equal rows too. The original position is carried into the tiebreak instead.
A table that arrived sorted
The markup's aria-sort is believed rather than re-sorted. Write it on the header the server
sorted by, and the element adopts the state and leaves the rows exactly as they came:
<th aria-sort="descending">Height</th>
A table that arrives ordered was ordered by the server, possibly by a key that is not in the DOM
at all β an id, a rank, a relevance score. An element that re-sorted it on upgrade would silently
reorder correct data into the order the visible text happens to give. data-sort-value is how
you make the two agree when you want them to.
Events
| Event | When | detail |
|---|---|---|
sortable-table-sort |
a header button was pressed and the rows moved | column β the header's index in its row, key β its text, direction β ascending or descending |
Not fired for the aria-sort adopted at upgrade, which is state the page wrote itself.
Attributes
| Attribute | Type | Default | What it does |
|---|---|---|---|
note-text |
string | Column headers with a button sort the table by that column. |
The sentence appended to the caption |
On the cells, not on the element:
| Attribute | On | What it does |
|---|---|---|
data-sort="none" |
a header <th> |
No button. The column keeps its text and does not sort |
data-sort-value |
any body cell | What that cell sorts by, instead of its text |
aria-sort |
a header <th> |
The table already arrived sorted this way. Adopted, not re-sorted |
Without script
Your table, in the order it arrived, and no buttons β which is a table, and a working page. The
element is display: contents, so dropping it around a table you already had changes no layout at
all, upgraded or not; give it display: block in your own CSS if you want something to hang an
overflow-x on.
Styling
The element's own stylesheet styles no table. What it does is make the upgrade invisible: a
<button> inside a <th> arrives with the UA's font, colour, background, border, centre
alignment β and, less famously, its own text-transform, letter-spacing, word-spacing,
line-height, text-indent and text-shadow. Every one of them is put back to what the cell
already had. That last six is what a header written text-transform: uppercase needs to still be
uppercase once it is a button; measured in Chromium, a <th> computing uppercase hands its
button none. None of this is a look β it is the header not starting to look like a form control
because it became one β which is why it is in the structure file and not the theme.
The button is inline-size: 100%, so the target is the column rather than the four words in it.
It stops at the cell's padding, because reaching into that would mean moving your padding onto
this button.
Both stylesheets reach only the buttons this element wrote β its own table, the last row of the
<thead>, and not a header marked data-sort="none". A button of your own in a
<th scope="row"> keeps its look and gets no arrow.
| Custom property | Default | What it does |
|---|---|---|
--sortable-table-elemental-indicator-color |
currentcolor |
Theme. The arrow on the sorted column |
--sortable-table-elemental-hint-opacity |
0.35 |
Theme. How visible the arrow is on a column that is not the sorted one β the affordance, shown on every sortable header |
--sortable-table-elemental-rule |
currentcolor at 20% |
Theme. The 2px rule under the header row |
--sortable-table-elemental-stripe |
currentcolor at 4% |
Theme. The tint on every second body row |
The theme has two opinions about the table itself, and no more. A 2px rule under the header
row, and a stripe on every second body row β both of them what a sortable table needs
rather than what a pretty one wants: the rule separates the row you press from the rows you are
reading, and the stripe is how an eye keeps its place across a row once the order under it can
change. The stripes are nth-child, so they stay where they are on the screen and the rows
travel through them; a stripe that moved with its row would be a second thing changing on every
press. Nothing about padding, fonts or cell borders β that part is a design system's answer, and
yours. Both colours are mixed out of currentcolor, so they land in the palette the page already
has, dark mode included.
The arrow is drawn with borders rather than written as β², for the reason
<tree-view-elemental>'s chevron is a masked icon: text in a pseudo-element is
read out by some screen readers, and aria-sort already says which way this column is sorted, in
the reader's own language. It is laid out inline rather than with flex so that this stylesheet has no opinion about
where your header text sits β CSS cannot read text-align, and every version of this that tried
to guess it got one alignment or the other wrong.
The faint arrow is on every sortable column, not only the one under the pointer. A control nobody can see is a control nobody presses, and hover is not a state a reader has before they have already guessed the feature is there β which leaves a keyboard reader finding it by tabbing in and a touch reader not at all. It points up, which is the direction the first press sorts; full weight is what says this column is the sorted one.
What it will not do
No multi-column sort β a second sort key is a comparator the markup has nowhere to put. No sort-on-load, no persistence, no paging, no filtering, no column resizing, no row selection; those are a data grid, and a data grid is the APG's Grid pattern and a different project.
No spanning cells in the body. rowspan sorting would tear apart; colspan is quieter and worse
β the column a header sorts by is that header's position in its row, so one spanning cell shifts
every cell after it along and the column sorts by its neighbour's text, in an order that looks
like an order. The element checks for neither, and a table with either should not have this
element around it.
@use "book-of-elementals/sortable-table/style.scss";
@use "book-of-elementals/sortable-table/theme.scss"; // optional
import 'book-of-elementals/sortable-table';