One sortable column heading for a table you already have: press it and the rows re-order, press it again for the other direction, press it a third time and the table goes back to the order it arrived in. Reach for it wherever a list of rows is long enough that people want it arranged their own way — an admin users or accounts table, an orders, invoices or billing-history list, search results, a dashboard's data table, a logs or audit-trail view, a file or document browser, a products or inventory grid, a leaderboard or ranking, a tickets/issues queue, transactions, and any report with a date, size, count or amount column. Common asks it answers: "sortable table header react", "table column sorting react", "click column header to sort", "sort table by column react", "shadcn sortable table", "shadcn table sorting", "shadcn data table sort without tanstack", "tanstack table alternative for just sorting", "react-table is too much for one column", "sort indicator arrow table header", "three state sort asc desc none", "how to clear sorting in a table", "reset table sort to default order", "aria-sort react", "accessible sortable table", "screen reader table sorting", "aria-sort not announced", "two columns both say ascending", "table header button accessibility", "th onclick not keyboard accessible", "sort numbers in strings correctly", "Item 10 sorts before Item 2", "natural sort react", "localeCompare numeric true", "Intl.Collator sort table", "accented names sort to the bottom", "null values first when sorting descending", "empty cells at top when sorting", "sorting mutates state array react", "table not re-rendering after sort", "テーブル ソート 見出し react", "並び替え カラム". Official shadcn/ui gives you nothing to start from here, and the measurement is not close: fetching every entry in its registry today — 63 listed, 62 fetchable, questionnaire is indexed and 404s on both style tracks — and grepping all 211,725 bytes of source, aria-sort, ariaSort, onSort, sortable, sortBy, sortDirection, ascending, descending, localeCompare, Intl.Collator, toSorted, ArrowUpDown and ChevronsUpDown are every one of them a zero hit. Its table component is 2,859 bytes of six styling wrappers in which the word sort never appears once. So every sortable table gets hand-rolled, and three things go wrong each time. The first is that the sort has two states when it needs three. Flipping between ascending and descending quietly takes something away: once a column has been pressed, the order the table came in — almost always the meaningful one, newest first, or a relevance rank the server computed — cannot be got back. There is no third press, no button that says stop sorting, and reloading the page is the only way out, which is exactly what people do. Here the cycle is first direction, opposite, gone, and gone hands back null so you render the rows as they came. Which direction comes first is per column, because ascending is the wrong first guess for a date, a size, a count or a score, where the first press is meant to mean newest or biggest and answers with the oldest and smallest instead. The second is aria-sort, which is not an attribute saying a column can be sorted — it says how the table is ordered right now, so it belongs on exactly one header at a time and reads none on the others. Held as a flag on each header, the failure always has the same shape: pressing a second column sets the new one and forgets to unset the old, and a screen reader is told two different columns are each sorting the table, which looks perfectly fine on screen. This takes the whole table's sort state as one value and derives each header's share of it, so that state cannot be represented. It also puts the attribute on the <th> rather than on the button inside it: aria-sort is defined for columnheader, and on a role=button it is silently dropped — it validates, it looks done, and it announces nothing. The third is the hit area. Putting onClick on a <th> gives a table that sorts with a mouse and for nobody else: nothing to tab to, nothing answering Enter or Space, and a cell that gives no hint it does anything, because a <th> is not interactive and no amount of ARIA makes it so. The heading here is a real button stretched across the cell — the cell gives up its padding to it — so it gets the keyboard, the focus ring and the announcement for free, and the pressable area matches the thing that looks pressable. Sorting a table also announces nothing on its own: the rows are replaced, focus has not moved, and aria-sort changing on an element is not an event any reader speaks. So a press puts "Table sorted by Name, ascending." through a polite live region — from the header that was actually pressed, not from the one that just lost the sort — and clears it again a moment later, since a live region inside a header cell would otherwise become part of that cell's own content and be read back on every future visit. The comparison is exported separately as sortRows, compareValues and isBlankValue, because the browser and the server that paginates the same table have to agree on what the order is. Descending is the negated comparison, never the ascending result reversed: reversing floats every empty cell to the top the moment the arrow flips, and scrambles the rows that tied on the way up, so the same three Pending rows appear in a different order each way and the table reads as if it is shuffling itself. Blank cells — null, undefined, empty string, NaN, an invalid Date, but not 0 and not false — stay at the bottom in both directions. Text goes through Intl.Collator with numeric ordering, so Item 2 comes before Item 10 and Ångström does not land below Zulu where nobody scrolls; dates compare as instants and booleans as false-then-true. It never sorts the array you pass it, which is the bug that renders nothing at all, and it is meant to be run over the rows the server sent rather than the ones already on screen — sorting what is displayed lets the previous sort survive inside every group of ties, so the table depends on the order of presses rather than on its state. A disabled heading keeps its tab stop and ignores presses instead of taking the disabled attribute, which would drop it out of the tab order and lose a keyboard user's place the moment the table starts loading. align="end" right-aligns a numeric column and moves the arrow to the label's left so the heading stays flush with the figures beneath it, and the unsorted double-chevron is visible at rest rather than on hover, because hover does not exist on a phone and it is the only thing saying the column can be sorted at all. useSortHeader, nextSortState and ariaSortFor are exported for a header laid out as divs or one that already holds a filter menu. Within pulld it is the table counterpart to sortable-list, which drags rows into an order a person chooses rather than computing one, and it sits beside scroll-shadow and virtual-list, the other two pieces a long table wants. One file, one column, your <table> stays yours, and its only dependency is lucide-react for the arrows.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/sort-header.json"