Sortable Table Columns with Alpine.js, No Library
AI generated
x-data
Alpine
Alpine.js · Tables · Case Study
Sortable Table Columns with Alpine.js
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.

20 min read x-data · Array.prototype.sort · computed property Alpine.js 3.x

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.

11. FAQ: Sortable Table Columns with Alpine.js

1Why not sort() directly on original data?
sort() mutates in place, always create a copy with the spread operator first.
2How do I sort mixed data types?
Via a column type registry with numeric, date or string comparison.
3How does toggling direction work?
Click on active column reverses direction, click on new column starts ascending.
4How do I show the active column?
With a sortIndicator method and an arrow symbol in the column header.
5What is multi level sorting?
A tiebreaker column decides when the primary column is equal.
6How do I make it accessible?
With aria-sort, tabindex and a keydown.enter handler.
7When is server side sorting needed?
As soon as pagination is required and data is not fully available.
8How do I sort dates correctly?
Convert to timestamps with new Date() before comparing.
9Third state for original order?
Yes, sortDirection can also take the value null.
10Build it or use DataTables?
For pure sorting, almost always build it, for bundle size reasons.