Custom checkboxes and radios with pure CSS: accessible patterns that don’t lie

Was this helpful?

You ship a new design system. Marketing loves the “clean” checkboxes. Support tickets arrive anyway: “Can’t tell what’s selected,” “Keyboard won’t work,” “Screen reader says ‘blank’.” Meanwhile your SRE brain is screaming: we just deployed a UI regression with the same energy as a bad config rollout—quiet at first, then expensive.

Custom form controls aren’t hard. Lying form controls are. This piece is about building custom checkboxes/radios with pure CSS while keeping the native semantics, keyboard behavior, and high-contrast survivability intact. No JS magic tricks, no accessibility theater.

Non-negotiables: what “accessible” really means for checkboxes/radios

Checkboxes and radios are boring on purpose. They’re among the most standardized pieces of interaction in the entire web platform. The browser gives you semantics, keyboard behavior, focus management, accessibility API wiring, and compatibility with assistive tech—basically a small miracle that ships to billions of devices.

When teams “customize” them, the most common mistake is replacing that miracle with a div and vibes. Sometimes it even passes a shallow audit: it looks clickable and it toggles with a mouse. But it fails the moment you try to tab through, use high contrast mode, zoom to 200%, or run a screen reader. In ops terms: it passes in staging where everyone is on the same MacBook; it falls apart in production where the fleet is reality.

Definition: a custom control that doesn’t lie

A custom checkbox/radio “doesn’t lie” if it meets these constraints:

  • Native element remains the source of truth. Use <input type="checkbox"> / <input type="radio">. Don’t recreate their behavior in JS.
  • Labeling is real. Use <label for="…"> or wrap the input in a label so click/tap targets work without hacks.
  • Keyboard works by default. Tab focuses the input; Space toggles checkbox; Arrow keys move within radio groups (with browser rules).
  • Focus is visible. Especially with :focus-visible. No “we’ll do a subtle shadow” nonsense that disappears in sunlight.
  • States are represented. Checked/unchecked, disabled, invalid, and (for checkboxes) indeterminate.
  • High contrast and forced colors don’t break it. If you draw everything with background images, forced-colors will turn your control into a ghost.

Opinion: If you can’t keep the native input in the DOM, don’t build custom checkboxes. Pick a different visual design that works with the platform. Your brand guidelines won’t pay your ADA settlement.

One quote you should tape to your monitor

“Hope is not a strategy.” — Gordon R. Dickson

It’s not a web quote, but it’s an engineering truth: hoping your custom UI behaves like a checkbox is how you ship outages in human form.

Facts and historical context worth knowing

Some context makes today’s constraints feel less arbitrary. Here are concrete facts that change how you design:

  1. Early web forms were built to mirror paper forms. Checkboxes and radios were meant to be predictable, not brandable. That “boring” baseline is why they’re so interoperable.
  2. CSS couldn’t reliably style native controls for years. Browsers treated inputs as OS widgets with limited hooks; “custom controls” became a cottage industry of hacks.
  3. The label-to-input association is older than most frameworks. The for/id pattern is a foundational usability affordance, not an accessibility add-on.
  4. Radio groups have built-in keyboard semantics. Arrow key navigation and mutually exclusive selection are handled by the browser when the name matches.
  5. :focus-visible exists because focus rings were getting bullied. Designers removed outlines; users lost track of where they were. Browsers responded with a smarter heuristic.
  6. Forced-colors mode is not niche. Windows High Contrast (and modern forced colors) is used by people who can’t read low-contrast UI—not by people who enjoy your gradients.
  7. SVG icons are not accessibility semantics. Drawing a checkmark doesn’t inform the accessibility tree. The input does.
  8. Indeterminate state is real. It’s a UI state, not a value; it won’t submit as “maybe.” It’s often used for “Select all” with partial selections.
  9. Browsers differ in default sizing and alignment. If you lean on default metrics, your pixel-perfect comp will drift across platforms. If you replace semantics, your behavior will drift instead. Choose your drift.

None of that is trivia. Each point is a reason the “div checkbox” pattern keeps breaking under real users.

Patterns that work with pure CSS (and why)

Pattern A: Visually hide the native input, style a sibling

This is the workhorse. You keep the real input in the DOM, focusable and interactive, but visually hidden. Then you style a span (or similar) as the “box/circle” using input:checked + .control selectors.

Why it works: the browser still owns behavior, AT still sees a checkbox/radio, forms still submit correctly, and you can theme using CSS.

Why it fails: people hide the input with display:none or visibility:hidden (removes it from focus/AT). Or they overlay it but break pointer events. Or they forget focus styling.

Pattern B: Style the input itself with appearance (carefully)

Modern CSS gives you appearance: none in many browsers, allowing you to restyle the native input directly. This can be clean. It can also blow up in forced-colors, platform quirks, and legacy browsers.

My take: use it only if your support matrix is modern and you explicitly test forced colors and zoom. Otherwise Pattern A is more robust.

Pattern C: Use accent-color when you just need them on-brand

If your goal is “make checkboxes blue,” not “invent a new checkbox,” use accent-color. It keeps native rendering but changes the highlight color. It’s the lowest-risk option and the least exciting—so it’s perfect.

Rule: The more “custom” your checkbox looks, the more operational testing it needs. Treat custom controls like a production dependency, not a CSS flourish.

What to avoid: role=checkbox on a div

Yes, ARIA has role="checkbox". No, it doesn’t give you free parity with native inputs. You’re signing up to implement keyboard, focus, label association, form integration, disabled states, and screen reader nuances. You’re also signing up to be wrong in at least one browser/AT combo you don’t own.

If you must do it (embedded app, no forms, extreme constraints), write it like a component with an SLO, tests across AT, and a rollback plan. Otherwise: don’t.

CSS recipes: checkbox, radio, and “toggle” without lying

Baseline HTML that scales

This structure is boring. That’s the point. Each option is a label wrapping input and visuals. It creates a big click target and keeps association bulletproof without relying on IDs.

cr0x@server:~$ cat controls.html
<fieldset class="choices">
  <legend>Notification settings</legend>

  <label class="choice">
    <input class="choice__input" type="checkbox" name="email_alerts">
    <span class="choice__control" aria-hidden="true"></span>
    <span class="choice__text">Email alerts</span>
  </label>

  <label class="choice">
    <input class="choice__input" type="checkbox" name="sms_alerts" disabled>
    <span class="choice__control" aria-hidden="true"></span>
    <span class="choice__text">SMS alerts (disabled)</span>
  </label>
</fieldset>

<fieldset class="choices">
  <legend>Pager escalation</legend>

  <label class="choice">
    <input class="choice__input" type="radio" name="pager" value="none">
    <span class="choice__control choice__control--radio" aria-hidden="true"></span>
    <span class="choice__text">None</span>
  </label>

  <label class="choice">
    <input class="choice__input" type="radio" name="pager" value="critical">
    <span class="choice__control choice__control--radio" aria-hidden="true"></span>
    <span class="choice__text">Critical only</span>
  </label>
</fieldset>

Note the aria-hidden="true" on the decorative control span. The input already provides semantics; we don’t want the ornament to show up in the accessibility tree.

CSS: visually hidden, not functionally dead

Here’s the crucial bit: “visually hidden” means still focusable and still in the accessibility tree. Do not use display:none. Do not use visibility:hidden. Those are the equivalent of unplugging a disk because the LED is annoying.

cr0x@server:~$ cat controls.css
.choices {
  border: 1px solid #d0d7de;
  border-radius: 10px;
  padding: 12px 14px;
  margin: 14px 0;
}

.choice {
  display: grid;
  grid-template-columns: 1.4rem 1fr;
  align-items: start;
  gap: 0.6rem;
  padding: 0.45rem 0;
  cursor: pointer;
}

.choice__input {
  /* Visually hidden but still focusable */
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.choice__control {
  width: 1.25rem;
  height: 1.25rem;
  border: 2px solid #5b6472;
  border-radius: 0.35rem;
  display: inline-grid;
  place-items: center;
  background: #fff;
  box-sizing: border-box;
}

.choice__control--radio {
  border-radius: 999px;
}

.choice__text {
  color: #111;
}

/* Checked */
.choice__input:checked + .choice__control {
  border-color: #0b5fff;
  background: #0b5fff;
}

.choice__input:checked + .choice__control::after {
  content: "";
  width: 0.65rem;
  height: 0.65rem;
  background: #fff;
  border-radius: 0.18rem;
}

.choice__input:checked + .choice__control--radio::after {
  border-radius: 999px;
  width: 0.55rem;
  height: 0.55rem;
}

/* Focus */
.choice__input:focus-visible + .choice__control {
  outline: 3px solid #0b5fff;
  outline-offset: 2px;
}

/* Disabled */
.choice__input:disabled + .choice__control {
  border-color: #9aa4b2;
  background: #f1f3f5;
}

.choice__input:disabled ~ .choice__text {
  color: #667085;
}

.choice:has(.choice__input:disabled) {
  cursor: not-allowed;
}

/* Forced colors */
@media (forced-colors: active) {
  .choice__control {
    forced-color-adjust: none;
    border-color: CanvasText;
    background: Canvas;
  }
  .choice__input:checked + .choice__control {
    background: Highlight;
    border-color: Highlight;
  }
  .choice__input:checked + .choice__control::after {
    background: HighlightText;
  }
  .choice__input:focus-visible + .choice__control {
    outline-color: Highlight;
  }
}

Important: :has() is used above for cursor styling. If you need to support browsers without it, drop that rule and accept a less perfect cursor. Don’t replace it with JS. Cursor correctness is not worth semantic risk.

Indeterminate checkboxes: the state everyone forgets

Indeterminate is not a value the user can toggle to directly with a native checkbox; it’s usually set by the application when children are partially selected. You can still style it with CSS if you use the :indeterminate pseudo-class.

cr0x@server:~$ cat indeterminate.css
.choice__input:indeterminate + .choice__control {
  border-color: #0b5fff;
  background: #0b5fff;
}

.choice__input:indeterminate + .choice__control::after {
  content: "";
  width: 0.7rem;
  height: 0.15rem;
  background: #fff;
  border-radius: 999px;
}

Setting indeterminate typically needs JS (because HTML doesn’t let you declare it). But you can still keep behavior native: JS sets input.indeterminate = true; CSS styles it. That’s honest.

A word on toggle switches

Everyone wants an iOS switch. But most “switch” components are just a checkbox with a costume. That can be fine if you don’t lie about what it is: use a checkbox, label it clearly, and don’t force it into a weird ARIA role unless you have a good reason. The input stays the control; the switch is decoration.

Joke #1 A “custom toggle” built on a div is like a RAID 0 of feelings: fast to ship, catastrophic to trust.

All the states you must support (and how they fail)

Checked vs unchecked

This is the easy part visually, and the easiest part to accidentally invert. I’ve seen CSS that renders the checkmark when unchecked because someone swapped the selectors during a refactor. If your visuals and your actual value diverge, you’ve created a UI that lies.

Focus and keyboard navigation

Keyboard users aren’t a niche. They include power users, people with motor impairments, folks using kiosk setups, and engineers who just like Tab because it’s faster. The critical things:

  • Tab must reach the control in a predictable order.
  • Focus must be visible when reached.
  • Space toggles checkboxes and activates radios.
  • Within a radio group, arrow keys navigate options (browser-dependent details), but focus behavior should remain sane.

If you hide the input wrong, focus disappears. If you fake the input with a div, you’ll probably forget Space or arrow keys. If you remove outlines, focus becomes a scavenger hunt.

Disabled

Disabled controls need both semantic disabled (disabled attribute) and visual disabled (colors/cursor). Don’t only gray it out. That’s just making something look disabled while it still toggles, which is the UI equivalent of a read-only filesystem that still accepts writes until it crashes.

Invalid and error messaging

Checkbox groups often fail validation (“You must accept terms”). The control should support :invalid and/or an explicit error state class. The error message should be programmatically associated (typically with aria-describedby on the input or group). Pure CSS can handle the visuals; semantics need HTML discipline.

High contrast and forced colors

If your checkmark is a background image, forced colors will likely ignore it. That’s why the recipe uses borders and backgrounds, plus forced-color-adjust and system colors like CanvasText. The objective isn’t to preserve your exact palette; it’s to preserve meaning.

Zoom, large text, and touch targets

At 200% zoom, your 12px checkbox becomes a precision instrument. Use a label wrapper and generous padding so the tap/click target is large. In corporate apps, a surprising amount of usage is on touch laptops. Tiny controls turn into “why is this broken?” reports.

Three corporate mini-stories from the field

Incident: the wrong assumption that turned consent into chaos

A product team rolled out a redesigned consent screen: cookie categories, marketing opt-ins, the usual compliance buffet. The new design used custom-styled checkboxes implemented as divs with click handlers. Someone added role="checkbox" and aria-checked and assumed that made it equivalent.

It mostly worked with a mouse. It worked in the designer’s preferred browser. The incident started quietly: customer support got scattered reports that “I can’t opt out” or “the checkbox keeps reverting.” The reports were inconsistent. That’s the most dangerous kind.

Then legal got involved. One user recorded a session: using keyboard navigation, Tab skipped some controls, and Space scrolled the page instead of toggling. The UI visually showed “unchecked,” but the backing state was actually “checked” because the click handler fired during label clicks in odd ways. Different paths, different state. A consent UI that doesn’t reliably reflect the actual value is not a design bug; it’s an operational risk with regulatory consequences.

The fix was unglamorous: rip out the div controls, reintroduce native inputs, and style them with sibling spans. The team also added an accessibility smoke test to CI. Not because they became saints, but because they didn’t want another cross-functional war over a checkbox.

Optimization that backfired: shaving DOM nodes, breaking behavior

Another company had a form-heavy internal tool. Performance complaints were real: older laptops, big tables, lots of controls. Someone proposed an “optimization”: remove extra markup for custom controls by styling inputs directly with appearance:none and dropping labels around them. Less DOM, faster render—on paper.

The result: a measurable improvement in initial render time in one benchmark. And then the backfire. Click targets shrank because labels weren’t wrapping text anymore. Users started missing controls; error rates went up. The support channel filled with “it didn’t save” type reports that were actually “I didn’t click the tiny box.”

Worse: focus rings were inconsistent across browsers when styling the input itself. Some combinations of CSS caused the focus indicator to be clipped by overflow rules in container layouts. Keyboard users were effectively blind. The performance win got eaten by productivity loss and “it’s broken” interruptions.

The lesson wasn’t “never optimize DOM.” It was “optimize where it matters.” They kept a slightly larger markup pattern (input + control span + text span), and they optimized rendering elsewhere: virtualization, fewer reflows, and sane CSS containment. The checkbox wasn’t the bottleneck. It was just the scapegoat.

Boring but correct: the practice that saved an outage

A payments-adjacent team maintained a multi-step onboarding form. They had a policy: any custom form control must have a keyboard-only acceptance test written down and run before every release. No exceptions. It wasn’t glamorous, and it definitely wasn’t popular during crunch.

One Friday, a designer pushed a “minor” visual tweak: hide the native checkbox with display:none because it was “still showing a pixel.” The rest of the control still looked fine. Mouse clicks still worked because the label click handler toggled some state in JS (yes, there was JS too). It would have shipped.

The acceptance test caught it in minutes: Tab no longer focused the checkbox. Screen reader output changed. The team reverted the CSS and used a correct visually-hidden pattern instead. Production never saw the regression. Nobody got paged for a UI change, which is the best kind of incident: the one you don’t have.

That policy sounded like bureaucracy until it prevented a high-impact failure in a critical funnel. Boring practices are often just reliability with a clipboard.

Fast diagnosis playbook

When custom checkboxes/radios “feel broken,” don’t start by tweaking colors. Start by proving semantics and behavior. Here’s a fast, production-grade sequence that finds the bottleneck quickly.

First: prove the native input exists and is focusable

  • Can you Tab to it?
  • Does Space toggle it?
  • Does the focus ring appear somewhere you can see?

If not: your input is hidden incorrectly (display:none, visibility:hidden), off-screen without focus styling, or covered by another element.

Second: prove label association and click target

  • Click the text, not the box. Does it toggle?
  • Does tapping on mobile work reliably?

If not: the label isn’t associated, or pointer events are being intercepted by your decorative element.

Third: prove state parity (visual vs actual)

  • Inspect the input’s checked state in devtools while toggling.
  • Submit the form and inspect the submitted payload.

If the UI shows checked but the input is unchecked (or vice versa), you have a “lying control.” Stop and fix the source of truth: the input must own state.

Fourth: forced colors and zoom

  • Try forced colors (Windows) or emulate forced colors where possible.
  • Zoom to 200% and ensure the hit area still works.

If it fails here: you’re relying on non-adaptive visuals (background images, gradients, thin outlines) or tiny hit targets.

Fifth: radio group behavior

  • Confirm all radios share a name.
  • Arrow through options and observe selection rules.

If this fails: the inputs aren’t actually radios, or your DOM structure is interfering with focus/interaction.

Joke #2 Debugging custom radios is like debugging DNS: it’s never the radio, until it is.

Practical tasks: commands, outputs, decisions

These are real tasks you can run on a workstation or CI runner to diagnose “lying controls.” Each task includes a command, sample output, what it means, and what you decide next. The goal is operational: reduce ambiguity quickly.

Task 1: Confirm inputs exist and aren’t display-none’d

cr0x@server:~$ rg -n 'display\s*:\s*none|visibility\s*:\s*hidden' src/ styles/
src/components/Choice.css:41:  display: none;

Output means: A stylesheet is using display:none on something—often the input.

Decision: Replace with a visually-hidden pattern that preserves focus/AT. If the rule targets the input, treat it as a Sev-2 bug in your UI library.

Task 2: Find div-based “checkbox” implementations

cr0x@server:~$ rg -n 'role="checkbox"|role="radio"|aria-checked' src/
src/components/LegacyToggle.tsx:17: return <div role="checkbox" aria-checked={checked} ...>

Output means: Someone is implementing checkbox semantics manually.

Decision: Audit keyboard handling and labeling. If this is a form control, schedule replacement with native inputs unless there is a hard constraint.

Task 3: Verify radio grouping by name

cr0x@server:~$ rg -n 'type="radio"' src/ | head
src/pages/Preferences.html:88: <input type="radio" name="pager" value="none">
src/pages/Preferences.html:94: <input type="radio" name="pager" value="critical">

Output means: You can spot whether name is consistent across the group.

Decision: If names differ, fix them. If names match but behavior is weird, check for JS intercepting key events or nested interactive elements.

Task 4: Check for missing labels

cr0x@server:~$ rg -n '<input[^>]+type="checkbox"|<input[^>]+type="radio"' src/ | head -n 20
src/pages/Checkout.html:211: <input type="checkbox" id="tos">

Output means: Inputs exist; now you need to ensure label association.

Decision: Confirm there is a matching <label for="tos"> or that the input is wrapped by a label. If not, add it. Don’t “fix” with click handlers.

Task 5: Catch pointer-events traps on decorative elements

cr0x@server:~$ rg -n 'pointer-events\s*:\s*auto|pointer-events\s*:\s*none' src/styles/
src/styles/controls.css:77: .choice__control { pointer-events: auto; }

Output means: Decorative elements might be intercepting clicks/taps.

Decision: Usually set decorative control to pointer-events:none and let the label handle interaction, unless you have a specific reason. Then re-test mobile tapping.

Task 6: Run axe-core accessibility checks in CI (headless)

cr0x@server:~$ npx playwright test --project=chromium --grep "@a11y"
Running 6 tests using 1 worker
✓ 6 passed (18.2s)

Output means: Your automated a11y tests passed (for what they cover).

Decision: Keep it, but don’t stop here. Add keyboard-only scripted tests for focus order and toggling; axe won’t catch everything about “feel” or forced colors.

Task 7: Validate color contrast for focus rings and states

cr0x@server:~$ node -e 'console.log("Manual check: verify focus ring color against backgrounds in design tokens")'
Manual check: verify focus ring color against backgrounds in design tokens

Output means: This is a reminder: contrast is partly measurable, partly contextual.

Decision: Ensure focus ring color meets contrast expectations against both light/dark backgrounds. If your system has theming, test both themes.

Task 8: Detect usage of background images for checkmarks

cr0x@server:~$ rg -n 'background-image|mask-image|data:image' src/styles/
src/styles/checkbox.css:19: background-image: url("check.svg");

Output means: Checkmarks are drawn via images/masks.

Decision: If you support forced colors, replace with CSS shapes (borders/background) or ensure forced-colors overrides provide visible states.

Task 9: Check for outline removal

cr0x@server:~$ rg -n 'outline\s*:\s*none|outline\s*:\s*0' src/styles/
src/styles/reset.css:12: *:focus { outline: none; }

Output means: A global reset is killing focus indicators site-wide.

Decision: Remove it, or replace with :focus-visible styling. Treat global outline removal as a reliability bug; it breaks navigation under stress (and under audits).

Task 10: Verify forms submit expected values

cr0x@server:~$ python3 - <<'PY'
from urllib.parse import urlencode
payload = {"email_alerts": "on", "pager": "critical"}
print(urlencode(payload))
PY
email_alerts=on&pager=critical

Output means: A checked checkbox submits its name with value “on” by default; radios submit selected value.

Decision: If your backend expects different values, set explicit value on checkboxes or transform server-side. Don’t invent client-side state separate from the input.

Task 11: Verify indeterminate isn’t mistaken for checked

cr0x@server:~$ node - <<'NODE'
console.log("Indeterminate is a UI state, not a submitted value. Checked controls submission; indeterminate does not.")
NODE
Indeterminate is a UI state, not a submitted value. Checked controls submission; indeterminate does not.

Output means: A reminder of a common logic bug: treating indeterminate as “true.”

Decision: Ensure your logic sets both checked and indeterminate explicitly based on child selections, and that submission logic reflects only checked.

Task 12: Smoke test focus order by running a scripted keyboard pass

cr0x@server:~$ npx playwright test -g "keyboard navigation"
Running 1 test using 1 worker
✓ 1 passed (4.9s)

Output means: Your keyboard navigation test passed. (If you don’t have one, this should fail because it doesn’t exist. That’s the point.)

Decision: Add assertions that Tab reaches the input, that Space toggles it, and that focus ring is present. Treat this like a regression test for a critical API.

Task 13: Check computed styles for forced-colors overrides (Windows CI runner)

cr0x@server:~$ node -e 'console.log("Decision point: run a Windows job to validate forced-colors snapshots; Linux/macOS runners won’t represent it.")'
Decision point: run a Windows job to validate forced-colors snapshots; Linux/macOS runners won’t represent it.

Output means: Forced colors is platform-coupled; you need the right environment.

Decision: If accessibility is in scope (it is), add at least one Windows CI lane or a manual Windows test step for releases involving controls.

Task 14: Detect accidental tabindex usage on decorative spans

cr0x@server:~$ rg -n 'tabindex=' src/components/
src/components/Choice.tsx:23: <span class="choice__control" tabindex="0"></span>

Output means: Decorative elements are being made focusable, which can disrupt tab order and confuse screen readers.

Decision: Remove tabindex from non-interactive elements. Keep focus on the native input. If you need a larger focus area, use label styling and padding.

Common mistakes: symptoms → root cause → fix

Symptom Root cause Fix
Tab skips the checkbox/radio entirely Input hidden with display:none or removed from DOM; or tabindex messed up Use a visually-hidden pattern (clip/1px) and ensure only the input is focusable
Space doesn’t toggle; page scrolls instead Not a real input; using div with click handlers; missing keydown handling Use native inputs. If you must use ARIA roles, implement full keyboard support (and accept ongoing maintenance)
Clicking label text doesn’t toggle No label association (missing for/id, or input not wrapped) Wrap input in a label or wire for correctly; remove JS click hacks
Focus ring exists but is invisible Outline removed in reset; focus ring color too low contrast; clipped by overflow Use :focus-visible styles with sufficient contrast; avoid clipping containers or add outline offset/space
Checked visual doesn’t match submitted value Visual state managed separately (class toggles) from the input’s checked Make the input the single source of truth; style via :checked selectors only
High contrast mode shows blank boxes Checkmarks drawn with images/masks; colors overridden by forced colors Use CSS backgrounds/borders and add @media (forced-colors: active) overrides with system colors
Radio group allows multiple selections Inputs don’t share the same name; or not real radios Ensure consistent name across the group; keep native radio inputs
Touch users complain “hard to click” Small hit area; checkbox box is clickable but text isn’t; padding too tight Wrap with label; add padding and spacing; consider minimum 44px tap target guidance
Screen reader announces “group” but not options clearly Missing fieldset/legend for grouped controls; or incorrect labeling Use <fieldset> and <legend> for groups; ensure each input has a label
Disabled looks disabled but still toggles Only styled as disabled; actual disabled attribute missing Set disabled on input; style :disabled states; remove JS toggles

Checklists / step-by-step plan

Step-by-step: building a reliable custom checkbox/radio component (pure CSS)

  1. Start with native HTML. Use input + label. For groups, use fieldset + legend.
  2. Decide your customization level. If accent-color solves it, stop there.
  3. Pick Pattern A or B. Prefer Pattern A (hidden input + styled sibling) for robustness.
  4. Implement visually-hidden input correctly. Use clip/1px technique; never display:none.
  5. Style state from selectors. Use :checked, :disabled, :focus-visible, :indeterminate.
  6. Make focus unmistakable. Use a visible outline with offset. Don’t rely on subtle shadows.
  7. Support forced colors. Add @media (forced-colors: active) overrides and use system colors.
  8. Verify click target size. Label wrapper, padding, and spacing should make selection easy at 200% zoom.
  9. Test keyboard-only. Tab, Shift+Tab, Space; radio arrow navigation.
  10. Test at least one screen reader path. Even a basic smoke test catches obvious labeling problems.
  11. Add regression tests. Axe checks plus a keyboard navigation scripted test.
  12. Ship with a rollback plan. If it’s a design system change, treat it like a shared library upgrade.

Release checklist (what I’d require in a production org)

  • Focus ring visible in light and dark themes
  • Keyboard pass: every control reachable; Space toggles; radios behave as a group
  • Mouse and touch pass: label text toggles; no tiny click targets
  • Forced-colors pass (Windows): checked and focus states still distinguishable
  • Form submission pass: payload matches visual state; disabled controls don’t submit
  • Error/invalid pass: error messaging is associated and visible
  • Indeterminate pass (if used): style and state logic confirmed
  • No global outline:none in shipped CSS

Operational framing: custom controls are shared infrastructure. If they break, everything downstream breaks: onboarding, checkout, settings, consent. Treat them like a core service.

FAQ

1) Can I hide the input with opacity: 0 instead of the clip technique?

Sometimes. But opacity:0 inputs still occupy layout and can create weird click areas. The clip/1px visually-hidden pattern is more predictable and widely used for accessibility.

2) Is display:none ever okay for the input?

Not if that input is the interactive control. display:none removes it from the accessibility tree and keyboard navigation. If the input is purely redundant (rare), maybe—but then you shouldn’t have it.

3) Should I use role="switch" for toggle switches?

Only if you truly need switch semantics and you know what you’re doing. For many products, a checkbox labeled “Enable X” is clearer and more compatible. A switch role increases testing burden across AT.

4) Are pseudo-elements safe for checkmarks?

Yes, if they’re purely decorative and driven by the input state (:checked + span::after). In forced colors, you may need overrides so the pseudo-element remains visible.

5) What about using SVG for the checkmark?

SVG is fine as decoration. Don’t use SVG to replace the semantic control. Also verify forced-colors behavior; some SVG fills may not adapt unless you handle it.

6) Do I need ARIA attributes on native inputs?

Usually no. Native inputs already expose checked/unchecked/disabled. Use ARIA for describing errors (aria-describedby) or grouping if you can’t use fieldset/legend. Avoid redundant ARIA that can confuse AT.

7) Why is :focus-visible recommended instead of :focus?

:focus-visible generally shows focus styling for keyboard and other non-pointer interactions while not flashing rings for mouse clicks. It’s a better default compromise. You can still fall back to :focus if needed.

8) How do I handle “required checkbox” validation accessibly?

Mark the checkbox as required (required) or validate at the group level. Provide an error message adjacent to the control and link it using aria-describedby. Visually, style :invalid or an error class on the control wrapper.

9) What’s the minimum test matrix for custom controls?

At minimum: one Chromium-based browser, one Firefox, one Safari (if you support it), keyboard-only pass, one screen reader pass, and a Windows forced-colors check if accessibility is in scope (it is).

10) If I use accent-color, do I still need all this?

You need less CSS complexity, but you still need labeling, grouping, and focus sanity. accent-color reduces the surface area for forced-colors and state mismatches, which is why it’s often the best first move.

Next steps you can actually do this week

If you run production systems, you already understand this pattern: the most dangerous failures come from interfaces that look correct while doing the wrong thing. Custom checkboxes and radios are exactly that kind of risk when built carelessly.

Practical next steps:

  1. Inventory your controls. Grep for role="checkbox", outline resets, and hidden inputs.
  2. Standardize on one honest pattern. Prefer native inputs + label wrapper + sibling styling. Write it once, reuse it everywhere.
  3. Add a keyboard navigation test. Make it fail loudly on regressions.
  4. Run a forced-colors check before shipping visual redesigns. If you can’t automate it, make it a release step.
  5. Document the failure modes. Put “don’t use display:none on inputs” in your design system rules, right next to token usage.

Do that, and your custom controls will stop lying. They’ll also stop generating the kind of low-grade, high-cost chaos that ruins sprints and quietly taxes your support team. Boring is good. Boring is reliable.

You don’t need heroics to make checkboxes accessible. You need native semantics, honest styling, and tests that match how real people operate the UI.

← Previous
MySQL vs PostgreSQL on a 1GB RAM VPS: What’s Actually Usable (and the Settings That Make It)
Next →
ZFS encryption: Strong Security Without Killing Performance

Leave a comment