Previous Article
WooCommerce Product View Counter: A Version That Actually Scales
14 November 2018
Reading which checkboxes or radio buttons a user has selected is a routine task with several sharp edges — particularly once you need groups that exclude one another, or a "select all" that reflects a partial selection.
Everything here is plain JavaScript. The jQuery approaches most tutorials show rely on .live(), which was removed in jQuery 1.9 back in 2013 — code using it does nothing at all on any modern version.
const terms = document.getElementById('terms');
if (terms.checked) {
// ticked
}
Use the .checked property, not the attribute. getAttribute('checked') returns the initial markup state and never changes as the user clicks. This trips people up constantly.
input.checked; // true / false — current state ✅
input.getAttribute('checked'); // reflects the HTML, not the user ❌
<fieldset> <legend>Extras</legend> <label><input type="checkbox" name="extras" value="gift-wrap" data-price="350" /> Gift wrap</label> <label><input type="checkbox" name="extras" value="express" data-price="900" /> Express delivery</label> <label><input type="checkbox" name="extras" value="insurance" data-price="250" /> Insurance</label> </fieldset>
const all = document.querySelectorAll('input[name="extras"]');
// The :checked pseudo-class does the filtering for you.
const checked = document.querySelectorAll('input[name="extras"]:checked');
const anyChecked = checked.length > 0;
const allChecked = checked.length === all.length;
const noneChecked = checked.length === 0;
// Values of everything ticked
const values = [...checked].map(input => input.value);
// ["gift-wrap", "express"]
querySelectorAll returns a NodeList, not an array. It supports forEach, but for map or filter you need to spread it first, as above.
If the inputs are inside a <form>, FormData collects them for you:
const data = new FormData(document.getElementById('order-form'));
const extras = data.getAll('extras'); // ["gift-wrap", "express"]
Note getAll(), not get() — get() returns only the first value, which is a quiet source of bugs with checkbox groups.
A radio group has at most one selection, so you want the element rather than a list:
const selected = document.querySelector('input[name="plan"]:checked');
// Nothing chosen yet returns null — always guard.
const plan = selected ? selected.value : null;
Radios are grouped by their shared name. If two "groups" on a page share a name, they behave as one group and selecting in either clears the other — a common cause of confusing behaviour on forms with repeated sections.
const total = [...document.querySelectorAll('input[name="extras"]:checked')]
.reduce((sum, input) => sum + Number(input.dataset.price), 0);
Keep money in integers. The prices above are in paise, not rupees. Floating point cannot represent most decimal fractions exactly, so summing 4.27 + 1.39 can yield 5.659999999999999. Work in the smallest unit and divide only when displaying:
const display = (total / 100).toFixed(2);
.live() existed to handle events on elements added after page load. The native equivalent is a listener on a stable ancestor:
document.getElementById('options').addEventListener('change', (e) => {
const input = e.target;
if (!input.matches('input[type="checkbox"], input[type="radio"]')) return;
updateSummary();
});
One listener covers every input inside #options, including ones added later. Use the change event rather than click — change also fires for keyboard selection, where click alone can miss it.
A "select all" checkbox should show a partial state when only some children are ticked:
const master = document.getElementById('select-all');
const items = document.querySelectorAll('input[name="items"]');
function syncMaster() {
const checked = document.querySelectorAll('input[name="items"]:checked').length;
master.checked = checked === items.length;
// indeterminate is a property only — there is no HTML attribute for it.
master.indeterminate = checked > 0 && checked < items.length;
}
master.addEventListener('change', () => {
items.forEach(item => { item.checked = master.checked; });
master.indeterminate = false;
});
items.forEach(item => item.addEventListener('change', syncMaster));
syncMaster();
indeterminate is purely visual — the input still submits as either checked or unchecked. It cannot be set in markup, only from JavaScript.
A common requirement: a set of checkboxes and a set of radios where choosing from one clears the other — "either apply these discounts, or apply this voucher, not both".
const discountBoxes = document.querySelectorAll('input[name="discounts"]');
const voucherRadios = document.querySelectorAll('input[name="voucher"]');
function clear(inputs) {
inputs.forEach(input => { input.checked = false; });
}
discountBoxes.forEach(box =>
box.addEventListener('change', () => {
if (box.checked) clear(voucherRadios);
updateSummary();
})
);
voucherRadios.forEach(radio =>
radio.addEventListener('change', () => {
if (radio.checked) clear(discountBoxes);
updateSummary();
})
);
Two things to get right. Announce the change — silently unticking a user's earlier choice is confusing, so update a live region explaining what happened. And enforce the rule on the server too; anyone can submit both groups by bypassing your JavaScript entirely, which on a discount form is a direct financial exploit.
The original version of this article did this:
<!-- BROKEN: the inner quotes terminate the attribute -->
<input data-itemcode="{"PromoId":1317,"Amount":1.39}" />
That does not parse. The HTML parser sees the attribute end at the second ", so PromoId becomes a stray attribute name and the JSON is lost. Encode the quotes:
<input data-itemcode='{"promoId":1317,"amount":139}' />
<!-- or, if you must use double quotes around the attribute -->
<input data-itemcode="{"promoId":1317,"amount":139}" />
In PHP, let the escaping function handle it:
<input data-itemcode="<?= htmlspecialchars(json_encode($item), ENT_QUOTES, 'UTF-8') ?>" />
Then read it back:
const item = JSON.parse(input.dataset.itemcode);
Simpler still: for a couple of scalar values, use separate attributes — data-promo-id and data-amount — and skip JSON entirely.
Another bug in the original markup: readonly="" on checkbox and radio inputs. The readonly attribute has no effect on checkboxes or radios — it applies to text inputs only. Users can still toggle them.
Use disabled instead, but note the trade-off: disabled inputs are excluded from form submission and skipped in the tab order. To show a value that is fixed but must still submit, use a readonly display element plus a hidden input:
<input type="checkbox" checked disabled /> <input type="hidden" name="extras" value="gift-wrap" />
<fieldset> with a <legend> so screen readers announce what the choice is about.<label> with every input — nesting the input inside the label is the simplest way.aria-live="polite" region.| Task | Code |
|---|---|
| Is one checked? | input.checked |
| All checked in a group | document.querySelectorAll('[name="x"]:checked') |
| How many checked | …:checked').length |
| Selected radio | document.querySelector('[name="x"]:checked') |
| All values | [...checked].map(i => i.value) |
| From a form | new FormData(form).getAll('x') |
| Partial state | input.indeterminate = true |
Advertisement
Written by
Amit VermaFounder / Senior Software Engineer
Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.
More articles by AmitYou might also like
We ship software that scales. Let's work together.