lexington®
Use code LEX35 at checkout
7k+ customers.
How to build a sorting table with images using Alpine.js and Tailwind CSS
Learn how to create a sortable table with avatars, flags, and dynamic sorting using Alpine.js and Tailwind CSS.
Published on October 31, 2025 by Michael AndreuzzaLet’s build a sortable, image‑rich table
Sorting tables are a staple of modern dashboards and admin panels. This guide shows you how to build a clean, responsive table with avatars, emoji flags, and custom sorting using Alpine.js and Tailwind CSS—no backend needed.
Heads up: Make sure Tailwind CSS and Alpine.js are already set up in your project. You can drop the snippet into any Astro/HTML page and it just works.
1. Component structure and data
We’ll use a single Alpine component to manage state (current sort key/direction) and handle sorting. Each row includes:
- name, email
- avatar image
- location object: city, country, flag emoji
- status and performance labels
Data shape
<div
x-data="{
sortKey: 'name',
sortDir: 'asc',
collator: new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
statusRank: { Active: 2, Inactive: 1 },
performanceRank: { Excellent: 3, 'Very Good': 2, Good: 1 },
rows: [] // your data array; see full demo at the end
}"
>
<!-- Table markup covered below -->
<!-- Sorting + resolver methods shown in the next sections -->
</div>
2. Sorting controls
Clicking a header calls setSort(key):
setSort(key){
if (this.sortKey === key) {
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
} else {
this.sortKey = key;
this.sortDir = 'asc';
}
}
- Clicking the same column toggles direction
- Clicking a different column starts at ascending
3. Robust sorting (strings, labels, and diacritics)
getValue() normalizes values before compare. Location sorts by city; status and performance use numeric ranks so “Excellent” > “Very Good” > “Good”. For strings, Intl.Collator gives better results for names like “São Paulo”.
getValue(row, key){
if (key === 'location') return row.location.city;
if (key === 'status') return this.statusRank[row.status] ?? 0;
if (key === 'performance') return this.performanceRank[row.performance] ?? 0;
return row[key];
}
sorted(){
const a = [...this.rows];
const k = this.sortKey;
const dir = this.sortDir === 'asc' ? 1 : -1;
return a.sort((x, y) => {
const vx = this.getValue(x, k);
const vy = this.getValue(y, k);
const cmp = (typeof vx === 'number' && typeof vy === 'number')
? (vx - vy)
: this.collator.compare(String(vx), String(vy));
return cmp * dir;
});
}
Collation and diacritics
Intl.Collatorhandles locale rules, case, and accents better than manualtoLowerCase()compares.- The
numeric: trueoption sorts embedded numbers naturally (e.g.,v2<v10). - Passing
undefineduses the user’s locale; set a specific one (e.g.,en) if you need consistent ordering across regions.
Sorting categorical labels
- Labels like Status/Performance don’t have an inherent order; map them to numbers with rank objects and sort the numbers.
- Keep the rank maps (
statusRank,performanceRank) outside the sort function to avoid re-creating them.
Tie‑breakers and nulls
Add a tie‑breaker so stable sorting doesn’t “jump” when values are equal, and push null/undefined to the end:
sorted(){
const a = [...(this.filtered ? this.filtered() : this.rows)];
const k = this.sortKey;
const dir = this.sortDir === 'asc' ? 1 : -1;
return a.sort((x, y) => {
const vx = this.getValue(x, k);
const vy = this.getValue(y, k);
// Nulls last
if (vx == null && vy == null) return 0;
if (vx == null) return 1;
if (vy == null) return -1;
const cmp = (typeof vx === 'number' && typeof vy === 'number')
? (vx - vy)
: this.collator.compare(String(vx), String(vy));
if (cmp !== 0) return cmp * dir;
// Tie-breaker by email for stability
return this.collator.compare(x.email, y.email) * dir;
});
}
4. Markup and accessibility
- Use
scope="col"on headers andaria-sortto expose sort state - Keep buttons inside
thfor keyboard/screen reader support - Provide alt text for avatars using the user’s name
5. Responsive layout and styling
- Wrap the table with
overflow-x-autoto enable horizontal scroll on small screens - Use
min-w-[900px]to prevent cramped columns - Zebra striping with
even:bg-zinc-50improves readability
Performance tips
- Keep
collatoras a single instance on the component (as shown) to avoid re-creating it insidesorted(). - Sort a copy (
[...rows]) so Alpine can detect changes without mutating your source array. - For very large lists, consider precomputing per‑row sort keys or using virtualization; the compare logic remains the same.
- Reset to a known page when sorting if you add pagination later (e.g.,
page = 1).
Optional: Add basic search
Want quick filtering? Add a query property and a small filter:
<input
type="search"
placeholder="Search…"
x-model="query"
class="mb-2 w-full max-w-xs rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-800 placeholder-zinc-400"
/>
query: '',
filtered(){
const q = this.query.trim().toLowerCase();
if (!q) return this.rows;
return this.rows.filter(r => [
r.name,
r.email,
r.location.city,
r.location.country,
r.status,
r.performance,
].some(v => String(v).toLowerCase().includes(q)));
},
sorted(){
const a = [...this.filtered()];
// … use the same compare logic as above
}
Bonus: Complete working demo
Here’s the full component assembled. Copy, paste, and customize the rows array and classes as needed.
<div
x-data="{
sortKey: 'name',
sortDir: 'asc',
// Optional: locale‑aware string compare
collator: new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
// Custom rank for label sorting
statusRank: { Active: 2, Inactive: 1 },
performanceRank: { Excellent: 3, 'Very Good': 2, Good: 1 },
rows: [
{ name: 'Alex Allan', avatar: '/avatars/1.jpg', email: 'alex.allan@company.com', location: { country: 'Brazil', city: 'São Paulo', flag: '🇧🇷' }, status: 'Active', performance: 'Excellent' },
{ name: 'Alex Thompson', avatar: '/avatars/2.jpg', email: 'a.tompson@company.com', location: { country: 'US', city: 'San Francisco', flag: '🇺🇸' }, status: 'Inactive', performance: 'Excellent' },
{ name: 'Anna Visconti', avatar: '/avatars/3.jpg', email: 'anna.visconti@company.com', location: { country: 'Italy', city: 'Rome', flag: '🇮🇹' }, status: 'Active', performance: 'Good' },
{ name: 'Astrid Andersen', avatar: '/avatars/4.jpg', email: 'a.andersen@company.com', location: { country: 'Norway', city: 'Oslo', flag: '🇳🇴' }, status: 'Inactive', performance: 'Good' },
{ name: 'Cheng Wei', avatar: '/avatars/5.jpg', email: 'c.wei@company.com', location: { country: 'China', city: 'Shanghai', flag: '🇨🇳' }, status: 'Active', performance: 'Excellent' },
{ name: 'David Kim', avatar: '/avatars/6.jpg', email: 'd.kim@company.com', location: { country: 'France', city: 'Paris', flag: '🇫🇷' }, status: 'Active', performance: 'Very Good' },
{ name: 'Diego Mendoza', avatar: '/avatars/7.jpg', email: 'd.mendoza@company.com', location: { country: 'Mexico', city: 'Mexico City', flag: '🇲🇽' }, status: 'Active', performance: 'Good' },
{ name: 'Emma Laurent', avatar: '/avatars/8.jpg', email: 'e.laurent@company.com', location: { country: 'Germany', city: 'Berlin', flag: '🇩🇪' }, status: 'Active', performance: 'Very Good' },
{ name: 'Eva Kowalski', avatar: '/avatars/9.jpg', email: 'e.kowalski@company.com', location: { country: 'South Korea', city: 'Seoul', flag: '🇰🇷' }, status: 'Active', performance: 'Good' },
{ name: 'Fatima Al-Sayed', avatar: '/avatars/10.jpg', email: 'f.alsayed@company.com', location: { country: 'Egypt', city: 'Cairo', flag: '🇪🇬' }, status: 'Active', performance: 'Excellent' },
],
// 2) Sorting controls
setSort(key){
if (this.sortKey === key) {
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
} else {
this.sortKey = key;
this.sortDir = 'asc';
}
},
// 3) Value resolver for different column types
getValue(row, key){
if (key === 'location') return row.location.city;
if (key === 'status') return this.statusRank[row.status] ?? 0;
if (key === 'performance') return this.performanceRank[row.performance] ?? 0;
return row[key];
},
// 4) Sorted rows (locale‑aware + numeric where needed)
sorted(){
const a = [...this.rows];
const k = this.sortKey;
const dir = this.sortDir === 'asc' ? 1 : -1;
return a.sort((x, y) => {
const vx = this.getValue(x, k);
const vy = this.getValue(y, k);
const cmp = (typeof vx === 'number' && typeof vy === 'number')
? (vx - vy)
: this.collator.compare(String(vx), String(vy));
return cmp * dir;
});
},
pageRows(){ return this.sorted(); },
}"
class="mx-auto flow-root max-w-7xl"
>
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div class="flex justify-center">
<table class="min-w-[900px] text-sm bg-white" role="table">
<thead class="border-b bg-zinc-100 border-zinc-200" role="rowgroup">
<tr role="row">
<th
scope="col"
class="px-3 py-2 font-medium text-left text-zinc-700"
:aria-sort="sortKey==='name' ? (sortDir==='asc' ? 'ascending' : 'descending') : 'none'"
>
<button
class="inline-flex items-center gap-1 hover:underline text-zinc-900"
@click="setSort('name')"
>
Name
<template x-if="sortKey==='name'">
<span>
<template x-if="sortDir==='asc'">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-chevron-up size-4"
>
<path
stroke="none"
d="M0 0h24v24H0z"
fill="none"
></path>
<path d="M6 15l6 -6l6 6"></path>
</svg>
</template>
<template x-if="sortDir==='desc'">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-chevron-down size-4"
>
<path
stroke="none"
d="M0 0h24v24H0z"
fill="none"
></path>
<path d="M6 9l6 6l6 -6"></path>
</svg>
</template>
</span>
</template>
</button>
</th>
<th
scope="col"
class="px-3 py-2 font-medium text-left text-zinc-700"
:aria-sort="sortKey==='email' ? (sortDir==='asc' ? 'ascending' : 'descending') : 'none'"
>
<button
class="inline-flex items-center gap-1 hover:underline text-zinc-900"
@click="setSort('email')"
>
Email
<span
x-text="sortKey==='email' ? (sortDir==='asc'?'▲':'▼') : ''"
></span>
</button>
</th>
<th
scope="col"
class="px-3 py-2 font-medium text-left text-zinc-700"
:aria-sort="sortKey==='location' ? (sortDir==='asc' ? 'ascending' : 'descending') : 'none'"
>
<button
class="inline-flex items-center gap-1 hover:underline text-zinc-900"
@click="setSort('location')"
>
Location
<span
x-text="sortKey==='location' ? (sortDir==='asc'?'▲':'▼') : ''"
></span>
</button>
</th>
<th
scope="col"
class="px-3 py-2 font-medium text-left text-zinc-700"
:aria-sort="sortKey==='status' ? (sortDir==='asc' ? 'ascending' : 'descending') : 'none'"
>
<button
class="inline-flex items-center gap-1 hover:underline text-zinc-900"
@click="setSort('status')"
>
Status
<span
x-text="sortKey==='status' ? (sortDir==='asc'?'▲':'▼') : ''"
></span>
</button>
</th>
<th
scope="col"
class="px-3 py-2 font-medium text-left text-zinc-700"
:aria-sort="sortKey==='performance' ? (sortDir==='asc' ? 'ascending' : 'descending') : 'none'"
>
<button
class="inline-flex items-center gap-1 hover:underline text-zinc-900"
@click="setSort('performance')"
>
Performance
<span
x-text="sortKey==='performance' ? (sortDir==='asc'?'▲':'▼') : ''"
></span>
</button>
</th>
</tr>
</thead>
<tbody role="rowgroup">
<template x-for="row in pageRows()" :key="row.email">
<tr class="even:bg-zinc-50" role="row">
<td class="px-3 py-2 text-zinc-700" role="cell">
<div class="flex items-center gap-2">
<img
class="object-cover w-8 h-8 rounded-full"
:src="row.avatar"
:alt="row.name"
/>
<span class="font-medium" x-text="row.name"></span>
</div>
</td>
<td
class="px-3 py-2 text-zinc-500"
role="cell"
x-text="row.email"
></td>
<td class="px-3 py-2 text-zinc-500" role="cell">
<span class="inline-flex items-center gap-1">
<span aria-hidden="true" x-text="row.location.flag"></span>
<span
x-text="row.location.city + ', ' + row.location.country"
></span>
</span>
</td>
<td
class="px-3 py-2"
role="cell"
:class="row.status==='Active' ? 'text-emerald-600' : row.status==='Inactive' ? 'text-red-600' : 'text-yellow-600'"
x-text="row.status"
></td>
<td
class="px-3 py-2 text-zinc-500"
role="cell"
x-text="row.performance"
></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
What you get
- Click‑to‑sort headers with visual indicators
- Avatars + emoji flags per row
- Locale‑aware sorting that handles diacritics
- Accessible markup that announces sort order
Use this pattern for team lists, customer directories, or any sortable, image‑rich table.
/Michael Andreuzza