How to Create a Select All Checkbox in JavaScript

A select-all checkbox looks trivial until you ship it in a real app: a long list, disabled items, filters, pagination, and people who click fast. If you only implement the one-way behavior (select-all toggles children), you end up with the most annoying bug: the select-all box stays checked even after someone unchecks a single item. If you only implement the other direction (children update parent), you lose the convenience of bulk actions.

When I build this feature, I treat it like a tiny state machine with three states for the select-all control: unchecked (nothing selected), checked (everything selected), and indeterminate (some selected). The indeterminate state is where most implementations fall apart, especially once items are dynamically added or disabled.

You are going to build a select-all checkbox that works in production: vanilla JavaScript first (my default in 2026), then a jQuery version for legacy codebases. Along the way, I will show you the bi-directional sync pattern, how to handle disabled rows, and what to do when someone asks for ‘select all across pages‘.

The Behavior You Actually Want

Before touching code, pin down the rules. A select-all checkbox can mean different things depending on context, and ambiguity is where bugs grow.

Here is the behavior I recommend for most web apps:

  • Select-all toggles a specific group of checkboxes (not every checkbox on the page).
  • Disabled items do not change when select-all is toggled.
  • If any enabled item is unchecked, select-all becomes indeterminate or unchecked.
  • If all enabled items are checked, select-all becomes checked.
  • If none of the enabled items are checked, select-all becomes unchecked.

That last part implies the select-all checkbox is not a simple mirror of the last click. It is derived state.

A useful mental model is a team checklist:

  • Select-all is the team captain.
  • The individual checkboxes are the players.
  • The captain is only fully ‘yes‘ when every active player is ready.
  • The captain is ‘mixed‘ when only some players are ready.

If you implement it this way, the UI communicates truthfully at all times, even after filtering, rerendering, or partial edits.

One More Rule I Always Decide Up Front: What Does ‘All‘ Mean?

If you do not explicitly answer this, you will ship a feature that feels inconsistent.

In practice, ‘all‘ usually means one of these:

  • All enabled items in this group (most common for settings forms).
  • All visible items (common in filtered tables).
  • All items matching the current query, even across pages (common in admin dashboards).

The first two are DOM-state problems. The third is a data-model problem. Treating ‘across pages‘ as a DOM problem is how you end up with ghost selections, wrong bulk deletes, or users accidentally acting on items they never saw.

The Tri-State Checkbox Is Not Optional

Browsers give you an indeterminate state for a reason: it is the only honest way to communicate partial selection without inventing a custom UI.

  • selectAll.checked = true means everything eligible is selected.
  • selectAll.checked = false and selectAll.indeterminate = false means nothing eligible is selected.
  • selectAll.checked = false and selectAll.indeterminate = true means some (but not all) eligible items are selected.

Notice something subtle: indeterminate is not its own checked value. It is a visual state you set programmatically. Users cannot directly click to toggle indeterminate; clicking changes checked, and you recompute indeterminate afterward.

Building the Markup: Labels, Groups, and Disabled Items

I start with semantic HTML that gives me a clean grouping boundary. A fieldset/legend pair is perfect for checkbox groups, and it costs nothing.

This markup is the baseline I use:


Notification channels

Disabled channels will not change.

A few practical details:

  • I wrap the input in a label so the click target is generous.
  • I keep a group boundary (fieldset#channels) so my scripts do not accidentally toggle other checkboxes on the page.
  • I include disabled items intentionally, because your app will have them.

You can add simple styling, but the behavior will work without any CSS.

Real-World Markup Extras That Save You Later

I often add two small upgrades early:

1) Give each item a stable ID (or data attribute) if the list maps to backend entities.


2) If you plan to submit the form, add a name attribute to the checkboxes so the browser actually includes them.


Without name, the UI works but your backend receives nothing.

Vanilla JavaScript: Bidirectional Sync + Indeterminate State

Vanilla JavaScript is my first choice in 2026 because it is small, predictable, and plays well with any stack (server-rendered pages, SPA islands, or full frameworks).

The pattern is:

1) When select-all changes, set all enabled item checkboxes to match.

2) When any item checkbox changes, recompute select-all state.

The recompute step is where indeterminate is set.

Here is a complete, runnable example (copy into a single .html file and open it):






Select All Checkbox (Vanilla JS)

body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; padding: 24px; }

.card { border: 1px solid #d0d7de; border-radius: 12px; padding: 16px; max-width: 520px; }

.row { padding: 8px 0; }

.row--selectAll { border-bottom: 1px solid #eef1f4; margin-bottom: 8px; padding-bottom: 12px; }

.hint { margin: 6px 0 0; color: #57606a; font-size: 0.92rem; }

label { display: inline-flex; gap: 10px; align-items: center; cursor: pointer; }

input[type=‘checkbox‘] { width: 18px; height: 18px; }

Select all checkbox (vanilla JavaScript)

Notification channels

Disabled channels will not change.

(function () {

const group = document.getElementById(‘channels‘);

const selectAll = document.getElementById(‘selectAllChannels‘);

function getItemCheckboxes() {

return Array.from(group.querySelectorAll(‘input.channel‘));

}

function getEnabledItemCheckboxes() {

return getItemCheckboxes().filter(cb => !cb.disabled);

}

function updateSelectAllState() {

const enabled = getEnabledItemCheckboxes();

// Edge case: if everything is disabled, keep select-all unchecked and not indeterminate.

if (enabled.length === 0) {

selectAll.checked = false;

selectAll.indeterminate = false;

selectAll.disabled = true;

return;

}

selectAll.disabled = false;

const checkedCount = enabled.filter(cb => cb.checked).length;

if (checkedCount === 0) {

selectAll.checked = false;

selectAll.indeterminate = false;

} else if (checkedCount === enabled.length) {

selectAll.checked = true;

selectAll.indeterminate = false;

} else {

selectAll.checked = false;

selectAll.indeterminate = true;

}

}

function setAllEnabledItems(checked) {

getEnabledItemCheckboxes().forEach(cb => {

cb.checked = checked;

});

}

// 1) Select-all drives items.

selectAll.addEventListener(‘change‘, function () {

setAllEnabledItems(selectAll.checked);

updateSelectAllState();

});

// 2) Items drive select-all.

// Event delegation means it also works if you inject new checkboxes later.

group.addEventListener(‘change‘, function (event) {

const target = event.target;

if (!(target instanceof HTMLInputElement)) return;

if (target.type !== ‘checkbox‘) return;

if (!target.classList.contains(‘channel‘)) return;

updateSelectAllState();

});

// Initialize on load (handles pre-checked items from server rendering).

updateSelectAllState();

})();

Why I like this implementation:

  • It is group-scoped (fieldset#channels) so it does not interfere with other parts of the page.
  • It handles disabled items correctly.
  • It supports pre-checked values (for edit forms) because state is computed on load.
  • It supports dynamic lists because of event delegation.

One subtle point: I call updateSelectAllState() after setting items in the select-all handler. That makes the indeterminate flag correct even if something weird happens (like all items being disabled by a rule you added later).

Making It Feel ‘Production-Grade‘: A Reusable Initializer

When I implement select-all more than once on a page (think: permissions, roles, categories, recipients), I do not want to hand-copy logic. I usually wrap the pattern in a small function that accepts a container, a select-all checkbox, and a selector for item checkboxes.

Here is a generic version that preserves the same rules:

function initSelectAllGroup(options) {

const {

container,

selectAll,

itemSelector,

getEligibleItems

} = options;

if (!container) throw new Error(‘initSelectAllGroup: container is required‘);

if (!selectAll) throw new Error(‘initSelectAllGroup: selectAll is required‘);

if (!itemSelector) throw new Error(‘initSelectAllGroup: itemSelector is required‘);

function getItems() {

return Array.from(container.querySelectorAll(itemSelector));

}

function getEligible() {

const base = getItems();

if (typeof getEligibleItems === ‘function‘) return getEligibleItems(base);

return base.filter(cb => !cb.disabled);

}

function update() {

const eligible = getEligible();

if (eligible.length === 0) {

selectAll.checked = false;

selectAll.indeterminate = false;

selectAll.disabled = true;

return;

}

selectAll.disabled = false;

let checkedCount = 0;

for (const cb of eligible) {

if (cb.checked) checkedCount += 1;

}

if (checkedCount === 0) {

selectAll.checked = false;

selectAll.indeterminate = false;

} else if (checkedCount === eligible.length) {

selectAll.checked = true;

selectAll.indeterminate = false;

} else {

selectAll.checked = false;

selectAll.indeterminate = true;

}

}

function setAll(checked) {

for (const cb of getEligible()) {

cb.checked = checked;

}

}

// Select-all drives items.

selectAll.addEventListener(‘change‘, function () {

setAll(selectAll.checked);

update();

});

// Items drive select-all (delegated).

container.addEventListener(‘change‘, function (event) {

const target = event.target;

if (!(target instanceof HTMLInputElement)) return;

if (target.type !== ‘checkbox‘) return;

if (!target.matches(itemSelector)) return;

update();

});

update();

return {

update,

setAll

};

}

Usage looks like this:

initSelectAllGroup({

container: document.getElementById(‘channels‘),

selectAll: document.getElementById(‘selectAllChannels‘),

itemSelector: ‘input.channel‘

});

And if you want ‘visible only‘ selection later, you can pass a custom getEligibleItems filter without rewriting the whole feature.

Handling Fast Clicks and ‘Double Events‘

You will occasionally see odd behavior when:

  • The row is clickable and toggles the checkbox.
  • The checkbox itself is clickable.
  • You have both click and change handlers.

My rule: let the checkbox be the source of truth. Do not toggle checked state on click handlers if you can avoid it. Prefer listening to the checkbox change event, and if the row should toggle the checkbox, do it by forwarding to the checkbox (and then let change handlers run normally).

Example row-toggling without fighting the browser:

document.querySelectorAll(‘.row‘).forEach(row => {

row.addEventListener(‘click‘, event => {

const cb = row.querySelector(‘input[type=checkbox]‘);

if (!cb || cb.disabled) return;

// Ignore clicks that already originated from the checkbox itself.

if (event.target === cb) return;

cb.checked = !cb.checked;

cb.dispatchEvent(new Event(‘change‘, { bubbles: true }));

});

});

That dispatchEvent line is what keeps select-all and item states synced without duplicating logic.

Nested Groups (Optional, but Common in Permissions UIs)

If you have parent categories with child permissions, you may need multiple select-alls:

  • One for the whole panel.
  • One per category.

The same tri-state logic still works; you just apply it at multiple levels. The simplest approach is:

  • Each category select-all controls its own items.
  • The global select-all controls all category items.
  • Whenever any item changes, recompute both its category state and the global state.

This is where a reusable initializer (like initSelectAllGroup) pays for itself.

jQuery: When You’re Maintaining Legacy UI

In new code, I stick to vanilla JS or framework-native state management. Still, jQuery lives in a lot of internal tools, admin panels, and older products. If you are already shipping jQuery, it is reasonable to implement select-all with it.

Here is the same behavior in a runnable jQuery page (including indeterminate):






Select All Checkbox (jQuery)


body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; padding: 24px; }

.card { border: 1px solid #d0d7de; border-radius: 12px; padding: 16px; max-width: 520px; }

.row { padding: 8px 0; }

.row--selectAll { border-bottom: 1px solid #eef1f4; margin-bottom: 8px; padding-bottom: 12px; }

label { display: inline-flex; gap: 10px; align-items: center; cursor: pointer; }

input[type=‘checkbox‘] { width: 18px; height: 18px; }

Select all checkbox (jQuery)

Notification channels

$(function () {

const $group = $(‘#channels‘);

const $selectAll = $(‘#selectAllChannels‘);

function $enabledItems() {

return $group.find(‘input.channel‘).filter(‘:not(:disabled)‘);

}

function updateSelectAllState() {

const $enabled = $enabledItems();

const enabledCount = $enabled.length;

if (enabledCount === 0) {

$selectAll.prop(‘checked‘, false);

$selectAll.prop(‘indeterminate‘, false);

$selectAll.prop(‘disabled‘, true);

return;

}

$selectAll.prop(‘disabled‘, false);

const checkedCount = $enabled.filter(‘:checked‘).length;

if (checkedCount === 0) {

$selectAll.prop(‘checked‘, false);

$selectAll.prop(‘indeterminate‘, false);

} else if (checkedCount === enabledCount) {

$selectAll.prop(‘checked‘, true);

$selectAll.prop(‘indeterminate‘, false);

} else {

$selectAll.prop(‘checked‘, false);

$selectAll.prop(‘indeterminate‘, true);

}

}

// Select-all drives items.

$selectAll.on(‘change‘, function () {

const checked = $selectAll.prop(‘checked‘);

$enabledItems().prop(‘checked‘, checked);

updateSelectAllState();

});

// Items drive select-all (delegated).

$group.on(‘change‘, ‘input.channel‘, function () {

updateSelectAllState();

});

updateSelectAllState();

});

My recommendation is simple: if the page already ships jQuery, this is fine. If it does not, do not add jQuery just for select-all.

jQuery Pitfall: Mixing .attr() and .prop()

Checkbox state is a property, not an attribute. If you use .attr(‘checked‘, ...) you can get weird mismatches between the DOM attribute (initial state) and the live property (current state). In jQuery, .prop(‘checked‘, ...) is the correct tool.

Common Edge Cases: Filters, Pagination, and Select All Across Pages

The phrase ‘select all‘ can mean at least three different things:

1) Select all items currently visible.

2) Select all items currently loaded in the DOM (maybe includes items hidden by CSS).

3) Select all items matching a filter, even across pages.

Cases (1) and (2) are DOM problems. Case (3) is a product and data problem.

Filtered lists (visible only)

If your list supports filtering (search box, tag filters), you should decide whether select-all affects hidden items. For most UIs, I only select visible items. It matches user expectations: they filtered to act on what they see.

Implementation trick: select items by a selector that reflects visibility. For example, if you hide rows with [hidden], select only :not([hidden]).

function getVisibleEnabledItems(container) {

return Array.from(container.querySelectorAll(‘input.item‘))

.filter(cb => !cb.disabled)

.filter(cb => !cb.closest(‘[hidden]‘));

}

If you hide via CSS classes, check for that class instead. I avoid relying on computed styles for performance.

#### Filtering Changes Selection State (and That Is Fine)

A very common bug: the filter changes which items are eligible/visible, but select-all stays stuck in the previous state.

If select-all is derived state, the fix is simple: call your recompute function after the filter applies.

Example:

searchInput.addEventListener(‘input‘, function () {

applyFilter(searchInput.value);

updateSelectAllState();

});

Pagination and select-all across pages

If your data is paginated, there is no honest way for a single checkbox to represent millions of rows without a different model.

When product requirements say ‘select all across pages‘, I implement a selection model like this:

  • Maintain a Set of selected IDs.
  • When the user clicks select-all on the current page, add those IDs.
  • If they click a ‘Select all results‘ banner, switch into an ‘all matching‘ mode.

A practical approach is ‘allMatching‘ plus ‘exceptions‘:

  • allMatching = false means only IDs in selectedIds are selected.
  • allMatching = true means everything matching the current query is selected, except IDs in excludedIds.

This is what email clients and admin dashboards often do.

Pseudo-code for that model:

const selection = {

allMatching: false,

selectedIds: new Set(),

excludedIds: new Set(),

queryKey: null

};

function isSelected(id) {

return selection.allMatching ? !selection.excludedIds.has(id) : selection.selectedIds.has(id);

}

function toggleItem(id, checked) {

if (!selection.allMatching) {

checked ? selection.selectedIds.add(id) : selection.selectedIds.delete(id);

return;

}

// allMatching mode: everything is selected by default, so we track only exclusions.

checked ? selection.excludedIds.delete(id) : selection.excludedIds.add(id);

}

function selectAllOnPage(pageIds) {

if (!selection.allMatching) {

for (const id of pageIds) selection.selectedIds.add(id);

return;

}

// allMatching mode: selecting all on page means removing exclusions for those ids.

for (const id of pageIds) selection.excludedIds.delete(id);

}

function clearAllOnPage(pageIds) {

if (!selection.allMatching) {

for (const id of pageIds) selection.selectedIds.delete(id);

return;

}

// allMatching mode: clearing all on page means adding exclusions.

for (const id of pageIds) selection.excludedIds.add(id);

}

function setAllMatching(queryKey, enabled) {

selection.allMatching = enabled;

selection.queryKey = queryKey;

selection.selectedIds.clear();

selection.excludedIds.clear();

}

#### How I Communicate ‘Across Pages‘ in the UI

A single checkbox cannot explain this model on its own. I add a banner when the user selects all on the current page.

Example copy I use:

  • After clicking page select-all: ‘All 50 items on this page are selected.‘ with a link/button: ‘Select all 2,431 results‘.
  • After enabling allMatching: ‘All 2,431 results are selected.‘ with a button: ‘Clear selection‘.

This prevents the classic trap where users think they are acting on 50 visible items but you actually apply the action to 2,431 items.

#### Submitting the Selection to Your Backend

In across-pages mode, you do not submit a list of all selected IDs (that could be huge). You submit the selection intent:

  • The query (or a stable queryKey your server can map to filters).
  • Whether it is allMatching.
  • Any explicit includes/excludes.

Example payload:

function buildSelectionPayload() {

return {

queryKey: selection.queryKey,

allMatching: selection.allMatching,

selectedIds: Array.from(selection.selectedIds),

excludedIds: Array.from(selection.excludedIds)

};

}

Server-side, the safe approach is:

  • If allMatching is false: operate only on selectedIds.
  • If allMatching is true: re-run the query for that queryKey, then subtract excludedIds.

Also: always confirm destructive bulk actions with a count derived on the server.

Disabled Items That Change Based on Other Inputs

A very real scenario: checking one option disables another. Example: ‘Email‘ enabled disables ‘SMS‘ because of a plan limitation.

When eligibility changes dynamically, you must recompute select-all state. In the vanilla implementation earlier, calling updateSelectAllState() after any change already helps, but if eligibility changes due to non-checkbox UI (dropdowns, plan selectors, permissions), call recompute there too.

planSelect.addEventListener(‘change‘, function () {

applyPlanRules();

updateSelectAllState();

});

Items Added/Removed After Load

If you append items (infinite scroll, ‘Add recipient‘ button), event delegation makes item-to-select-all updates work automatically. But select-all-to-items still depends on how you collect items.

My two rules:

  • Do not cache the list of items forever. Re-query when you need it.
  • After adding items, call updateSelectAllState().

This keeps the select-all checkbox honest when the list size changes.

Accessibility Notes (The Stuff People Skip and Then Regret)

Checkboxes are one of the best-supported form controls for accessibility, which is good news. The main gotcha is indeterminate.

  • Indeterminate is visual state. Some assistive technologies announce it, some rely on your surrounding context.
  • Make sure the select-all label is clear: ‘Select all channels‘ is better than ‘Select all‘.
  • If the select-all only affects a subset (like visible items), say that in helper text.

I also like adding aria-describedby to connect the select-all checkbox to an explanation (as shown in the vanilla example). That way, a screen reader user gets the rule that disabled channels will not change.

Keyboard behavior is largely handled by the browser:

  • Tab focuses the checkbox.
  • Space toggles it.

Your job is not to break that by turning the entire row into a custom control without forwarding to the actual checkbox.

Performance Considerations (When Lists Get Big)

For a list of 10 or 50 items, performance is irrelevant. For 5,000 items, it matters.

What I optimize first:

  • Avoid repeated DOM queries inside loops.
  • Avoid reading layout (like computed styles) during selection operations.
  • Keep recompute O(n) and predictable.

In the generic initializer earlier, I used a simple loop to count checked items instead of .filter(...).length. That is not about micro-optimizing; it is about making the hot path obvious and avoiding extra array allocations when lists are large.

If selecting all triggers expensive side effects (like per-row rendering, API calls, or validation), I isolate the side effects and batch them. A simple technique is to update checkboxes first, then run a single update step, rather than triggering work per checkbox.

Also: do not attach a separate change listener to every checkbox if you can avoid it. Event delegation (one listener on the container) scales better.

Common Pitfalls (And How I Avoid Them)

These are the mistakes I see most often:

1) Forgetting to recompute select-all after an item changes.

  • Fix: treat select-all as derived state, always recompute.

2) Forgetting disabled items.

  • Fix: define eligibility and filter by !cb.disabled (and optionally visibility).

3) Not initializing state on load.

  • Fix: call updateSelectAllState() once after wiring listeners.

4) Selecting the wrong group.

  • Fix: scope queries to a container (fieldset, div, table) instead of document.

5) Confusing attributes and properties.

  • Fix: use .checked in vanilla JS, .prop(‘checked‘, ...) in jQuery.

6) Trying to make indeterminate user-clickable.

  • Fix: users click a checkbox to check/uncheck; you set indeterminate only after computing derived state.

Alternative Approaches (When a Checkbox Is Not Enough)

Sometimes a select-all checkbox is not the best control.

I switch to a different UI when:

  • Selection is not binary (e.g., multi-state per row).
  • The bulk action is dangerous (e.g., delete users) and you want explicit selection rather than accidental ‘select all‘.
  • The list is extremely large and selection intent needs a clearer model (‘Apply to all matching results‘ with explicit confirmation).

Two alternatives I use:

1) A ‘Select: None / Page / All results‘ dropdown.

  • This makes scope explicit.

2) Bulk actions without persistent selection.

  • Example: ‘Archive all filtered results‘ button that acts on the current query, without checkboxes at all.

The key idea: do not force a checkbox UI onto a problem that is really about query scope.

Expansion Strategy

When I expand a select-all checkbox from a demo into something I trust in production, I follow a checklist.

  • Define eligibility: enabled only, visible only, or all loaded items.
  • Define scope: this page, this group, or all matching results.
  • Implement bi-directional sync: select-all drives items and items drive select-all.
  • Implement tri-state UI: unchecked, checked, indeterminate.
  • Handle dynamic changes: items added/removed, disabled toggles, filtering.
  • Add safety rails: confirmation for destructive bulk actions; server-derived counts.

If you do only one thing from this list, do this: make select-all derived state and recompute it whenever anything relevant changes.

If Relevant to Topic

If you are integrating this into a larger codebase, a little tooling goes a long way:

  • Linters catch accidental global selectors (like document.querySelectorAll(‘input[type=checkbox]‘)).
  • A small unit test around your selection state logic prevents regressions when requirements change.
  • If you implement ‘across pages‘ selection, log the selection payload shape in dev mode and verify the server interprets it correctly.

I also like writing the selection model (especially allMatching + excludedIds) as pure functions that can be tested without the DOM. Then the DOM layer becomes thin: read current page IDs, call selection functions, render checkboxes, recompute select-all state.

That separation is what keeps this feature sane when the list UI evolves.

Scroll to Top