Sortable tables are one of those UI patterns that look simple but have a handful of tricky details: tracking sort direction, toggling it on repeat clicks, keeping the visual indicator in sync, and not breaking the layout when a column contains an image alongside text. Alpine.js handles the logic in a few dozen lines of reactive JavaScript, and Tailwind CSS handles the styling without a separate stylesheet.
This guide builds a complete, working sortable table with a thumbnail image column from scratch. The code is self-contained — paste it into any HTML file or Astro component and it runs. We'll use Alpine.js v3 (the current stable release) and Tailwind CSS v4 with the CDN play build so there's no compile step while you prototype.
What we're building
A product table with five columns: a thumbnail, a product name, a category, a price, and a rating. Clicking any column header re-sorts the rows. Clicking the same header again reverses the order. A small arrow indicator shows the active column and direction.
Set up the HTML shell
Include both libraries via CDN. Alpine must load after the Tailwind CDN in the <head>, and Alpine itself should use the defer attribute so it initialises after the DOM is parsed.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sortable Table</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
</head>
<body class="p-8 bg-gray-50">
<!-- table goes here -->
</body>
</html>
If you're in an Astro project using Tailwind CSS v4 with a CSS-first @theme setup, you already have Tailwind wired in. Just add Alpine via npm install alpinejs and import it in your layout's client script, or load it from the CDN as above.
Define the Alpine component
Alpine's x-data directive turns any element into a reactive component. All state and methods live inside the object you pass to it. Here's the full JavaScript data object for the table:
{
sortCol: 'name',
sortAsc: true,
products: [
{
image: 'https://placehold.co/48x48/e2e8f0/475569?text=A',
name: 'Apex Trowel Set',
category: 'Masonry',
price: 34.99,
rating: 4.7
},
{
image: 'https://placehold.co/48x48/e2e8f0/475569?text=B',
name: 'Basin Wrench Pro',
category: 'Plumbing',
price: 22.50,
rating: 4.2
},
{
image: 'https://placehold.co/48x48/e2e8f0/475569?text=C',
name: 'Circuit Tracer Kit',
category: 'Electrical',
price: 89.00,
rating: 4.9
},
{
image: 'https://placehold.co/48x48/e2e8f0/475569?text=D',
name: 'Deck Screw Assortment',
category: 'Fasteners',
price: 15.75,
rating: 4.4
},
{
image: 'https://placehold.co/48x48/e2e8f0/475569?text=E',
name: 'Extension Cord Reel',
category: 'Electrical',
price: 47.00,
rating: 3.8
}
],
get sorted() {
return [...this.products].sort((a, b) => {
const valA = a[this.sortCol];
const valB = b[this.sortCol];
if (typeof valA === 'string') {
return this.sortAsc
? valA.localeCompare(valB)
: valB.localeCompare(valA);
}
return this.sortAsc ? valA - valB : valB - valA;
});
},
setSort(col) {
if (this.sortCol === col) {
this.sortAsc = !this.sortAsc;
} else {
this.sortCol = col;
this.sortAsc = true;
}
}
}
Three things worth noting here. First, sorted is a getter, not a method — Alpine evaluates it as a computed property each time any reactive state it references changes, so the table re-renders automatically whenever sortCol or sortAsc changes. Second, the spread [...this.products] creates a shallow copy before sorting so the original array order is preserved. Third, the sort branch checks typeof valA === 'string' so string columns use localeCompare (correct for names and categories) while numeric columns use subtraction (correct for price and rating).
Build the table markup
Now wrap the data object in x-data and build the table. The key directives are @click on each <th> to call setSort, and x-for on a <tr> to loop over the sorted getter.
<div
x-data="{
sortCol: 'name',
sortAsc: true,
products: [
{ image: 'https://placehold.co/48x48/e2e8f0/475569?text=A', name: 'Apex Trowel Set', category: 'Masonry', price: 34.99, rating: 4.7 },
{ image: 'https://placehold.co/48x48/e2e8f0/475569?text=B', name: 'Basin Wrench Pro', category: 'Plumbing', price: 22.50, rating: 4.2 },
{ image: 'https://placehold.co/48x48/e2e8f0/475569?text=C', name: 'Circuit Tracer Kit', category: 'Electrical', price: 89.00, rating: 4.9 },
{ image: 'https://placehold.co/48x48/e2e8f0/475569?text=D', name: 'Deck Screw Assortment', category: 'Fasteners', price: 15.75, rating: 4.4 },
{ image: 'https://placehold.co/48x48/e2e8f0/475569?text=E', name: 'Extension Cord Reel', category: 'Electrical', price: 47.00, rating: 3.8 }
],
get sorted() {
return [...this.products].sort((a, b) => {
const valA = a[this.sortCol];
const valB = b[this.sortCol];
if (typeof valA === 'string') {
return this.sortAsc ? valA.localeCompare(valB) : valB.localeCompare(valA);
}
return this.sortAsc ? valA - valB : valB - valA;
});
},
setSort(col) {
if (this.sortCol === col) { this.sortAsc = !this.sortAsc; }
else { this.sortCol = col; this.sortAsc = true; }
}
}"
class="overflow-x-auto rounded-xl shadow-sm"
>
<table class="w-full text-sm text-left bg-white border border-gray-200 rounded-xl">
<thead class="bg-gray-100 text-gray-600 uppercase text-xs tracking-wider">
<tr>
<!-- Image column: not sortable -->
<th class="px-4 py-3 w-16">Photo</th>
<!-- Sortable columns -->
<template x-for="col in [
{ key: 'name', label: 'Product' },
{ key: 'category', label: 'Category' },
{ key: 'price', label: 'Price' },
{ key: 'rating', label: 'Rating' }
]" :key="col.key">
<th
@click="setSort(col.key)"
class="px-4 py-3 cursor-pointer select-none hover:bg-gray-200 transition-colors"
:aria-sort="sortCol === col.key ? (sortAsc ? 'ascending' : 'descending') : 'none'"
>
<span class="flex items-center gap-1">
<span x-text="col.label"></span>
<span class="text-gray-400" x-show="sortCol === col.key">
<span x-show="sortAsc">↑</span>
<span x-show="!sortAsc">↓</span>
</span>
</span>
</th>
</template>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-for="product in sorted" :key="product.name">
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-4 py-3">
<img
:src="product.image"
:alt="product.name"
class="w-12 h-12 rounded-md object-cover"
/>
</td>
<td class="px-4 py-3 font-medium text-gray-900" x-text="product.name"></td>
<td class="px-4 py-3 text-gray-600" x-text="product.category"></td>
<td class="px-4 py-3 text-gray-800" x-text="'$' + product.price.toFixed(2)"></td>
<td class="px-4 py-3 text-gray-800" x-text="product.rating.toFixed(1)"></td>
</tr>
</template>
</tbody>
</table>
</div>
How each piece works
The image cell
The <img> tag uses :src and :alt — Alpine's shorthand for x-bind:src and x-bind:alt — to bind both attributes reactively to the current product object. The image column is intentionally left out of the sortable header loop because sorting by a URL string is meaningless. You could add a hidden altText field and sort by that, but the cleaner default is to leave Photo non-sortable.
The arrow indicator
Two nested <span> elements with x-show toggle the up and down arrows independently. This avoids a ternary in x-text, which can be brittle when the values are HTML entities. The outer x-show="sortCol === col.key" hides the entire indicator for inactive columns.
Accessibility: aria-sort
The :aria-sort binding on each <th> sets the correct value (ascending, descending, or none) so screen readers can announce the sort state. This is the minimum accessible markup for a sortable column header. If you want full ARIA compliance, also add role="columnheader" to each <th> and scope="col" — both are valid HTML attributes.
Extracting to Alpine.data() for larger projects
Inline x-data strings get unwieldy beyond a handful of fields. The cleaner pattern for an Astro component or a separate .js file is Alpine.data():
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('productTable', () => ({
sortCol: 'name',
sortAsc: true,
products: [ /* ... your data ... */ ],
get sorted() {
return [...this.products].sort((a, b) => {
const valA = a[this.sortCol];
const valB = b[this.sortCol];
if (typeof valA === 'string') {
return this.sortAsc ? valA.localeCompare(valB) : valB.localeCompare(valA);
}
return this.sortAsc ? valA - valB : valB - valA;
});
},
setSort(col) {
if (this.sortCol === col) { this.sortAsc = !this.sortAsc; }
else { this.sortCol = col; this.sortAsc = true; }
}
}));
});
</script>
<div x-data="productTable">
<!-- same table markup as above -->
</div>
This script must run before Alpine initialises, which is why it listens for the alpine:init event rather than DOMContentLoaded. In an Astro component, put this in a <script> tag at the bottom of the component file and it will be bundled and deferred automatically.
Loading real data from an API or Astro content collection
For a static Astro site you'll often want to pass server-rendered data into the Alpine component instead of hard-coding it in JavaScript. The cleanest approach is to serialize the data as JSON in a data-* attribute and read it in x-init:
<!-- In your .astro file -->
---
import { getCollection } from 'astro:content';
const products = await getCollection('products');
---
<div
x-data="productTable"
x-init="products = JSON.parse($el.dataset.products)"
:data-products={JSON.stringify(products.map(p => p.data))}
>
<!-- table markup -->
</div>
This pattern keeps the Alpine component logic generic and passes real data in at render time, which means no client-side fetch is needed and the initial render is immediate.
Tailwind CSS v4 note
The utility classes in this guide — divide-y, tracking-wider, object-cover, overflow-x-auto — all work identically in Tailwind v4. If you're using the v4 CSS-first setup with a @theme block, you can replace the placeholder colors (gray-50, gray-100, etc.) with your own custom tokens. For example:
/* In your main CSS file */
@import "tailwindcss";
@theme {
--color-surface: #f8f9fa;
--color-border: #e2e8f0;
--color-muted: #64748b;
}
Then use bg-surface, border-border, and text-muted in your markup instead of the default gray scale. The table structure doesn't change — only the color tokens.
Common pitfalls
- Sorting mixed types: If a column can be
nullorundefined, add a null guard before the comparison:if (valA == null) return 1; if (valB == null) return -1;— this pushes blank values to the bottom regardless of sort direction. - Image aspect ratio: Use
object-coverwith explicitw-*andh-*classes so thumbnails don't distort when the source images have varying aspect ratios. - Hydration in Astro: Alpine components in Astro do not need a
client:*directive — Alpine is not a framework component. Load it once via a<script>tag in your layout and it works across every page. - Large datasets: The getter re-sorts on every reactive change. For tables with thousands of rows, debounce the sort or move the comparison outside Alpine into a Web Worker. For most service business use cases (dozens to low hundreds of rows) this is not a concern.
That's the complete pattern. The Alpine reactive getter keeps the sort logic clean and automatic, the Tailwind utility classes handle spacing and hover states without a single line of custom CSS, and the image column fits naturally into a standard <td> with x-bind attributes. From here you can extend it with a search filter (x-model on a text input, filter the array before sorting), pagination, or a row-click action — all within the same x-data object.