webdevpuneet.com

Selectable Table — Row Checkbox Selection HTML CSS JS

Selectable Table · Tables · Plain HTML, CSS & JS · Live preview

CategoryTables

Copy & paste

Source Code

<div class="st-wrap">
  <div class="st-bar">
    <span class="st-count" id="stCount">No rows selected</span>
    <button type="button" class="st-act" id="stAct" hidden>Delete selected</button>
  </div>
  <table class="st-table">
    <thead><tr>
      <th class="st-cb"><input type="checkbox" id="stAll" aria-label="Select all"></th>
      <th>Name</th><th>Role</th><th class="st-num">Score</th>
    </tr></thead>
    <tbody id="stBody"></tbody>
  </table>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Tri-state select-all
The header checkbox is checked, indeterminate, or empty to reflect full/partial/none.
Shift-click ranges
Selects the contiguous block between the last click and the current row.
Whole-row click target
Clicking anywhere on a row toggles it, not just the small checkbox.
Set as source of truth
All visual state derives from one selection Set, so nothing drifts.
Live selection count
The toolbar shows how many rows are selected, pluralised.
Contextual action bar
The bulk action appears only when something is selected.
Stable row ids
Rows carry ids so the bulk action maps back to real records.
Data-driven & no library
Renders from a ROWS array in plain HTML/CSS/JS — zero dependencies.

About this UI Snippet

Selectable Table — Tri-State Select-All, Shift-Click Ranges, and a Bulk Action Bar

Screenshot of the Selectable Table snippet rendered live

Letting users select rows and act on them in bulk is core to any admin table, and the selection mechanics — a header checkbox that reflects partial selection, shift-click to grab a range, clicking anywhere on a row — are easy to get subtly wrong. This snippet builds a fully correct selectable table in plain HTML, CSS, and vanilla JavaScript, with a tri-state select-all, range selection, a live count, and a bulk action bar — no library.

A tri-state select-all header

The header checkbox has three states, which most implementations miss: checked when every row is selected, *indeterminate* (the dash) when some but not all are, and empty when none are. The snippet sets allCb.indeterminate based on whether the selected count is between zero and the total — the standard way to show partial selection. Without the indeterminate state, the header checkbox lies about what is selected; getting it right is what makes the control trustworthy.

Shift-click range selection

Holding Shift and clicking selects every row between your last click and the current one — the spreadsheet and file-manager convention for grabbing a contiguous block. The snippet remembers the lastIndex clicked and, on a shift-click, adds the whole range between it and the new row to the selection Set. This range gesture is what makes selecting many rows fast; clicking each checkbox individually is tedious for more than a few.

Whole-row click target

Clicking anywhere on a row toggles its selection, not just the checkbox — a much larger, more forgiving hit target. The checkbox stays in sync because all visual state (the checkbox, the row highlight) is derived in one refresh() from the selection Set, the single source of truth. Driving the DOM from the Set rather than reading checkbox states avoids the drift that creeps in when selection lives in the DOM.

Live count and contextual action bar

A toolbar shows the live selection count ("3 rows selected") and reveals a bulk action button only when something is selected — the contextual pattern where actions appear when they become relevant. The action reports the selected row ids (wire it to your real delete, export, or assign), then clears the selection.

Data-driven and drop-in

Rows come from a ROWS array keyed by stable ids, so the selection survives by index and you can map back to real records for the bulk action. It is a clear, dependency-free reference for correct table selection: tri-state header, range selection, Set-as-source-of-truth, and a contextual action bar. One thing to change before shipping against a paginated or sorted table: this demo's selection Set stores row *indices*, which is fine for a static array but breaks the moment rows are re-sorted or a different page is loaded, since index 2 no longer points at the same record — for anything beyond a single static page, store the selected *ids* in the Set instead and look up rows by id when rendering.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSA table renders with row checkboxes and a select-all header.
  2. 2
    Select rowsClick a row (anywhere) or its checkbox to toggle it; the count and highlight update.
  3. 3
    Shift-click a rangeClick one row, then Shift-click another to select everything between them.
  4. 4
    Use select-allThe header checkbox selects all, clears all, and shows a dash when only some are selected.
  5. 5
    Run a bulk actionThe action button appears when rows are selected; wire it to your real delete/export.
  6. 6
    Swap in your dataReplace the ROWS array (keyed by id) with your own records.

Real-world uses

Common Use Cases

Admin bulk management
Select and act on many users or items, with a header checkbox that is checked, indeterminate or empty depending on the selection.
Email multi-select
Select messages with shift-click ranges, as in an inbox, with one selection Set driving all visual state.
File and asset managers
Range-select files for move or delete in a file manager UI, using whole-row click targets that are easy to hit.
Moderation queues
Approve or remove many items at once, revealing a bulk actions bar with a live count once anything is selected.
Export and assignment flows
Pick rows then export or assign, combining with a filterable table and a plain data table.
Related: Conditional Formatting Table
See the Conditional Formatting Table for a related tables pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

The header select-all checkbox has three states: checked (all rows selected), unchecked (none), and indeterminate — a dash — when some but not all are selected. Setting allCb.indeterminate = true when the selected count is between 0 and the total is the standard, accessible way to signal a partial selection. Without it, the header checkbox would misleadingly appear empty or checked when only some rows are selected.

The table remembers the index of the last row you clicked (lastIndex). When you Shift-click another row, it computes the low and high of that index and the new one and adds every row in between to the selection Set. This mirrors the spreadsheet/file-manager convention and makes selecting a large contiguous block one gesture instead of many clicks.

Keeping the selection in a Set as the single source of truth, and deriving the checkboxes and row highlights from it in refresh(), prevents the state from drifting. If you read selection straight from the DOM checkboxes, re-renders, sorting, or filtering can desync them. The Set holds the truth; the DOM is just a view of it, so it is always consistent.

Rows carry stable ids, and the selection Set stores indices, so the action maps them back: [...selected].map(i => ROWS[i].id) gives the selected ids. In the action handler, send those ids to your delete/export/assign API, then clear the Set and refresh(). The demo logs the ids and clears — replace that with your real call.

Hold the selection as a Set (or array) of ids in state and derive each row's checked/highlight from it. In React use useState and an onClick that handles shiftKey; in Vue a ref with @click; in Angular a property with (click). Compute the header's checked/indeterminate from the selection size. The selection logic is framework-agnostic — only state and event binding move into the framework.