Shift-Click Range Select in a Table — Gmail/Sheets-Style Multi-Row Selection
Shift-Click Range Select in a Table (Gmail/Sheets-Style) · Tables · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="demo">
<div class="table-toolbar">
<span class="sel-count" id="selCount">0 selected</span>
<span class="sel-hint">Click a row to select it, Shift-click another to select the range between them, Cmd/Ctrl-click to toggle one row</span>
</div>
<table class="sel-table">
<thead>
<tr><th class="th-check"></th><th>Name</th><th>Status</th><th>Owner</th></tr>
</thead>
<tbody id="tbody">
<tr data-row><td class="td-check"><input type="checkbox" tabindex="-1" /></td><td>Landing page redesign</td><td><span class="status live">Live</span></td><td>Mia Chen</td></tr>
<tr data-row><td class="td-check"><input type="checkbox" tabindex="-1" /></td><td>Checkout flow v2</td><td><span class="status draft">Draft</span></td><td>Sam Okoye</td></tr>
<tr data-row><td class="td-check"><input type="checkbox" tabindex="-1" /></td><td>Pricing page copy</td><td><span class="status review">Review</span></td><td>Jules Park</td></tr>
<tr data-row><td class="td-check"><input type="checkbox" tabindex="-1" /></td><td>Onboarding emails</td><td><span class="status live">Live</span></td><td>Mia Chen</td></tr>
<tr data-row><td class="td-check"><input type="checkbox" tabindex="-1" /></td><td>Mobile nav revamp</td><td><span class="status draft">Draft</span></td><td>Ravi Singh</td></tr>
<tr data-row><td class="td-check"><input type="checkbox" tabindex="-1" /></td><td>API docs refresh</td><td><span class="status review">Review</span></td><td>Sam Okoye</td></tr>
<tr data-row><td class="td-check"><input type="checkbox" tabindex="-1" /></td><td>Dark mode support</td><td><span class="status live">Live</span></td><td>Jules Park</td></tr>
</tbody>
</table>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f8fafc; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; }
.demo { width: 560px; max-width: 100%; display: flex; flex-direction: column; gap: 10px; }
.table-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.sel-count { font-size: 12.5px; font-weight: 700; color: #4338ca; background: #eef2ff; padding: 4px 10px; border-radius: 999px; }
.sel-hint { font-size: 11px; color: #94a3b8; }
.sel-table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; user-select: none; }
.sel-table th { text-align: left; font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.4px; padding: 10px 12px; border-bottom: 1px solid #e2e8f0; background: #f8fafc; }
.sel-table td { padding: 10px 12px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9; }
.sel-table tr:last-child td { border-bottom: none; }
.th-check, .td-check { width: 34px; }
.td-check input { pointer-events: none; accent-color: #6366f1; }
tr[data-row] { cursor: pointer; transition: background 0.1s; }
tr[data-row]:hover { background: #f8fafc; }
tr[data-row].selected { background: #eef2ff; }
tr[data-row].anchor { outline: 2px solid #6366f1; outline-offset: -2px; }
.status { font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 999px; }
.status.live { background: #ecfdf5; color: #047857; }
.status.draft { background: #f1f5f9; color: #64748b; }
.status.review { background: #fffbeb; color: #b45309; }const tbody = document.getElementById('tbody');
const selCount = document.getElementById('selCount');
const rows = Array.from(tbody.querySelectorAll('tr[data-row]'));
let anchorIndex = null; // the last row clicked without a modifier — the fixed end of a shift-click range
const selected = new Set();
function updateRowVisuals() {
rows.forEach((row, i) => {
row.classList.toggle('selected', selected.has(i));
row.querySelector('input[type="checkbox"]').checked = selected.has(i);
row.classList.toggle('anchor', i === anchorIndex);
});
selCount.textContent = `${selected.size} selected`;
}
function selectRange(from, to) {
const [start, end] = from <= to ? [from, to] : [to, from];
selected.clear();
for (let i = start; i <= end; i++) selected.add(i);
}
rows.forEach((row, index) => {
row.addEventListener('click', (e) => {
const isToggleModifier = e.metaKey || e.ctrlKey;
const isRangeModifier = e.shiftKey;
if (isRangeModifier && anchorIndex !== null) {
// Range select: fill every row between the anchor and this row,
// replacing whatever selection existed before — matches Sheets/Gmail
// behavior where shift-click always defines a fresh contiguous range
// from the anchor, not an additive union with prior clicks.
selectRange(anchorIndex, index);
} else if (isToggleModifier) {
// Toggle select: add or remove just this one row, leaving everything
// else untouched, and move the anchor here so a *subsequent* shift-click
// ranges from this newly toggled row rather than the old anchor.
if (selected.has(index)) selected.delete(index);
else selected.add(index);
anchorIndex = index;
} else {
// Plain click: a fresh single-row selection, and this row becomes the
// new anchor for any future shift-click range.
selected.clear();
selected.add(index);
anchorIndex = index;
}
updateRowVisuals();
});
});
updateRowVisuals();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.
What's included
Features
About this UI Snippet
Shift-Click Range Selection — The Anchor-Based Pattern Behind Sheets and Gmail

Most custom-built admin tables only support one selection mode: click a checkbox, one row at a time. Power users expect the selection behavior every spreadsheet and email client has trained them on for decades — plain click selects one row, Shift-click selects everything between that row and the last one clicked, and Cmd/Ctrl-click toggles a single row without disturbing the rest of the selection. This snippet implements all three modes correctly, built around the one concept that makes range selection possible: a persistent anchor.
What the anchor actually is
anchorIndex tracks the index of the last row clicked *without* the Shift modifier — not simply "the last row clicked" in general. This distinction is what makes range selection predictable: Shift-clicking always measures a range from that fixed anchor point, regardless of how many Shift-clicks happen afterward. Click row 2, Shift-click row 6 (selects 2–6), then Shift-click row 4 — the range recalculates as 2–4, not "extend from 6 to 4," because the anchor never moved during any of the Shift-click steps. This exactly matches how Sheets and Gmail behave, and is the detail most hand-rolled implementations get wrong by treating "last clicked row" and "anchor" as the same thing.
Why Shift-click replaces the selection instead of adding to it
selectRange() calls selected.clear() before filling in the new range — a fresh Shift-click always defines a brand-new contiguous selection from the anchor, discarding whatever was selected before. This mirrors real spreadsheet behavior: Shift-click is for *defining a range*, not *appending to an existing arbitrary selection* (that's what Cmd/Ctrl-click is for). Conflating the two would make it impossible to ever shrink a range back down after over-shooting it with a previous Shift-click.
Why Cmd/Ctrl-click moves the anchor, but plain click also moves it
Both the toggle path and the plain-click path update anchorIndex to the row that was just clicked — but Shift-click deliberately does not. This means a user can Cmd-click three unrelated rows, and if they then Shift-click a fourth, the range is measured from the *last Cmd-clicked row* (since that click updated the anchor), letting range and toggle selection compose naturally in the same interaction sequence, exactly as it does in real file managers and spreadsheets.
Checkboxes reflect state, they don't drive it
Every row's checkbox has tabindex="-1" and pointer-events: none in the CSS — they're rendered purely as a *visual reflection* of selected, not an independent interactive control a user could click directly. All selection logic lives in the row's own click handler, so there's exactly one source of truth for whether a row is selected, and the checkbox can never fall out of sync with the row's actual selection state.
Step by step
How to Use
- 1Click a rowSelects just that row and sets it as the anchor for any future Shift-click range.
- 2Shift-click a different rowSelects every row between the anchor and the clicked row, replacing the previous selection with this new contiguous range.
- 3Cmd/Ctrl-click a rowToggles just that one row on or off without disturbing the rest of the selection, and moves the anchor to it.
- 4Combine the twoCmd-click a few individual rows, then Shift-click another — the range is measured from the last Cmd-clicked row, letting the two modes compose naturally.
- 5Wire up bulk actionsRead the selected Set (or the checked checkboxes) to drive a bulk-action toolbar — the selection state is already fully tracked and ready to use.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
The anchor is the last row clicked without holding Shift — it updates on a plain click or a Cmd/Ctrl-click, but not on a Shift-click. This is what lets repeated Shift-clicks recalculate a range from a stable, fixed point rather than accidentally chaining forward from whichever row was Shift-clicked most recently.
This matches how spreadsheets and file managers behave — Shift-click defines a fresh contiguous range from the anchor. If you want to add a separate non-contiguous row to an existing selection, that is what Cmd/Ctrl-click is for; combining both modifiers in sequence lets you compose ranges and individual toggles together.
Yes — Cmd-click a few individual rows first (each one becomes the new anchor as you go), then Shift-click another row: the range will be measured from the most recently Cmd-clicked row, letting you build up complex selections the same way a spreadsheet allows.
They have pointer-events: none and are purely a visual reflection of the selected Set — this guarantees there is exactly one source of truth for selection state (the row click handler), so the checkbox display can never fall out of sync with the actual selection.
The selected Set holds the currently selected row indices at any time — iterate it (or check each row's .selected class) to build a bulk-action payload referencing the underlying data for those rows.
Shift-click and Ctrl-click are keyboard-modifier-dependent and are primarily a desktop pattern. For touch, pair this with a long-press-to-enter-selection-mode pattern so mobile users have an equivalent way to multi-select without keyboard modifiers.