Selectable tables show up in almost every admin UI — email clients, order dashboards, CMS list views. The pattern is deceptively tricky: you need a "select all" checkbox that reflects partial selection with an indeterminate visual state, per-row checkboxes that stay in sync, and a live count of what's selected. Reach for a full JavaScript framework and you'll spend more time wiring state than building the feature.
Alpine.js handles this pattern cleanly in a single x-data block. No build step, no component lifecycle to understand — just reactive state living next to your HTML. Tailwind CSS v4 supplies the visual layer. By the end of this guide you'll have a working, copy-pasteable component.
What we're building
A table of rows (say, a list of customer orders) where:
- Each row has a checkbox you can tick individually.
- A header checkbox selects or deselects every row at once.
- When only some rows are checked, the header checkbox shows as indeterminate (a dash, not a tick).
- A status bar shows "N of M selected" and a bulk-action button appears when anything is checked.
Prerequisites
You need Alpine.js loaded on the page and a project set up with Tailwind CSS v4. If you're starting from scratch, add both via CDN for quick prototyping:
<!-- Tailwind CSS v4 (CDN play build) -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Alpine.js v3 -->
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
For a real project using Tailwind v4's CSS-first approach, your entry CSS file looks like this instead of a tailwind.config.js:
/* app.css */
@import "tailwindcss";
@theme {
--color-brand: oklch(55% 0.2 250);
}
Modelling the state
Alpine keeps all reactive state in an x-data object. The key insight for a selectable table is to store selected row IDs in a JavaScript Set — membership checks and toggle operations are O(1), and you never have to filter an array to know whether a row is checked.
<div x-data="{
rows: [
{ id: 1, customer: 'Marta Reyes', order: '#1042', status: 'Shipped', total: '$84.00' },
{ id: 2, customer: 'James Okonkwo', order: '#1043', status: 'Processing', total: '$210.50' },
{ id: 3, customer: 'Sun Li', order: '#1044', status: 'Shipped', total: '$37.20' },
{ id: 4, customer: 'Anika Patel', order: '#1045', status: 'Pending', total: '$129.00' },
{ id: 5, customer: 'Carlos Ruiz', order: '#1046', status: 'Cancelled', total: '$55.75' }
],
selected: new Set(),
get allSelected() { return this.selected.size === this.rows.length; },
get noneSelected() { return this.selected.size === 0; },
get someSelected() { return !this.allSelected && !this.noneSelected; },
toggleAll() {
if (this.allSelected) {
this.selected = new Set();
} else {
this.selected = new Set(this.rows.map(r => r.id));
}
},
toggleRow(id) {
const next = new Set(this.selected);
next.has(id) ? next.delete(id) : next.add(id);
this.selected = next;
},
isSelected(id) { return this.selected.has(id); }
}">
A few things to note here. The get accessors (allSelected, noneSelected, someSelected) are computed properties — Alpine evaluates them reactively whenever selected changes. Replacing the Set with a new instance (this.selected = new Set(this.selected) or similar) is necessary to trigger Alpine's reactivity, because Alpine tracks object identity, not internal mutations.
The select-all checkbox with indeterminate state
The indeterminate property on a checkbox is a DOM property, not an HTML attribute, so you can't set it with plain HTML. Alpine's x-bind lets you set DOM properties directly:
<th class="px-4 py-3">
<input
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-blue-600
focus:ring-2 focus:ring-blue-500 focus:ring-offset-0
cursor-pointer"
:checked="allSelected"
:indeterminate="someSelected"
@change="toggleAll()"
/>
</th>
Using :checked and :indeterminate (shorthand for x-bind:checked and x-bind:indeterminate) keeps the checkbox in sync with your state without needing x-model, which works better here because x-model doesn't handle the indeterminate property.
The full table markup
<div class="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
<!-- Bulk action bar -->
<div
x-show="!noneSelected"
x-transition
class="flex items-center gap-3 border-b border-gray-200 bg-blue-50 px-4 py-2 text-sm"
>
<span class="text-blue-700 font-medium">
<span x-text="selected.size"></span>
of
<span x-text="rows.length"></span>
selected
</span>
<button
class="ml-auto rounded-md bg-red-600 px-3 py-1 text-white hover:bg-red-700
text-xs font-medium transition-colors"
>
Delete selected
</button>
</div>
<table class="w-full text-sm text-left text-gray-700">
<thead class="border-b border-gray-200 bg-gray-50 text-xs uppercase text-gray-500">
<tr>
<th class="px-4 py-3 w-10">
<input
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-blue-600
focus:ring-2 focus:ring-blue-500 focus:ring-offset-0 cursor-pointer"
:checked="allSelected"
:indeterminate="someSelected"
@change="toggleAll()"
/>
</th>
<th class="px-4 py-3">Customer</th>
<th class="px-4 py-3">Order</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3 text-right">Total</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-for="row in rows" :key="row.id">
<tr
:class="isSelected(row.id) ? 'bg-blue-50' : 'hover:bg-gray-50'"
class="transition-colors cursor-pointer"
@click="toggleRow(row.id)"
>
<td class="px-4 py-3" @click.stop>
<input
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-blue-600
focus:ring-2 focus:ring-blue-500 focus:ring-offset-0 cursor-pointer"
:checked="isSelected(row.id)"
@change="toggleRow(row.id)"
/>
</td>
<td class="px-4 py-3 font-medium text-gray-900" x-text="row.customer"></td>
<td class="px-4 py-3 text-gray-500" x-text="row.order"></td>
<td class="px-4 py-3">
<span
:class="{
'bg-green-100 text-green-800': row.status === 'Shipped',
'bg-yellow-100 text-yellow-800': row.status === 'Processing',
'bg-gray-100 text-gray-600': row.status === 'Pending',
'bg-red-100 text-red-700': row.status === 'Cancelled'
}"
class="inline-block rounded-full px-2 py-0.5 text-xs font-medium"
x-text="row.status"
></span>
</td>
<td class="px-4 py-3 text-right" x-text="row.total"></td>
</tr>
</template>
</tbody>
</table>
</div>
Walking through the key decisions
Clicking a row vs clicking its checkbox
The @click="toggleRow(row.id)" on the <tr> makes the whole row clickable, which is a nicer UX. But if the click lands on the checkbox cell itself, the event would fire twice — once for the checkbox's own @change, and once bubbling up to the row. The @click.stop on the checkbox <td> stops propagation so only the checkbox handler runs.
Why replace the Set rather than mutate it
Alpine v3 uses a Proxy to observe property assignments on the data object. When you do this.selected.add(id), the proxy doesn't see a new assignment — the property reference didn't change. Assigning a new Set() does trigger an update. This is the same reason you replace arrays rather than calling .push() directly when you want Alpine to react.
The indeterminate state is display-only
Setting indeterminate: true on a checkbox changes its visual appearance (a dash instead of a tick) but does not affect its checked value or form submission. It's purely a UI hint. That's why you always need to drive it from real state — in our case, someSelected — rather than treating it as a third checkbox state.
Keyboard and accessibility
Because the entire <tr> fires toggleRow on click, keyboard users navigating with Tab will land on the checkbox (which is naturally focusable), not the row itself. This is fine — they can Space to toggle the checkbox and the state updates correctly. If you want the row itself to be keyboard-operable, add tabindex="0" and a @keydown.space.prevent="toggleRow(row.id)".
Wiring up real data
The rows array in x-data is a plain JavaScript value. In a real application you'd typically fetch it from an API and assign it once the response arrives. In Alpine that looks like adding an init() method to your data object:
x-data="{
rows: [],
selected: new Set(),
// ... getters and methods ...
async init() {
const res = await fetch('/api/orders');
this.rows = await res.json();
}
}"
Alpine automatically calls init() when the component mounts. The table will render empty until the fetch resolves, then reactively populate. You can add a loading state (loading: true, set to false after the fetch) and use x-show="loading" on a skeleton row if the latency is noticeable.
Extending the pattern
Once you have the core working, a few extensions are straightforward:
- Shift-click range selection: track a
lastClickedID, and when the next click arrives with$event.shiftKey, select all rows between the two indices. - Persistent selection across pages: keep the
Setalive in a parent component that wraps both the table and the pagination controls, so navigating pages doesn't clear the selection. - Column sorting: add a
sortKeyandsortDirproperty to state, then replacerowswith aget sortedRows()computed getter that returns a sorted copy. Usex-for="row in sortedRows"in the template.
Putting it all together
The complete component is about 80 lines of HTML — no build pipeline, no npm dependencies beyond Alpine and Tailwind. The state model is plain JavaScript objects and a Set, which means it's easy to debug (just log this.selected) and easy to adapt. If your requirements grow past what Alpine handles comfortably — complex server-state sync, optimistic updates, pagination with persisted selection — that's the signal to reach for a dedicated reactive framework. But for most local-business dashboards and admin tables, this pattern covers everything you need.