required on a checkbox makes that box mandatory, so putting it on every box in a group forces the user to tick all of them. To require at least one, leave required off, call setCustomValidity("Pick at least one") on the first box while none is checked, clear it with setCustomValidity("") on change, and let the browser block the submit. Then check the count again on the server.Point any HTML form at one endpoint. 500 free submissions — no card, no time limit — and unlimited forms.
Create free accountThe problem: required makes every box required
The required attribute works per control. On a checkbox it means "this box must be ticked", which is right for an "I agree to the terms" box and wrong for a group of choices. Sharing a name doesn't join checkboxes into one group for validation, so this form can't be sent until all three boxes are ticked:
<!-- Wrong: the user must tick ALL three -->
<fieldset>
<legend>Topics</legend>
<label><input type="checkbox" name="topics" value="pricing" required> Pricing</label>
<label><input type="checkbox" name="topics" value="api" required> API</label>
<label><input type="checkbox" name="topics" value="security" required> Security</label>
</fieldset>Radio buttons behave differently. Radios with the same name form one group, and required on any of them is satisfied as soon as one is picked. That is why "just add required" works for radios and confuses people with checkboxes. If only one answer is allowed, switch to radios. If several are allowed but at least one is needed, you need a small script.
Fix 1: setCustomValidity on the first box
The Constraint Validation API lets you mark any control as invalid with your own message. While no box is checked, put a message on the first box. The browser then treats the form as invalid, blocks the submit and shows the message next to that box, just like a required field. Clear it with an empty string as soon as one box is ticked. The custom HTML5 validation message guide covers setCustomValidity() in more depth.
<form id="signup" action="/subscribe" method="POST">
<fieldset id="topics">
<legend>Which topics? (pick at least one)</legend>
<label><input type="checkbox" name="topics" value="pricing"> Pricing</label>
<label><input type="checkbox" name="topics" value="api"> API</label>
<label><input type="checkbox" name="topics" value="security"> Security</label>
</fieldset>
<button type="submit">Send</button>
</form>
<script>
const boxes = [...document.querySelectorAll('#topics input[type="checkbox"]')];
function checkGroup() {
const ok = boxes.some((b) => b.checked);
boxes[0].setCustomValidity(ok ? "" : "Pick at least one topic.");
}
boxes.forEach((b) => b.addEventListener("change", checkGroup));
checkGroup(); // invalid from the start, until a box is ticked
</script>With 0 boxes ticked, pressing Send does nothing except show "Pick at least one topic." on the first box. Tick any box and change clears the message, so the next submit goes through. Calling checkGroup() once on load matters: without it the group starts out valid and an untouched form would submit empty.
If you submit with fetch, call reportValidity()
The native check runs before the submit event, so a normal submit listener only fires when the group is valid. If you send the form from a button click handler or a script instead, run the check yourself with form.reportValidity(), which shows the message and returns false:
sendButton.addEventListener("click", async () => {
checkGroup();
if (!form.reportValidity()) return; // shows "Pick at least one topic."
await fetch(form.action, { method: "POST", body: new FormData(form) });
});Fix 2: a min and max count ("pick 1 to 3")
The same pattern handles a range. Count the checked boxes and choose the message from the count. Keeping the limits in data- attributes means one script serves every group on the page:
<fieldset class="check-group" data-min="1" data-max="3">
<legend>Pick 1 to 3 interests</legend>
<label><input type="checkbox" name="interests" value="design"> Design</label>
<label><input type="checkbox" name="interests" value="seo"> SEO</label>
<label><input type="checkbox" name="interests" value="ads"> Ads</label>
<label><input type="checkbox" name="interests" value="email"> Email</label>
<label><input type="checkbox" name="interests" value="video"> Video</label>
</fieldset>
<script>
document.querySelectorAll(".check-group").forEach((group) => {
const boxes = [...group.querySelectorAll('input[type="checkbox"]')];
const min = Number(group.dataset.min || 1);
const max = Number(group.dataset.max || boxes.length);
function check() {
const count = boxes.filter((b) => b.checked).length;
let msg = "";
if (count < min) msg = `Pick at least ${min}.`;
else if (count > max) msg = `Pick no more than ${max}.`;
boxes[0].setCustomValidity(msg);
}
boxes.forEach((b) => b.addEventListener("change", check));
check();
});
</script>Some forms disable the unchecked boxes once the maximum is reached. That works, but disabled boxes are skipped by keyboard users and give no reason, so the message approach above is usually clearer. If you do disable them, say why in the legend ("up to 3").
Styling the group with :has() and :user-invalid
A <fieldset> matches :invalid when any control inside it is invalid, and :has() lets you style the whole group from its checked state. Use :user-invalid so the error only shows after the user has interacted or tried to submit, not on page load:
/* at least one box is ticked */
fieldset:has(input:checked) {
border-color: #16a34a;
}
/* show the error only after interaction or a submit attempt */
fieldset:has(:user-invalid) {
border-color: #dc2626;
}
fieldset:has(:user-invalid) .group-error {
display: block;
}
.group-error {
display: none;
color: #dc2626;
}:user-invalid and :has() are supported in current Chrome, Edge, Firefox and Safari. In an older browser the rules are simply ignored and the native validation bubble still blocks the submit, so nothing breaks.
Accessibility: fieldset, legend and a linked error
- Wrap the group in a fieldset with a legend. Screen readers read the legend ("Which topics? pick at least one") when focus enters the group, so put the rule in the legend text.
- Give the visible error an
idand point the fieldset at it witharia-describedby, so the rule is announced with the group. - The native bubble from
setCustomValidity()is announced, and the browser moves focus to the first box, which is the one carrying the message. - Avoid the "hidden required input" trick (a visually hidden
requiredtext field or checkbox that a script fills in). Screen readers can still reach it and announce an unlabelled required field, and the error bubble points at something the user can't see.
<fieldset id="topics" aria-describedby="topics-error">
<legend>Which topics? (pick at least one)</legend>
<label><input type="checkbox" name="topics" value="pricing"> Pricing</label>
<label><input type="checkbox" name="topics" value="api"> API</label>
<label><input type="checkbox" name="topics" value="security"> Security</label>
<p id="topics-error" class="group-error">Pick at least one topic.</p>
</fieldset>React: keep the choices in a state array
In React, store the checked values in an array and derive the error from its length. Show the message after a submit attempt and stop the submit while the array is empty:
const TOPICS = ["pricing", "api", "security"];
function TopicsForm() {
const [topics, setTopics] = useState([]);
const [tried, setTried] = useState(false);
const empty = topics.length === 0;
function toggle(value, checked) {
setTopics((prev) =>
checked ? [...prev, value] : prev.filter((t) => t !== value)
);
}
async function onSubmit(e) {
e.preventDefault();
setTried(true);
if (empty) return;
await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ topics }),
});
}
return (
<form onSubmit={onSubmit} noValidate>
<fieldset aria-describedby="topics-error">
<legend>Which topics? (pick at least one)</legend>
{TOPICS.map((t) => (
<label key={t}>
<input type="checkbox" value={t}
checked={topics.includes(t)}
onChange={(e) => toggle(t, e.target.checked)} />
{t}
</label>
))}
{tried && empty && (
<p id="topics-error" role="alert">Pick at least one topic.</p>
)}
</fieldset>
<button type="submit">Send</button>
</form>
);
}You can also write <button disabled={empty}>, but a silently disabled button doesn't tell anyone what is missing. If you go that way, keep the rule in the legend. The trade-offs are covered in disable the submit button until the form is valid.
Vue: v-model on an array
Vue binds a checkbox group straight to an array when every box uses the same v-model:
<script setup>
import { ref, computed } from "vue";
const topics = ref([]);
const empty = computed(() => topics.value.length === 0);
</script>
<template>
<form @submit.prevent="!empty && send()">
<label><input type="checkbox" value="pricing" v-model="topics"> Pricing</label>
<label><input type="checkbox" value="api" v-model="topics"> API</label>
<p v-if="empty">Pick at least one topic.</p>
<button type="submit" :disabled="empty">Send</button>
</form>
</template>How checkbox arrays reach the server (and why you check again)
Each ticked box sends its own name=value pair, and unticked boxes send nothing. Two ticks in a group named topics send topics=pricing&topics=api. In JavaScript, read every value with getAll(), because get() and Object.fromEntries() keep only one. The FormData guide explains why.
const data = new FormData(form);
data.getAll("topics"); // ["pricing", "api"], or [] when nothing is tickedServer frameworks differ. PHP only builds an array when the name ends in brackets, name="topics[]"; with a plain repeated name, $_POST['topics'] holds just the last value. Express with urlencoded({ extended: true }) gives a string for one tick and an array for two or more, and Flask needs request.form.getlist("topics").
Whatever you use, validate the count on the server too. The browser check is a convenience: anyone can disable JavaScript, edit the page or post straight to your endpoint. An empty group arrives as a missing key, not an empty list, so treat "no topics key" as zero. If a missing key confuses your backend, send a value for unchecked checkboxes shows the options.
// Express
const raw = req.body.topics; // undefined, "api" or ["api", "seo"]
const topics = raw === undefined ? [] : [].concat(raw);
if (topics.length < 1 || topics.length > 3) {
return res.status(400).send("Pick 1 to 3 topics.");
}Copy-paste example: post the group to splitforms
Here is the full form, ready to paste into any HTML page. It blocks the submit until at least one topic is ticked, styles the group, links the error for screen readers and posts to splitforms with no backend of your own:
<style>
fieldset:has(input:checked) { border-color: #16a34a; }
fieldset:has(:user-invalid) { border-color: #dc2626; }
.group-error { display: none; color: #dc2626; }
fieldset:has(:user-invalid) .group-error { display: block; }
</style>
<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">
<label>Email <input type="email" name="email" required></label>
<fieldset id="topics" aria-describedby="topics-error">
<legend>Which topics? (pick at least one)</legend>
<label><input type="checkbox" name="topics" value="pricing"> Pricing</label>
<label><input type="checkbox" name="topics" value="api"> API</label>
<label><input type="checkbox" name="topics" value="security"> Security</label>
<p id="topics-error" class="group-error">Pick at least one topic.</p>
</fieldset>
<button type="submit">Send</button>
</form>
<script>
const boxes = [...document.querySelectorAll('#topics input[type="checkbox"]')];
function checkGroup() {
const ok = boxes.some((b) => b.checked);
boxes[0].setCustomValidity(ok ? "" : "Pick at least one topic.");
}
boxes.forEach((b) => b.addEventListener("change", checkGroup));
checkGroup();
</script>splitforms collects repeated non-empty values under one key into an array, so two ticks are stored as ["pricing", "api"] and one tick as a single value. The field and payload reference explains how repeated values are stored. Get a free access key to try it.
FAQ
Why does required on every checkbox force all of them to be checked?
required on a checkbox means that one box must be checked. Each box is validated on its own, even when they share a name, so required on three boxes makes all three mandatory. Radio buttons are different: a radio group with the same name counts as one control, so required on any radio only needs one choice.
How do I require at least one checkbox without JavaScript?
HTML has no attribute for it. The no-script options are a <select multiple required>, which needs at least one option chosen, or radio buttons if only one answer is allowed. For a real checkbox group you need a few lines of JavaScript with setCustomValidity(), plus a check on the server.
How do I allow only 1 to 3 checkboxes?
Count the checked boxes on every change. Set a message such as 'Pick at least one' when the count is 0 and 'Pick no more than 3' when it is over 3, with setCustomValidity() on the first box, and clear it with an empty string when the count is in range. You can also disable the unchecked boxes once 3 are ticked.
How do I style a checkbox group that has no box checked?
Use fieldset:has(input:checked) for the valid state and fieldset:not(:has(input:checked)) for the empty one. To show the error only after the user has interacted, combine it with :user-invalid, for example fieldset:has(:user-invalid), which matches once the invalid first box has been touched or a submit was tried.
How are checkbox groups sent to the server?
Each checked box sends its own name=value pair, so name="topics" with two ticks sends topics=pricing&topics=api. Read them with formData.getAll('topics'). PHP needs name="topics[]" to build an array; without the brackets $_POST keeps only the last value. Always check the count again on the server.
References: MDN: setCustomValidity(), MDN: :user-invalid and MDN: FormData.getAll().