Summit Themes
Blog

How to build a selectable table with checkboxes using Alpine.js and Tailwind CSS

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 lastClicked ID, and when the next click arrives with $event.shiftKey, select all rows between the two indices.
  • Persistent selection across pages: keep the Set alive 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 sortKey and sortDir property to state, then replace rows with a get sortedRows() computed getter that returns a sorted copy. Use x-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.