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 fast
- allSelected()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 native- indeterminateproperty
- aria-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