Click on the column header, no library needed
Sortable table columns are one of the most commonly needed data list features, yet they often pull an entire DataTables library into the project. With a single Alpine.js x-data component, a generic comparison function and a few lines for the sort direction, the same functionality emerges without any extra dependency at all.
Table of Contents
- 1. Why sortable table columns are often over engineered
- 2. Basic structure: column header, click handler and state
- 3. The sort logic: computed property with Array.prototype.sort
- 4. Toggling sort direction: ascending and descending
- 5. Type safe comparisons: numbers, dates and text
- 6. Visual sort indicators in the column header
- 7. Multi level sorting: secondary column as a tiebreaker
- 8. Accessibility: the ARIA sort attribute and keyboard
- 9. Sorting approaches compared
- 10. Summary
- 11. FAQ
1. Why sortable table columns are often over engineered
As soon as a table with multiple columns and more than a handful of rows shows up in a project, many teams reflexively reach for DataTables, ag Grid or a similar full featured table library. These libraries bring pagination, filtering, export and many other features, but often also fifty to a hundred kilobytes of extra JavaScript, just to implement sortable table columns triggered by a single click.
The actual logic behind sortable table columns is manageable: a reference to the currently sorted column, a flag for the sort direction, and a generic comparison function that sorts correctly depending on the column's data type. With Alpine.js this functionality can be implemented in a single x-data component that fits exactly into the existing table structure, without introducing an entire grid system with its own CSS and its own conventions.
This article builds fully sortable table columns from scratch: from the basic structure through type safe comparisons for numbers, dates and text, visual sort indicators, multi level sorting with a tiebreaker column, all the way to complete keyboard and screen reader support.
2. Basic structure: column header, click handler and state
The basic structure for sortable table columns needs two state variables: sortColumn stores the field name of the currently sorted column, sortDirection stores either asc or desc. Every column header gets an @click handler that checks, on click, whether the same column is already active. If it is, only the direction gets reversed, otherwise the new column gets set as active with ascending as the default direction.
This structure for sortable table columns keeps state minimal: just two variables control the entire sorting of the table, no matter how many columns actually exist. The actual rows are never mutated, but are instead provided through a computed property as a new, sorted copy of the original array, which avoids side effects and makes the component predictable.
<table x-data="sortableTable()" class="w-full text-sm border-collapse">
<thead>
<tr>
<th class="cursor-pointer select-none p-3" @click="toggleSort('name')">
Name <span x-text="sortIndicator('name')"></span>
</th>
<th class="cursor-pointer select-none p-3" @click="toggleSort('price')">
Price <span x-text="sortIndicator('price')"></span>
</th>
<th class="cursor-pointer select-none p-3" @click="toggleSort('createdAt')">
Created <span x-text="sortIndicator('createdAt')"></span>
</th>
</tr>
</thead>
<tbody>
<template x-for="row in sortedRows" :key="row.id">
<tr class="border-t border-slate-100">
<td class="p-3" x-text="row.name"></td>
<td class="p-3" x-text="row.price"></td>
<td class="p-3" x-text="row.createdAt"></td>
</tr>
</template>
</tbody>
</table>
3. The sort logic: computed property with Array.prototype.sort
The core of sortable table columns is a computed property sortedRows that returns a new, sorted copy of the original data on every access. It is important never to apply Array.prototype.sort directly to the original array, since this method mutates the array in place. Instead, a shallow copy is created first with the spread operator, which then gets sorted, keeping the original data untouched.
For sortable table columns with Alpine.js, a generic comparison function is enough, one that extracts the value of the currently sorted field from both rows being compared and, depending on direction, returns the result of localeCompare or a numeric subtraction. This single function already covers the vast majority of real world table use cases.
function sortableTable() {
return {
rows: [
{ id: 1, name: 'Widget A', price: 29.99, createdAt: '2026-01-15' },
{ id: 2, name: 'Widget B', price: 14.5, createdAt: '2026-03-02' },
{ id: 3, name: 'Widget C', price: 49.0, createdAt: '2025-11-20' },
],
sortColumn: 'name',
sortDirection: 'asc',
// Never mutate the original array — sort() mutates in place
get sortedRows() {
const copy = [...this.rows];
const direction = this.sortDirection === 'asc' ? 1 : -1;
return copy.sort((a, b) => {
const valueA = a[this.sortColumn];
const valueB = b[this.sortColumn];
if (typeof valueA === 'number' && typeof valueB === 'number') {
return (valueA - valueB) * direction;
}
return String(valueA).localeCompare(String(valueB)) * direction;
});
},
toggleSort(column) {
if (this.sortColumn === column) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortColumn = column;
this.sortDirection = 'asc';
}
},
};
}
4. Toggling sort direction: ascending and descending
Users expect sortable table columns to sort ascending on the first click, and to reverse direction on a second click on the same column. The toggleSort method from the previous section implements exactly this behavior: if the clicked column is already the active sort column, only sortDirection gets toggled between asc and desc. If a different column is clicked, the new sort always starts ascending, regardless of the previous direction of the old column.
A detail often overlooked with sortable table columns: some use cases benefit from a third state, resetting to the original, unsorted order after two clicks. For this, a third value null is introduced in addition to asc and desc, under which sortedRows returns the original data unchanged, without calling sort() at all.
function sortableTable() {
return {
rows: [ /* ... */ ],
sortColumn: null,
sortDirection: null, // 'asc' | 'desc' | null
// Three-state cycle: asc -> desc -> null (original order) -> asc ...
toggleSort(column) {
if (this.sortColumn !== column) {
this.sortColumn = column;
this.sortDirection = 'asc';
return;
}
if (this.sortDirection === 'asc') {
this.sortDirection = 'desc';
} else if (this.sortDirection === 'desc') {
this.sortColumn = null;
this.sortDirection = null;
} else {
this.sortDirection = 'asc';
}
},
get sortedRows() {
if (!this.sortColumn) return this.rows;
const copy = [...this.rows];
const direction = this.sortDirection === 'asc' ? 1 : -1;
return copy.sort((a, b) => String(a[this.sortColumn]).localeCompare(String(b[this.sortColumn])) * direction);
},
};
}
5. Type safe comparisons: numbers, dates and text
A common mistake with sortable table columns is comparing all values indiscriminately as text via localeCompare. This produces wrong results for numeric columns, because string comparisons work lexicographically rather than numerically: "10" would sort before "9", since the character "1" comes before "9" in the alphabet. For sortable table columns with mixed data types, a type specific comparison function is therefore mandatory.
Date values bring an additional challenge: as a string in the format 2026-03-02, a lexicographic comparison actually works correctly, since the ISO format behaves like a number in this respect, but it fails for other date formats such as 02.03.2026. For sortable table columns with date fields, it is therefore recommended to explicitly convert values to timestamps via new Date() before comparing, regardless of the display format.
// Column type registry drives the correct comparison strategy per field
const columnTypes = {
name: 'string',
price: 'number',
createdAt: 'date',
};
function compareValues(valueA, valueB, type) {
switch (type) {
case 'number':
return valueA - valueB;
case 'date':
return new Date(valueA).getTime() - new Date(valueB).getTime();
case 'string':
default:
return String(valueA).localeCompare(String(valueB), 'en', { sensitivity: 'base' });
}
}
function sortableTable() {
return {
rows: [ /* ... */ ],
sortColumn: 'name',
sortDirection: 'asc',
get sortedRows() {
const copy = [...this.rows];
const direction = this.sortDirection === 'asc' ? 1 : -1;
const type = columnTypes[this.sortColumn] || 'string';
return copy.sort((a, b) => compareValues(a[this.sortColumn], b[this.sortColumn], type) * direction);
},
};
}
6. Visual sort indicators in the column header
Sortable table columns without visual feedback confuse users, since it stays unclear which column currently drives the active sort and in which direction it sorts. A small arrow next to the column title that switches between pointing up and pointing down solves this problem with minimal effort. The sortIndicator method returns a suitable Unicode character or an empty string depending on the column's state.
For sortable table columns with Tailwind CSS, the same effect can also be achieved with conditional classes on an SVG arrow icon that switches between a rotation of zero and 180 degrees via :class. This variant often looks somewhat more elegant in modern interfaces than plain Unicode arrows, since size and color can be controlled consistently with the rest of the design system.
function sortableTable() {
return {
rows: [ /* ... */ ],
sortColumn: 'name',
sortDirection: 'asc',
sortIndicator(column) {
if (this.sortColumn !== column) return '';
return this.sortDirection === 'asc' ? '▲' : '▼';
},
isActiveSortColumn(column) {
return this.sortColumn === column;
},
};
}
7. Multi level sorting: secondary column as a tiebreaker
With sortable table columns that have many identical values in the primary sort column, the order within identical groups often looks arbitrary, since Array.prototype.sort is stable, but the original insertion order rarely represents the desired secondary order. Multi level sorting solves this problem by consulting a second, fixed comparison column as a tiebreaker whenever the primary column is equal.
Technically, two comparison functions are chained: if the first comparison returns 0, the second comparison decides the order. For sortable table columns with datasets where, for example, several products share the same name but have different creation dates, this approach ensures a consistent, traceable order instead of a seemingly random arrangement within identical groups.
function sortableTable() {
return {
rows: [ /* ... */ ],
sortColumn: 'name',
sortDirection: 'asc',
tiebreakerColumn: 'createdAt', // secondary sort key when primary values are equal
get sortedRows() {
const copy = [...this.rows];
const direction = this.sortDirection === 'asc' ? 1 : -1;
return copy.sort((a, b) => {
const primary = String(a[this.sortColumn]).localeCompare(String(b[this.sortColumn]));
if (primary !== 0) return primary * direction;
// Tiebreaker: always ascending, regardless of the primary direction
return String(a[this.tiebreakerColumn]).localeCompare(String(b[this.tiebreakerColumn]));
});
},
};
}
8. Accessibility: the ARIA sort attribute and keyboard
For accessible sortable table columns, every clickable column header needs the ARIA attribute aria-sort with one of the values ascending, descending or none, so screen reader users correctly hear the current sort state of a column read aloud. In addition, the column header should be operable without a mouse via tabindex="0" and an @keydown.enter handler, since a plain @click handler on a th element is not keyboard reachable by default.
On top of that, sortable table columns benefit from the columnheader role and a descriptive aria-label that summarizes the column name and current sort direction in one sentence, for example "Sorted by price, ascending". These small additions are the difference between a table that only works visually and one that is genuinely usable by every user group.
<th
scope="col"
tabindex="0"
role="columnheader"
class="cursor-pointer select-none p-3"
@click="toggleSort('price')"
@keydown.enter="toggleSort('price')"
:aria-sort="sortColumn === 'price' ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'"
>
Price <span x-text="sortIndicator('price')" aria-hidden="true"></span>
</th>
9. Sorting approaches compared
Several technical implementations exist for sortable table columns, each with different trade offs.
| Approach | Bundle size | Customizability | Best fit |
|---|---|---|---|
| Alpine.js computed property | 0 KB extra | Full | Default case, any project size |
| DataTables | about 90 KB gzip with jQuery | Via plugin configuration | Legacy projects with many extra features |
| ag Grid Community | about 250 KB gzip | Extensive | Complex enterprise data grids |
| Server side sorting | 0 KB frontend | Depends on the backend | Very large datasets, pagination required |
For the vast majority of real world use cases with data already loaded client side, the Alpine.js solution for sortable table columns is convincing due to its minimal bundle size and full control over behavior and appearance. Only for very large datasets that require server side pagination does the sort logic sensibly move into the database query itself.
Mironsoft
Alpine.js components for Hyvä, Magento and custom frontends
Need a custom sortable table or another Alpine.js component?
We build tailored Alpine.js components, from sortable data tables to filter lists and complex forms, cleanly integrated into your existing Hyvä or Magento frontend.
Concept
Clarifying the data model and sort logic for your table
Implementation
Type safe comparisons, multi level sorting and accessibility
Integration
Clean integration into existing Hyvä and Magento frontends
10. Summary
Sortable table columns can be fully implemented with Alpine.js without loading a heavyweight table library. Two state variables for column and direction, a computed property that delivers a sorted copy of the data, and a type specific comparison function for numbers, dates and text form the complete foundation.
For production use, visual sort indicators, multi level sorting with a tiebreaker column, and full accessibility through aria-sort and keyboard support round things out. Together this produces a solution for sortable table columns that beats any ready made DataTables library in bundle size and performance, as long as the data is already available client side.
Sortable Table Columns with Alpine.js — The Essentials at a Glance
State
Two variables: sortColumn and sortDirection control the entire sort.
Sort logic
Computed property with a copy of the original array, never a direct mutation via sort().
Type safety
A column type registry decides between numeric, date and string comparison.
Accessibility
aria-sort, tabindex and @keydown.enter for full keyboard operation.