How to build a data table with sorting and pagination using Alpine.js
Create a fully functional data table with column sorting, pagination controls, and responsive design using Alpine.js and Tailwind CSS
Let’s build a data table with sorting and pagination
Data tables are essential for displaying large datasets in a manageable way. This tutorial shows you how to build a fully functional table with sorting, pagination, and clean styling using Alpine.js and Tailwind CSS. No backend required—everything runs in the browser.
Heads up: This tutorial assumes you have Tailwind CSS and Alpine.js already set up in your project. The table will work anywhere you drop it.
1. Understanding the component structure
Before diving into the code, let’s understand what we’re building:
- Sortable columns: Click column headers to sort ascending or descending
- Pagination: Navigate through data in chunks (5 rows per page)
- Responsive design: Horizontal scroll for smaller screens
- Status indicators: Color-coded status column
- Formatted currency: Proper currency formatting with Intl API
2. Setting up the Alpine.js data structure
The component starts with an x-data
directive containing all our state and logic. Let’s break down each piece:
<div
x-data="{
sortKey:'name',
sortDir:'asc',
page:1,
size:5,
rows:[
{ name:'Alex Thompson', email:'a.tompson@company.com', location:'San Francisco, US', status:'Inactive', balance:1750 },
{ name:'James Wilson', email:'j.wilson@company.com', location:'London, UK', status:'Inactive', balance:650 },
{ name:'Lars Nielsen', email:'l.nielsen@company.com', location:'Stockholm, SE', status:'Active', balance:1000 },
{ name:'Maria Garcia', email:'m.garcia@company.com', location:'Madrid, Spain', status:'Active', balance:0 },
{ name:'Sarah Chen', email:'sarah.c@company.com', location:'Singapore', status:'Active', balance:600 },
{ name:'David Kim', email:'d.kim@company.com', location:'Seoul, KR', status:'Active', balance:1000 },
{ name:'Emma Laurent', email:'e.laurent@company.com', location:'Berlin, DE', status:'Active', balance:1200 },
],
}"
class="mx-auto flow-root max-w-7xl"
>
<!-- Table markup goes here -->
</div>
State properties explained
- sortKey: The current column being sorted (
'name'
,'email'
, or'balance'
) - sortDir: Sort direction, either
'asc'
(ascending) or'desc'
(descending) - page: Current page number, starts at 1
- size: Number of rows per page
- rows: Your data array—each object represents one table row
3. Adding the sorting logic
Next, we need methods to handle sorting. Add these inside your x-data
object:
setSort(k) {
if(this.sortKey === k) {
// Toggle direction if clicking the same column
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
} else {
// New column: reset to ascending
this.sortKey = k;
this.sortDir = 'asc';
}
// Reset to first page when sorting changes
this.page = 1;
}
What it does:
- If you click the same column header twice, it flips between ascending and descending
- If you click a different column, it starts fresh with ascending order
- Always resets to page 1 so you don’t end up on an empty page
4. Implementing the sorted() method
This method returns a sorted copy of your data:
sorted() {
const a = [...this.rows]; // Create a copy to avoid mutating original
const k = this.sortKey;
const dir = this.sortDir === 'asc' ? 1 : -1;
return a.sort((x, y) => (x[k] > y[k] ? 1 : -1) * dir);
}
How it works:
[...this.rows]
creates a shallow copy so we don’t modify the original array- The sort compares values at the current
sortKey
- Multiplying by
dir
flips the order for descending sorts
5. Adding pagination methods
These helper methods handle page calculations:
total() {
return this.sorted().length;
},
pages() {
return Math.max(1, Math.ceil(this.total() / this.size));
},
pageRows() {
const s = (this.page - 1) * this.size;
return this.sorted().slice(s, s + this.size);
}
Breaking it down:
- total(): Total number of rows after filtering/sorting
- pages(): Total number of pages (
Math.max(1, ...)
ensures at least 1 page) - pageRows(): Returns only the rows for the current page using
slice()
6. Building the table markup
Now let’s create the actual table. Start with the wrapper and table element:
<div class="overflow-x-auto">
<div class="flex flex-col justify-center">
<table class="min-w-[900px] text-sm bg-white">
<!-- Table head and body go here -->
</table>
</div>
</div>
The overflow-x-auto
wrapper allows horizontal scrolling on small screens, and min-w-[900px]
ensures the table doesn’t get cramped.
7. Creating sortable column headers
Let’s build the table header with sortable columns:
<thead class="border-b bg-zinc-100 border-zinc-200">
<tr>
<th class="px-3 py-2 font-medium text-left text-zinc-700">
<button
@click="setSort('name')"
class="flex items-center hover:underline gap-1"
>
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 class="px-3 py-2 font-medium text-left text-zinc-700">
<button @click="setSort('email')" class="hover:underline">
Email
<span x-text="sortKey==='email'?(sortDir==='asc'?'▲':'▼'):''"></span>
</button>
</th>
<th class="px-3 py-2 font-medium text-left text-zinc-700">
Location
</th>
<th class="px-3 py-2 font-medium text-left text-zinc-700">
Status
</th>
<th class="px-3 py-2 font-medium text-right text-zinc-700">
<button @click="setSort('balance')" class="hover:underline">
Balance
<span x-text="sortKey==='balance'?(sortDir==='asc'?'▲':'▼'):''"></span>
</button>
</th>
</tr>
</thead>
Key points:
- The Name column uses SVG icons for sort indicators
- Email and Balance columns use text arrows (
▲
/▼
) for simplicity - Location and Status columns aren’t sortable (no button)
@click="setSort('name')"
triggers sorting when clicked
8. Rendering table rows with Alpine
Use x-for
to loop through the paginated data:
<tbody>
<template x-for="r in pageRows()" :key="r.email">
<tr class="even:bg-zinc-50">
<td class="px-3 py-2 font-medium text-zinc-700" x-text="r.name"></td>
<td class="px-3 py-2 text-zinc-500" x-text="r.email"></td>
<td class="px-3 py-2 text-zinc-500" x-text="r.location"></td>
<td
class="px-3 py-2"
:class="r.status==='Active' ? 'text-emerald-600' : r.status==='Inactive' ? 'text-red-600' : 'text-yellow-600'"
x-text="r.status"
></td>
<td
class="px-3 py-2 font-mono font-medium text-right tabular-nums text-zinc-700"
x-text="Intl.NumberFormat('en-US',{style:'currency',currency:'USD'}).format(r.balance)"
></td>
</tr>
</template>
<tr x-show="pageRows().length===0">
<td class="px-3 py-6 text-center text-zinc-500" colspan="5">
No results
</td>
</tr>
</tbody>
What’s happening:
x-for="r in pageRows()"
loops through the current page’s rows:key="r.email"
helps Alpine track rows efficientlyeven:bg-zinc-50
adds zebra striping automatically- Status column uses
:class
to conditionally apply colors - Balance uses
Intl.NumberFormat
for proper currency formatting - Empty state row shows when no results match
9. Building the pagination controls
Add pagination at the bottom of your table:
<div class="flex items-center justify-between p-3 border-t border-zinc-200">
<div class="font-mono text-sm text-zinc-900">
<span x-text="(page-1)*size + 1"></span> –
<span x-text="Math.min(page*size,total())"></span> of
<span x-text="total()"></span>
</div>
<div class="flex items-center gap-1">
<button
class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-8 px-3.5 text-xs"
:disabled="page<=1"
@click="page=1"
>
First
</button>
<button
class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-8 px-3.5 text-xs"
:disabled="page<=1"
@click="page=page-1"
>
Prev
</button>
<button
class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-8 px-3.5 text-xs"
:disabled="page>=pages()"
@click="page=page+1"
>
Next
</button>
<button
class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-8 px-3.5 text-xs"
:disabled="page>=pages()"
@click="page=pages()"
>
Last
</button>
</div>
</div>
Pagination features:
- Shows current range (e.g., “1 – 5 of 7”)
- Four navigation buttons: First, Prev, Next, Last
- Buttons disable when at boundaries (
:disabled
attribute) - Simple click handlers update the
page
variable
10. Complete working example
Here’s the full component assembled:
<div
x-data="{
sortKey:'name',
sortDir:'asc',
page:1,
size:5,
rows:[
{ name:'Alex Thompson', email:'a.tompson@company.com', location:'San Francisco, US', status:'Inactive', balance:1750 },
{ name:'James Wilson', email:'j.wilson@company.com', location:'London, UK', status:'Inactive', balance:650 },
{ name:'Lars Nielsen', email:'l.nielsen@company.com', location:'Stockholm, SE', status:'Active', balance:1000 },
{ name:'Maria Garcia', email:'m.garcia@company.com', location:'Madrid, Spain', status:'Active', balance:0 },
{ name:'Sarah Chen', email:'sarah.c@company.com', location:'Singapore', status:'Active', balance:600 },
{ name:'David Kim', email:'d.kim@company.com', location:'Seoul, KR', status:'Active', balance:1000 },
{ name:'Emma Laurent', email:'e.laurent@company.com', location:'Berlin, DE', status:'Active', balance:1200 },
],
setSort(k){
if(this.sortKey===k) {
this.sortDir=this.sortDir==='asc'?'desc':'asc';
} else {
this.sortKey=k;
this.sortDir='asc';
}
this.page=1;
},
sorted(){
const a=[...this.rows];
const k=this.sortKey;
const dir=this.sortDir==='asc'?1:-1;
return a.sort((x,y)=>(x[k]>y[k]?1:-1)*dir);
},
total(){
return this.sorted().length;
},
pages(){
return Math.max(1, Math.ceil(this.total()/this.size));
},
pageRows(){
const s=(this.page-1)*this.size;
return this.sorted().slice(s,s+this.size);
}
}"
class="mx-auto flow-root max-w-7xl"
>
<div class="overflow-x-auto">
<div class="flex flex-col justify-center">
<table class="min-w-[900px] text-sm bg-white">
<thead class="border-b bg-zinc-100 border-zinc-200">
<tr>
<th class="px-3 py-2 font-medium text-left text-zinc-700">
<button
@click="setSort('name')"
class="flex items-center hover:underline gap-1"
>
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 class="px-3 py-2 font-medium text-left text-zinc-700">
<button @click="setSort('email')" class="hover:underline">
Email
<span x-text="sortKey==='email'?(sortDir==='asc'?'▲':'▼'):''"></span>
</button>
</th>
<th class="px-3 py-2 font-medium text-left text-zinc-700">
Location
</th>
<th class="px-3 py-2 font-medium text-left text-zinc-700">
Status
</th>
<th class="px-3 py-2 font-medium text-right text-zinc-700">
<button @click="setSort('balance')" class="hover:underline">
Balance
<span x-text="sortKey==='balance'?(sortDir==='asc'?'▲':'▼'):''"></span>
</button>
</th>
</tr>
</thead>
<tbody>
<template x-for="r in pageRows()" :key="r.email">
<tr class="even:bg-zinc-50">
<td class="px-3 py-2 font-medium text-zinc-700" x-text="r.name"></td>
<td class="px-3 py-2 text-zinc-500" x-text="r.email"></td>
<td class="px-3 py-2 text-zinc-500" x-text="r.location"></td>
<td
class="px-3 py-2"
:class="r.status==='Active' ? 'text-emerald-600' : r.status==='Inactive' ? 'text-red-600' : 'text-yellow-600'"
x-text="r.status"
></td>
<td
class="px-3 py-2 font-mono font-medium text-right tabular-nums text-zinc-700"
x-text="Intl.NumberFormat('en-US',{style:'currency',currency:'USD'}).format(r.balance)"
></td>
</tr>
</template>
<tr x-show="pageRows().length===0">
<td class="px-3 py-6 text-center text-zinc-500" colspan="5">
No results
</td>
</tr>
</tbody>
</table>
<div class="flex items-center justify-between p-3 border-t border-zinc-200">
<div class="font-mono text-sm text-zinc-900">
<span x-text="(page-1)*size + 1"></span> –
<span x-text="Math.min(page*size,total())"></span> of
<span x-text="total()"></span>
</div>
<div class="flex items-center gap-1">
<button
class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-8 px-3.5 text-xs"
:disabled="page<=1"
@click="page=1"
>
First
</button>
<button
class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-8 px-3.5 text-xs"
:disabled="page<=1"
@click="page=page-1"
>
Prev
</button>
<button
class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-8 px-3.5 text-xs"
:disabled="page>=pages()"
@click="page=page+1"
>
Next
</button>
<button
class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-8 px-3.5 text-xs"
:disabled="page>=pages()"
@click="page=pages()"
>
Last
</button>
</div>
</div>
</div>
</div>
</div>
11. Customization ideas
Now that you have a working table, here are some ways to extend it:
Add search/filter functionality
query: '',
filtered() {
const q = this.query.toLowerCase();
return this.rows.filter(r =>
r.name.toLowerCase().includes(q) ||
r.email.toLowerCase().includes(q)
);
},
// Update sorted() to use filtered() instead of this.rows
sorted() {
const a = [...this.filtered()];
// rest of the code...
}
Add a search input
<div class="p-3 border-b border-zinc-200">
<input
type="text"
x-model="query"
@input="page=1"
placeholder="Search by name or email..."
class="w-full px-3 py-2 border rounded-md border-zinc-200 focus:outline-none focus:ring-2 focus:ring-zinc-500"
/>
</div>
Make rows per page configurable
size: 10, // Change default
// Add a select dropdown
<select x-model.number="size" @change="page=1" class="px-2 py-1 border rounded border-zinc-200">
<option value="5">5 per page</option>
<option value="10">10 per page</option>
<option value="25">25 per page</option>
<option value="50">50 per page</option>
</select>
Add row selection
selected: new Set(),
toggleRow(id) {
if(this.selected.has(id)) {
this.selected.delete(id);
} else {
this.selected.add(id);
}
}
12. Performance considerations
For larger datasets (1000+ rows), consider these optimizations:
- Virtual scrolling: Only render visible rows
- Server-side pagination: Fetch data page by page from an API
- Debounced search: Wait for typing to stop before filtering
- Memoization: Cache sorted results if data doesn’t change often
Wrapping up
You now have a production-ready data table with sorting and pagination, built entirely with Alpine.js and Tailwind CSS. The component is:
- Lightweight: No heavy dependencies or frameworks
- Accessible: Semantic HTML with proper button elements
- Responsive: Works on all screen sizes with horizontal scroll
- Extensible: Easy to add filters, search, and more features
The key concepts you learned:
- Using Alpine.js
x-data
for component state - Creating helper methods for sorting and pagination
- Conditionally rendering with
x-if
andx-show
- Looping with
x-for
to render dynamic rows - Binding classes and attributes with
:class
and:disabled
Copy the code, swap in your own data, and customize the styles to match your design system!
/Michael Andreuzza