Last Updated on 18/08/2025
Radio buttons look simple. One click, one choice. However, they’re also one of the easiest ways to compromise UX and accessibility. Hide the native input, skip the fieldset
and legend
, or validate too aggressively and you’ll ship a form that confuses keyboard users, frustrates screen readers, and bleeds conversions.
This guide shows you how to do radios right in 2025: fast to choose, easy to scan, fully accessible, and measurable. We’ll start with a clean, semantic baseline, then layer on modern CSS so radios can look like cards or segmented controls without breaking keyboard or screen-reader behavior.
You’ll see practical validation patterns that don’t nag, responsive layouts that keep targets big on mobile, and production snippets you can paste into any stack. We’ll also cover analytics, A/B ideas, and a Formspree setup so you can capture results immediately.
Who this is for: designers, front-end devs, and marketers who care about both accessibility and conversion. No heavy frameworks required, just solid HTML, focused CSS, and a little progressive enhancement.
By the end, you’ll know:
- When radios beat selects, checkboxes, or toggles
- How to build an accessible radio group (
fieldset
,legend
, labeling, focus) - How to style custom “card” radios without hiding the input
- How to validate with the HTML Constraint API (and message well)
- How to wire radios to Formspree and measure selections
TL;DR
- Use radios for exactly one choice within a well-defined set (ideally 2–6 options) that should be visible at once.
- Always wrap radios in
<fieldset>
+<legend>
, give them the samename
, and make each option a clickable<label>
. - Don’t hide inputs with
display:none
; keep them focusable (e.g.,opacity:0; position:absolute; inset:0
) so Tab/Arrows/Space work. - Make targets big: at least 44×44px, with a focus ring that’s clearly visible.
- Validate with the HTML Constraint API (
required
,reportValidity()
), then enhance messages, not the other way around. - If you have many options or they’re unfamiliar, add helper text under each label and consider search or grouping instead.
- On mobile, stack in one column; on wide screens, 2–3 columns via CSS grid.
- Measure: track impressions, selection, and submit, and A/B test option order and copy.
- For Formspree, keep native POST as a fallback; add fetch only as progressive enhancement.
When to Use Radios (Decision Tree)
Q1. Is the user choosing exactly one option?
- No → Use checkboxes (0–many) or a toggle (binary) or rethink the task.
- Yes → Go to Q2.
Q2. Do the options need to be visible at a glance?
- Yes → Use radios (best for scanning & quick choice).
- No (space is tight / many options) → Consider a select (dropdown). If choices are critical and benefit from comparison, prefer radios and redesign the layout.
Q3. How many options?
- 2–6 → Radios are ideal.
- 7–10 → Radios may still work with grouping or a two-column grid; otherwise use a select or a searchable list.
- >10 → Use a select with search or a step that narrows the set first.
Q4. Are the options mutually exclusive states?
- Yes → Radios.
- No / combinations make sense → Checkboxes.
Q5. Will the choice trigger different follow-up questions?
- Yes → Radios still fine. Show follow-ups conditionally, but keep hidden fields out of the tab order and SR flow until revealed.
- No → Simple radio group.
Q6. Familiar vs. descriptive?
- Familiar labels (e.g., “Small / Medium / Large”) → short labels are enough.
- Unfamiliar or high-stakes (e.g., “Technical SEO vs. Content SEO”) → add microcopy (one-line description) under each label.
Q7. Mobile-first?
- Yes (always) → Use one-column stack with generous spacing and 16–18px text. Promote the most-picked option toward the top.
Practical examples
- Your form: “Which service?” → Radios beat a select because users should compare options side by side. Keep 3 cards (Link Building, SEO, Web Dev) with short subtext.
- Shipping speed: 3–4 mutually exclusive speeds → Radios (with price deltas in subtext).
- Newsletter frequency: Daily/Weekly/Monthly → Radios.
- Interests (can pick multiple) → Not radios; use checkboxes.
Anatomy of a Proper Radio Group
Copy-paste semantic baseline (no fancy CSS yet)
<form id="quote-form" action="/submit" method="post">
<fieldset id="service-group">
<legend>Which service do you need?</legend>
<p id="service-hint">Pick one option that best matches your goal.</p>
<div class="field">
<label>
<input type="radio" name="service" id="svc-link" value="link-building" required>
<span>Link Building</span>
</label>
<label>
<input type="radio" name="service" id="svc-seo" value="seo">
<span>Search Engine Optimization</span>
</label>
<label>
<input type="radio" name="service" id="svc-web" value="web-dev">
<span>Web Design / Development</span>
</label>
</div>
<p id="service-error" class="error" role="alert" hidden>
Please choose a service to continue.
</p>
</fieldset>
<button type="submit">Continue</button>
</form>
Why each part matters
<fieldset>
+<legend>
gives the group a name for screen readers and sets context for everyone.- Same
name
makes the inputs mutually exclusive. - Clickable
<label>
around each input gives you a huge tap/click area (44px+). required
on the first radio lets the browser validate the group natively.- Helper text (
service-hint
) and error text (service-error
) are separate so you can show/hide errors without moving things around. - No ARIA roles needed. Native radios already expose the correct semantics.
Minimal CSS for usability (native look, big targets, clear focus)
/* Layout & spacing */
.field label {
display: flex;
align-items: center;
gap: .6rem;
padding: .65rem .8rem; /* big tap target */
border-radius: .6rem;
cursor: pointer;
}
/* Keyboard focus: highlight the whole label when the input is focused */
.field label:has(input:focus-visible) {
outline: 3px solid #3b82f6;
outline-offset: 2px;
}
/* Selected state (optional, native dot still shows) */
.field label:has(input:checked) {
background: #f1f5ff;
}
/* Error text */
.error { color: #b91c1c; margin-top: .5rem; font-size: .9rem; }
Tip:
:has()
gives you that “focus ring on the whole card” without hiding the real input. If an older browser doesn’t support it, users still get the native focus ring on the radio itself.
Gentle, accessible validation (uses the browser first)
Let the browser handle the “you must pick one” part, then show your inline error if needed.
const form = document.getElementById('quote-form');
const group = document.getElementById('service-group');
const error = document.getEementById('service-error');
form.addEventListener('submit', (e) => {
// Trigger native validation UI (works because the first radio has `required`)
if (!form.reportValidity()) {
// Optional: surface a friendly inline error too
error.hidden = false;
group.setAttribute('aria-invalid', 'true');
e.preventDefault();
} else {
error.hidden = true;
group.removeAttribute('aria-invalid');
}
});
Behavior you should expect (and test):
- Keyboard: Tab once to enter the group; use Arrow keys to move; Space selects.
- Screen readers: The legend is read as the group label; each option is announced with “selected/not selected” state.
- Touch: Tapping the label toggles the radio (thanks to label wrapping).
Custom-Styled “Card” Radios (without breaking accessibility)
Copy-paste HTML
<form id="plan" action="/submit" method="post">
<fieldset>
<legend>Choose a plan</legend>
<p id="plan-hint">Pick one option. You can change anytime.</p>
<div class="card-group" role="group" aria-describedby="plan-hint">
<label class="card">
<input type="radio" name="plan" value="starter" required>
<span class="card-title">Starter</span>
<span class="card-sub">Good for small sites</span>
</label>
<label class="card">
<input type="radio" name="plan" value="growth">
<span class="card-title">Growth</span>
<span class="card-sub">Most popular</span>
</label>
<label class="card">
<input type="radio" name="plan" value="pro">
<span class="card-title">Pro</span>
<span class="card-sub">Advanced features</span>
</label>
</div>
<p id="plan-error" class="error" role="alert" hidden>Select one to continue.</p>
</fieldset>
</form>
Minimal, robust CSS
:root{
--brand:#0b5cff; --ring: rgba(11,92,255,.35);
--border:#e5e7eb; --bg:#fff; --muted:#6b7280;
--radius:14px;
}
/* Grid */
.card-group{
display:grid; gap:16px;
grid-template-columns: repeat(3, minmax(0,1fr));
}
@media (max-width:900px){ .card-group{ grid-template-columns:1fr 1fr; } }
@media (max-width:640px){ .card-group{ grid-template-columns:1fr; } }
/* Card label */
.card{
position:relative;
display:grid; place-items:center; text-align:center;
gap:6px; padding:18px;
border:1px solid var(--border); border-radius:var(--radius);
background:var(--bg);
cursor:pointer; user-select:none;
transition: box-shadow .15s, border-color .15s, transform .02s;
}
.card:hover{ box-shadow:0 8px 20px rgba(0,0,0,.06); }
/* Keep the native input focusable: no display:none */
.card input{
position:absolute; inset:0; opacity:0;
}
/* Keyboard focus ring on the whole card */
.card:has(input:focus-visible){
box-shadow:0 0 0 3px var(--ring);
border-color: var(--brand);
}
/* Selected state */
.card:has(input:checked){
border-color: var(--brand);
box-shadow:0 0 0 3px var(--ring);
}
/* Content */
.card-title{ font-weight:700; color:#111; }
.card-sub{ font-size:14px; color:var(--muted); }
/* Error text */
.error{ color:#b91c1c; margin-top:.5rem; font-size:.9rem; }
Behavior notes (why this pattern works)
- No
display:none
on the input → keyboard users can Tab in; Arrow keys move selection; Space selects. - The whole card is clickable because the input is inside the
label
. :has()
lets us style focus and checked states on the parent card without ARIA hacks.- Responsive grid gives 1–3 columns automatically; touch targets stay large.
Optional: gentle validation (native first)
const form = document.getElementById('plan');
const err = document.getElementById('plan-error');
form.addEventListener('submit', (e) => {
if (!form.reportValidity()) { // uses the `required` on first radio
err.hidden = false;
e.preventDefault();
} else {
err.hidden = true;
}
});
Validation Strategies That Don’t Annoy
Great validation is invisible until it’s needed. Here’s a radio-specific approach that’s fast, accessible, and soothing for users.
Principles
- Native first. Let HTML handle “one must be selected.”
- Defer errors. Don’t shout at users before they try to continue.
- Point to the problem. Announce errors, move focus smartly, and keep copy short.
- Clear on change. As soon as a valid choice is made, remove the error.
Baseline (native only)
HTML alone can enforce the rule with required
on one radio in the group.
<fieldset id="svc" aria-describedby="svc-hint">
<legend>Which service do you need?</legend>
<p id="svc-hint">Pick one option.</p>
<label>
<input type="radio" name="service" value="link" required>
<span>Link Building</span>
</label>
<label>
<input type="radio" name="service" value="seo">
<span>Search Engine Optimization</span>
</label>
<label>
<input type="radio" name="service" value="web">
<span>Web Design / Development</span>
</label>
</fieldset>
This already gives you: keyboard arrows, Space to select, and browser-level validation.
Friendly inline errors (progressive enhancement)
Turn off the browser’s popup and handle messaging inline so it matches your design.
HTML (add an error region)
<form id="quote" novalidate>
<!-- fieldset from above -->
<p id="svc-err" class="error" role="alert" hidden>Please choose one option.</p>
<button type="submit">Continue</button>
</form>
CSS (simple style)
.error { color:#b91c1c; margin-top:.5rem; font-size:.9rem; }
JS (defer errors, announce properly)
const form = document.getElementById('quote');
const group = document.getElementById('svc');
const err = document.getElementById('svc-err');
const radios = form.querySelectorAll('input[name="service"]');
let attempted = false; // show errors only after user tries to continue
function hasSelection() {
return [...radios].some(r => r.checked);
}
function showError(msg='Please choose one option.') {
err.textContent = msg;
err.hidden = false;
group.setAttribute('aria-invalid', 'true');
// Append error to describedby so SRs announce it next
group.setAttribute('aria-describedby', 'svc-hint svc-err');
}
function clearError() {
err.hidden = true;
group.removeAttribute('aria-invalid');
group.setAttribute('aria-describedby', 'svc-hint');
}
form.addEventListener('submit', (e) => {
attempted = true;
if (!hasSelection()) {
e.preventDefault();
showError();
// Bring the group into view and place focus on the first radio
radios[0].focus();
group.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
clearError();
}
});
// Don’t nag early; clear once they pick something
radios.forEach(r => {
r.addEventListener('change', () => {
if (attempted) clearError();
});
});
Why this works
- No browser tooltip bubbles; users see errors inline next to the group.
- Screen readers hear the legend, then the error, thanks to
aria-describedby
. - Errors only appear after a submit attempt (or when returning to fix).
Gentle per-step validation (multi-step forms)
If you gate with a “Next” button, validate just that step.
document.getElementById('nextBtn').addEventListener('click', () => {
if (!hasSelection()) {
showError();
} else {
clearError();
// advance to next step...
}
});
Custom messages with the Constraint API (optional)
If you prefer keeping the browser’s validity model, set a custom message on the first radio in the group.
const firstRadio = radios[0];
form.addEventListener('submit', (e) => {
if (!hasSelection()) {
firstRadio.setCustomValidity('Please choose one option.');
// Triggers native UI; or call form.reportValidity()
e.preventDefault();
form.reportValidity();
} else {
firstRadio.setCustomValidity('');
}
});
Use this when you want the native “bubble” plus your own text, otherwise stick to the inline method above for consistent UI.
Error summary (for long forms)
If your form is long, add a top summary that links to the problem area.
<div id="error-summary" class="error" role="alert" hidden>
There’s a problem: <a href="#svc">Choose a service</a>.
</div>
function showSummary() {
const sum = document.getElementById('error-summary');
sum.hidden = false;
sum.querySelector('a').focus(); // SRs announce the alert
}
Server-side & network fallbacks
Client-side validation improves UX, but always validate on the server (or by your submission service) too. If submission fails, return the page with:
- The previous selection persisted, and
- The same inline error next to the group.
Formspree Integration (Progressive Enhancement)
Make radios work even if JavaScript fails, then enhance for a smoother thank-you and better analytics.
Native HTML (always works)
<form
id="quote"
action="https://formspree.io/f/your_form_id"
method="POST"
>
<fieldset>
<legend>Which service do you need?</legend>
<p id="svc-hint">Pick one option.</p>
<label class="card">
<input type="radio" name="service" value="link-building" required>
<span class="card-title">Link Building</span>
<span class="card-sub">Earn high-quality backlinks.</span>
</label>
<label class="card">
<input type="radio" name="service" value="seo">
<span class="card-title">Search Engine Optimization</span>
<span class="card-sub">Boost rankings & traffic.</span>
</label>
<label class="card">
<input type="radio" name="service" value="web-dev">
<span class="card-title">Web Design / Development</span>
<span class="card-sub">Fast, mobile-first sites.</span>
</label>
</fieldset>
<label>
<span>Full Name</span>
<input type="text" name="name" autocomplete="name" required>
</label>
<label>
<span>Email</span>
<input type="email" name="email" autocomplete="email" required>
</label>
<!-- Optional: subject line in your inbox -->
<input type="hidden" name="_subject" value="New lead from Contact form">
<!-- Simple honeypot -->
<input type="text" name="company" tabindex="-1" autocomplete="off" style="position:absolute; left:-9999px" aria-hidden="true">
<button type="submit">Get My Free Proposal</button>
<p id="form-error" class="error" role="alert" hidden>Something went wrong. Please try again.</p>
<p id="form-success" class="success" role="status" hidden>Thanks! We’ll be in touch shortly.</p>
</form>
What this gives you:
- Works with JS off (good for SEO, reliability).
- Browser handles “one must be selected” (
required
on first radio). - Formspree receives
service
,name
,email
, and_subject
.
JS enhancement (nice UX, better tracking)
Intercept the submit, validate, send with fetch
, and show an inline thank-you (or redirect).
<script>
(function(){
const form = document.getElementById('quote');
const okMsg = document.getElementById('form-success');
const errMsg = document.getElementById('form-error');
const submitBtn = form.querySelector('button[type="submit"]');
// Optional: capture UTM + page context
function appendMeta(fd){
const url = new URL(location.href);
['utm_source','utm_medium','utm_campaign','utm_term','utm_content'].forEach(k=>{
const v = url.searchParams.get(k);
if(v) fd.append(k, v);
});
fd.append('page_url', location.href);
if (document.referrer) fd.append('referrer', document.referrer);
}
form.addEventListener('submit', async (e) => {
// Keep native fallback if JS fails
e.preventDefault();
// Use native validity (includes radio required)
if (!form.reportValidity()) return;
const fd = new FormData(form);
// Don’t send honeypot if filled (treat as spam)
if (fd.get('company')) return; // silently drop bots
appendMeta(fd);
submitBtn.disabled = true;
const original = submitBtn.textContent;
submitBtn.textContent = 'Submitting…';
okMsg.hidden = true; errMsg.hidden = true;
try{
const res = await fetch(form.action, {
method: 'POST',
body: fd,
headers: { 'Accept': 'application/json' }
});
if(res.ok){
form.reset();
okMsg.hidden = false;
// Optional: fire analytics
if (window.gtag) gtag('event','generate_lead',{method:'formspree', service: fd.get('service')});
// Optional: redirect after success
// setTimeout(()=> location.href = '/thank-you/', 1200);
} else {
errMsg.hidden = false;
}
} catch {
errMsg.hidden = false;
} finally {
submitBtn.disabled = false;
submitBtn.textContent = original;
}
});
})();
</script>
Notes
- Set
Accept: application/json
so Formspree returns JSON and you can decide success/failure cleanly. - Use
form.reportValidity()
so the browser enforces radio selection and email format before you send. - Keep the native
action
/method
so the form still works without JS.
Spam control without hurting conversions
- Honeypot (included above) catches many bots with zero friction.
- Timing: add a hidden
started_at
when the page loads; ignore submissions faster than, say, 2 seconds. - Content signals: if you see repeated junk values, filter them in your Formspree rules or post-processing.
<input type="hidden" name="started_at" id="started_at">
<script>
document.getElementById('started_at').value = Date.now();
</script>
// in submit handler before sending:
const started = Number(fd.get('started_at'));
if (Date.now() - started < 2000) return; // looks like a bot
Subject lines that reflect the radio choice (clean inbox)
You can tailor _subject
from the selected radio:
const service = fd.get('service'); // e.g., "seo"
fd.set('_subject', `New ${service} lead from Contact form`);
Common pitfalls to avoid
- Hiding radios with
display:none
. Useopacity:0; position:absolute; inset:0
inside the label so keyboards still work. - Forgetting
fieldset
/legend
. Screen readers rely on them for context. - Over-validating early. Show errors after submit/Next, then clear as soon as the user fixes the group.
- Not capturing context. Add
page_url
,referrer
, and UTM fields, you’ll thank yourself later.
Internationalization & Inclusivity
Language, labels, and tone
- Use the page’s
lang
(and switch per-locale pages):<html lang="en"> … </html>
- Keep labels short and descriptive, avoid culture-specific jargon.
- Prefer neutral phrasing (“Choose a plan”) over assumptions (“What’s your budget?”).
- Localize errors and helper text with a simple dictionary keyed by
document.documentElement.lang
.
const i18n = {
en: { choose: 'Please choose one option.' },
es: { choose: 'Elige una opción, por favor.' },
ar: { choose: 'يُرجى اختيار خيار واحد.' }
};
const t = (k) => (i18n[document.documentElement.lang] || i18n.en)[k];
errorEl.textContent = t('choose');
Long labels & multiline options
- Let labels wrap; never truncate crucial words.
- Keep the hit area big even when text wraps: use padding on the label, not just the input.
.card { inline-size: 100%; padding: 16px; }
.card .card-title { font-weight: 700; line-height: 1.25; }
.card .card-sub { font-size: 14px; line-height: 1.4; }
Right-to-left (RTL) support
Use CSS logical properties and :dir()
so you don’t need separate styles.
/* Spacing that flips in RTL automatically */
.card { padding-inline: 16px; padding-block: 16px; }
/* Focus ring that respects direction */
.card:has(input:focus-visible) {
outline: 3px solid var(--ring);
outline-offset: 2px;
}
/* Optional: direction-specific tweaks */
:dir(rtl) .card-group { direction: rtl; }
Accessibility beyond ARIA
- Ensure contrast ≥ 4.5:1 for labels and focus states.
- Touch targets ≥ 44×44 px (padding rather than font size).
- Don’t communicate state by color alone; use borders, icons, or text.
Testing Checklist (Print-Friendly)
- Semantics: Radios are inside
fieldset
with a visiblelegend
. - Keyboard: Tab enters the group; arrows move; Space selects.
- Screen readers: Legend announced; each option says selected/not selected.
- Focus: Visible ring on the whole card (and on the native input).
- Hit area: Entire label is clickable; works on mobile.
- Validation: No errors until submit/Next; errors clear on change.
- Copy: Options are short; helper text clarifies differences.
- Layout: 1 column on phones; 2–3 on desktop; no overflow clipping.
- i18n: Errors/labels localized; long text wraps gracefully; RTL OK.
- Analytics: Events for impression, select, submit.
- Performance: No heavy UI libs; minimal CSS; no layout shift.
Performance & Maintainability
- Favor native inputs + light CSS; avoid replacing radios with divs.
- Keep CSS modular (custom properties for colors, radius, ring).
- Avoid
display:none
on inputs; useopacity:0; position:absolute; inset:0
so accessibility remains intact. - Defer non-critical JS; keep enhancements under ~2–5 KB when possible.
- Use container queries (when available) instead of complex media breakpoints for embedded forms.
@container (min-width: 680px) {
.card-group { grid-template-columns: 1fr 1fr 1fr; }
}
Analytics & Experimentation
Track intent and friction points:
// Fire when the radio group becomes visible
gtag?.('event','form_step_view', { step: 1, form: 'contact' });
// Fire on selection
document.querySelectorAll('input[name="service"]').forEach(r => {
r.addEventListener('change', () => {
gtag?.('event','radio_select', { group: 'service', value: r.value });
});
});
// Fire on successful submit
gtag?.('event','generate_lead', { form: 'contact', service: form.service.value });
A/B test:
- Option order (most-picked first)
- CTA copy (“Get My Free Proposal” vs “Get My Quote”)
- Helper text presence
Anti-Patterns (Don’t Do These)
- Hiding inputs with
display:none
(breaks keyboard & SRs). - Missing
fieldset/legend
. - Tiny hit areas (only the small dot clickable).
- Color-only selection state; invisible focus.
- Validating before the user tries to proceed.
- Packing 10–20 options into radios; use a searchable select instead.
Final Checklist
fieldset
+legend
present- Same
name
, uniquevalue
s - Labels wrap inputs; entire card clickable
- Focus ring clearly visible
required
on first radio; gentle inline errors- 1–3 column grid responsive, big touch targets
- Localized strings; RTL safe via logical properties
- Analytics on view/select/submit
- Server-side validation mirrors client rules
Resources
Specs & Authoring Patterns
- HTML Standard –
<input type="radio">
: native semantics, grouping byname
, form behavior. - WAI-ARIA Authoring Practices 1.2 – Radio Group: expected keyboard behavior (Tab into group, Arrow to move, Space to select), labeling patterns.
- WCAG 2.2 essentials for radios:
- 2.1.1 Keyboard (everything operable via keyboard)
- 2.4.7 Focus Visible (clearly visible focus)
- 2.5.5 Target Size (≥44×44px recommended)
- 1.4.3 Contrast (Minimum)
Reference Guides
- MDN: Radio input,
label
,fieldset
/legend
, form validation (Constraint Validation API). - GOV.UK Design System – Radios: excellent, battle-tested guidance on wording and errors.
- Inclusive Components (Heydon Pickering): practical a11y patterns and critiques.
Testing & Tooling
- axe DevTools (browser extension) – automated a11y checks.
- Accessibility Insights – quick guided tests.
- Lighthouse – performance + accessibility signals.
- WebAIM Contrast Checker – verify color contrast.
- Screen readers to spot-check real behavior:
- NVDA (Windows), JAWS (Windows), VoiceOver (macOS/iOS), TalkBack (Android).
- Manual keyboard pass: Tab into the group → Arrows move → Space selects → focus ring stays obvious.
Check out our review of Notions Marketing
FAQs (Short & Practical)
Aim for 2–6. If you have 7–10, group or use a two-column grid. Over ~10, switch to a select (with search) or redesign to narrow choices first.
Only if there’s a clear majority default and it won’t bias data. Preselection speeds completion but can reduce deliberate choice. If unsure, don’t preselect.
Radios: single choice, options should be visible side-by-side.
Select: single choice when space is tight or options are many.
Checkboxes: 0–many choices (not mutually exclusive).
Yes. Keep the native input focusable (don’t use display:none
). Put the input inside the <label>
, set it to opacity:0; position:absolute; inset:0;
, and style the label. Use :has(input:focus-visible)
and :has(input:checked)
to drive focus/selected states.
Use HTML first: put required
on the first radio and call form.reportValidity()
(or let the browser handle submit). For nicer UI, show a short inline error near the group after the first attempt, and clear on change.
role="radiogroup"
? Not for native radios. A proper fieldset
+ legend
already gives screen readers the right context. Add ARIA only if you’re building a fully custom widget (avoid if possible).
Show/hide the follow-ups after a selection. When hidden, also disable those controls so they’re out of tab order and not submitted. When revealed, move focus sensibly and ensure labels remain clear.
Keyboard flow, visible focus, screen-reader announcements (legend read, selected state voiced), mobile hit areas, validation timing (no early nagging), and that selected values persist on server-side error.