Press / to search

    No results

    Search failed

    npm GitHub

    <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 marked data-sort="none" β€” for a column of free text, or of buttons, where sorting means nothing.
    • data-sort-value on a cell is what that cell sorts by. A date reads 3 Aug 2026 and sorts by 2026-08-03; a price reads $1,200 and sorts by 1200.
    • 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';