lexington®
Use code LEX35 at checkout
7k+ customers.
How to build a selectable table with checkboxes using Alpine.js and Tailwind CSS
Build a table with row selection, a select-all checkbox, and live counts using Alpine.js and Tailwind CSS. Includes indeterminate state for partial selection.
Published on October 31, 2025 by Michael AndreuzzaLet’s build a selectable table
Need a table where users can select rows and quickly select/unselect everything? Here’s a clean Alpine.js + Tailwind CSS pattern with a master checkbox, per-row checkboxes, and a live “N selected” counter.
Heads up: Tailwind CSS and Alpine.js should already be set up in your project. Drop the snippets into any Astro/HTML page and they’ll work.
1. State and data
Use a Set to track selected row IDs. A Set makes toggling and membership checks fast and simple.
<div
x-data="{
rows: [ /* your rows here; see full demo */ ],
selected: new Set(),
toggleAll(e){ this.selected = new Set(e.target.checked ? this.rows.map(r=>r.id) : []); },
toggle(id){ this.selected.has(id) ? this.selected.delete(id) : this.selected.add(id); },
allSelected(){ return this.rows.length > 0 && this.selected.size === this.rows.length; },
}"
>
<!-- markup below -->
</div>
selectedholds only IDs, so rendering stays fastallSelected()returns true when every row is selected
Derived helpers (optional)
Add a couple of convenience helpers to simplify UI logic:
someSelected(){ return this.selected.size > 0 && !this.allSelected(); },
clear(){ this.selected = new Set(); },
invert(){ this.selected = new Set(this.rows.map(r => r.id).filter(id => !this.selected.has(id))); },
2. Master checkbox (select all)
The master checkbox mirrors selection and supports an indeterminate state when some—but not all—rows are selected.
<input
type="checkbox"
x-ref="master"
:checked="allSelected()"
:aria-checked="selected.size>0 && selected.size<rows.length ? 'mixed' : (allSelected() ? 'true' : 'false')"
@change="toggleAll($event)"
x-effect="$refs.master && ($refs.master.indeterminate = selected.size>0 && selected.size<rows.length)"
class="bg-white rounded size-4 border-zinc-300 text-zinc-700 focus:ring-zinc-300 checked:bg-white checked:border-zinc-200 checked:text-zinc-900"
/>
x-ref="master"+x-effectsets the nativeindeterminatepropertyaria-checked="mixed"communicates partial selection to assistive tech
How indeterminate actually works
indeterminateis a DOM property (not an attribute). You must set it via JS each time selection changes.- Keep the master checkbox’s
checkedin sync withallSelected(), and separately toggleindeterminatewhensomeSelected()is true.
3. Row checkboxes + highlighting
Each row’s checkbox toggles its ID in selected. Optionally highlight selected rows.
<template x-for="r in rows" :key="r.id">
<tr class="even:bg-zinc-50" :class="selected.has(r.id) ? 'bg-zinc-50 ' : ''">
<td class="px-3 py-2">
<input
type="checkbox"
:checked="selected.has(r.id)"
@change="toggle(r.id)"
:aria-checked="selected.has(r.id) ? 'true' : 'false'"
class="bg-white rounded size-4 border-zinc-300 text-zinc-700 focus:ring-zinc-300 checked:bg-white checked:border-zinc-200 checked:text-zinc-900"
/>
</td>
<!-- other cells -->
</tr>
</template>
- Keep padding consistent (
px-3 py-2) for alignment - Use
tabular-numsfor numeric columns
Optional: Row-level toggle
Let users click anywhere on the row to toggle selection while preserving interactive elements:
<tr @click="toggle(r.id)" @keydown.enter.prevent="toggle(r.id)" tabindex="0"
class="even:bg-zinc-50 cursor-pointer focus:outline-none focus:ring-2 focus:ring-zinc-300"
:class="selected.has(r.id) ? 'bg-zinc-50 ' : ''">
<!-- Stop propagation on inner controls so they don’t double-toggle -->
<td class="px-3 py-2" @click.stop>
<input type="checkbox" :checked="selected.has(r.id)" @change="toggle(r.id)" />
</td>
<!-- other cells -->
</tr>
4. Selected count and bulk actions
Show a live count and disable bulk action buttons when nothing is selected.
<div class="flex items-center justify-between mb-3">
<div class="text-sm text-zinc-600" x-text="selected.size ? selected.size + ' selected' : '0 selected'"></div>
<div class="flex items-center gap-2">
<button
class="rounded-md h-8 px-3.5 text-xs bg-zinc-900 text-white disabled:opacity-40"
:disabled="selected.size===0"
>
Bulk action
</button>
</div>
</div>
5. Accessibility notes
- Master checkbox uses
aria-checked="mixed"in partial-selection states - Use
scope="col"on header cells and consider a<caption>describing the dataset - Ensure sufficient color contrast for the selected row highlight
Labels and hit areas
- Wrap checkboxes in
<label>or add visually hidden text to describe the action (e.g., “Select row Invoice Q3.pdf”). - Keep focus styles visible (
focus:ring-*) so keyboard users can track position.
Common pitfalls
- Indeterminate not showing? The
indeterminateproperty is not an attribute; set it via JS (x-effect) on the element - Duplicated IDs: selection relies on unique, stable
idvalues—don’t reuse them - Filtering or pagination: keep
selectedin sync when the visible set of rows changes
Performance and reactivity tips
- Using
Setis fast for lookups. If mutations don’t appear reactive in your setup, reassign to a new Set after toggling:this.selected = new Set(this.selected). - For very large tables, consider windowing/virtualization; selection logic stays the same since it’s ID-based.
Quick debug checklist
- Master checkbox shows the wrong state? Re-check
allSelected()andindeterminatelogic. - Events firing twice? Stop propagation on inner controls if the row itself is clickable.
- Selection lost after filtering/paging? Persist
selectedacross views and reconcile with the currentrowslist.
Advanced: Shift‑click range selection
Add range selection when users hold Shift. Track the last clicked index and select a span:
lastIndex: null,
indexById(){ const map = new Map(); this.rows.forEach((r,i)=>map.set(r.id,i)); return map; },
toggleRange(id, event){
const map = this.indexById();
const idx = map.get(id);
if (event.shiftKey && this.lastIndex != null) {
const [a,b] = [this.lastIndex, idx].sort((x,y)=>x-y);
for (let i=a;i<=b;i++){ this.selected.add(this.rows[i].id); }
this.selected = new Set(this.selected);
} else {
this.toggle(id);
}
this.lastIndex = idx;
}
Use it on the row checkbox: @change="toggleRange(r.id, $event)" (and on the row click handler if you enable row-level toggling).
Bonus: Complete working demo
Copy, paste, and customize:
<!-- Monochrome Table • Selectable rows + Bulk actions -->
<div
x-data="{
rows: [
{ id: 1, name: 'Invoice Q3.pdf', owner: 'alice@example.com', size: '144 KB' },
{ id: 2, name: 'Brand Guidelines.pptx', owner: 'bob@example.com', size: '180 KB' },
{ id: 3, name: 'UX Research.xlsx', owner: 'carol@example.com', size: '192 KB' },
{ id: 4, name: 'Wireframes.sketch', owner: 'dave@example.com', size: '204 KB' },
{ id: 5, name: 'Logo.svg', owner: 'eve@example.com', size: '156 KB' },
{ id: 6, name: 'Roadmap.pdf', owner: 'alice@example.com', size: '168 KB' },
{ id: 7, name: 'Sprint Plan.docx', owner: 'bob@example.com', size: '216 KB' },
{ id: 8, name: 'Meeting Notes.txt', owner: 'carol@example.com', size: '132 KB' },
{ id: 9, name: 'Budget.xlsx', owner: 'dave@example.com', size: '228 KB' },
{ id: 10, name: 'Release Checklist.md', owner: 'eve@example.com', size: '156 KB' },
{ id: 11, name: 'Design Tokens.json', owner: 'alice@example.com', size: '168 KB' },
{ id: 12, name: 'User Feedback.csv', owner: 'bob@example.com', size: '180 KB' },
],
selected: new Set(),
toggleAll(e){ this.selected = new Set(e.target.checked ? this.rows.map(r=>r.id) : []); },
toggle(id){ this.selected.has(id) ? this.selected.delete(id) : this.selected.add(id); },
allSelected(){ return this.rows.length > 0 && this.selected.size === this.rows.length; },
}"
class="mx-auto flow-root max-w-7xl"
>
<div class="flex items-center justify-between mb-3">
<div
class="text-sm text-zinc-600"
x-text="selected.size ? selected.size + ' selected' : '0 selected'"
></div>
</div>
<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">
<thead class="bg-zinc-100">
<tr>
<th class="w-10 px-3 py-2 text-left">
<input
type="checkbox"
x-ref="master"
:checked="allSelected()"
:aria-checked="selected.size>0 && selected.size<rows.length ? 'mixed' : (allSelected() ? 'true' : 'false')"
@change="toggleAll($event)"
x-effect="$refs.master && ($refs.master.indeterminate = selected.size>0 && selected.size<rows.length)"
class="bg-white rounded size-4 border-zinc-300 text-zinc-700 focus:ring-zinc-300 checked:bg-white checked:border-zinc-200 checked:text-zinc-900"
/>
</th>
<th class="px-3 py-2 font-medium text-left text-zinc-700">
Name
</th>
<th class="px-3 py-2 font-medium text-left text-zinc-700">
Owner
</th>
<th class="px-3 py-2 font-medium text-right text-zinc-700">
Size
</th>
</tr>
</thead>
<tbody>
<template x-for="r in rows" :key="r.id">
<tr
class="even:bg-zinc-50"
:class="selected.has(r.id) ? 'bg-zinc-50 ' : ''"
>
<td class="px-3 py-2">
<input
type="checkbox"
:checked="selected.has(r.id)"
@change="toggle(r.id)"
:aria-checked="selected.has(r.id) ? 'true' : 'false'"
class="bg-white rounded size-4 border-zinc-300 text-zinc-700 focus:ring-zinc-300 checked:bg-white checked:border-zinc-200 checked:text-zinc-900"
/>
</td>
<td class="px-3 py-2 text-zinc-700" x-text="r.name"></td>
<td class="px-3 py-2 text-zinc-500" x-text="r.owner"></td>
<td
class="px-3 py-2 text-right tabular-nums text-zinc-700"
x-text="r.size"
></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div> /Michael Andreuzza