CORE JSC

International Technology Partnership

UI/UX

Fixing Custom Checkbox and Radio Components That Lose Native Keyboard and Screen Reader Semantics

A custom-styled checkbox or radio button looks exactly like the design calls for — a clean square or circle with its own checked state animation — but clicking it with a mouse is the only way it works. The Space bar does nothing, screen readers never announce it as a checkbox at all, and the one line of markup the design replaced was quietly doing more work than anyone realized.

Core JSC Team·November 10, 2026
AccessibilityUI/UXFormsARIAWeb Development

The Problem

A design system calls for a checkbox or radio button that looks nothing like the browser's default rendering — a specific size, a custom checked-state icon, a particular animation on toggle. The implementation replaces the native <input type="checkbox"> or <input type="radio"> with a styled <div> or <span> that toggles a class on click, and visually it matches the design perfectly. A sighted mouse user never notices anything is different. A keyboard user tabbing to it finds nothing happens on focus, pressing Space does nothing, and a screen reader user hears it announced as nothing more than plain, unlabeled text — not as a checkbox, not as checked or unchecked, not as anything interactive at all.

Why It Happens

A native checkbox input carries built-in role, state, and keyboard behavior that a styled div has none of

The browser gives <input type="checkbox"> its accessible role (checkbox), its checked/unchecked state exposed to assistive technology, its place in the tab order, and its Space-to-toggle keyboard handling entirely for free, as part of what the element fundamentally is. A <div> is, by default, none of these things — it has no role, no state, isn't focusable, and has no keyboard behavior, because a generic container element was never meant to be interactive.

Hiding the native input with display:none or opacity:0 removes it from both the tab order and the accessibility tree in some approaches

A common but flawed pattern hides the real <input> entirely and layers a purely decorative, styled element on top for the visible appearance. Depending on exactly how the input is hidden, this can strip it out of the tab order or the accessibility tree along with hiding it visually, leaving the decorative replacement — which has no semantics of its own — as the only thing a keyboard or screen reader user can actually perceive.

Click handlers alone only cover mouse and touch input, leaving keyboard interaction as an afterthought that's easy to forget entirely

Building the custom checkbox around an onClick handler on the styled wrapper makes it work correctly for exactly the input modality that was being tested during development — mouse clicks — while silently leaving keyboard activation unimplemented, since a plain non-form element's click handler never fires from a Space or Enter keypress the way a native form control's does.

The Fix

1. Keep the real input element in the DOM, focusable and in the tab order, and style only its visual presentation

input[type="checkbox"].custom-checkbox {
  position: absolute;
  opacity: 0;
  width: 1px;
  height: 1px;
  /* Visually hidden, but still focusable and in the accessibility tree —
     unlike display: none or visibility: hidden, which remove it from both. */
}
input[type="checkbox"].custom-checkbox + .checkbox-visual {
  /* The actual custom-styled square/circle, purely decorative */
}
input[type="checkbox"].custom-checkbox:checked + .checkbox-visual {
  /* Checked-state styling, driven by the real input's :checked state */
}

Visually hiding an input this way — as opposed to display: none or visibility: hidden, both of which remove an element from the tab order and the accessibility tree — keeps every piece of native behavior intact (focus, keyboard toggling, checked state, screen reader announcement) while letting a fully custom-styled sibling element carry the actual visible appearance.

2. Wrap the input and its visual sibling in a label, or associate them explicitly, so the visible element's click also toggles the real input

<label class="checkbox-wrapper">
  <input type="checkbox" class="custom-checkbox" />
  <span class="checkbox-visual" aria-hidden="true"></span>
  Subscribe to updates
</label>

A native <label> wrapping both the input and its decorative sibling makes clicking anywhere in the label — including directly on the custom-styled visual element — toggle the real input automatically, without needing a manual click handler to bridge the two, and aria-hidden="true" on the purely decorative span prevents it from being announced as redundant, meaningless content.

3. If a true <div>-based custom control is unavoidable, rebuild every piece of native behavior explicitly with ARIA and keyboard handlers

<div
  role="checkbox"
  aria-checked={checked}
  tabIndex={0}
  onClick={() => setChecked(!checked)}
  onKeyDown={(e) => {
    if (e.key === " " || e.key === "Enter") {
      e.preventDefault();
      setChecked(!checked);
    }
  }}
>
  {/* visual content */}
</div>

When a native <input> genuinely can't be used — most often inside a component library with a rigid rendering contract — every piece of what the browser gives a real checkbox for free has to be added back explicitly: the checkbox role, the live aria-checked state, inclusion in the tab order via tabIndex={0}, and a keydown handler that specifically treats both Space and Enter as activation, matching native checkbox behavior rather than inventing new keyboard conventions.

4. Verify with keyboard-only navigation and a screen reader, not just a visual review against the design

Tab to the control -> Space toggles it -> screen reader announces
"Subscribe to updates, checkbox, checked" (or "not checked")

A visual design review only confirms the control looks right; confirming it actually tabs to correctly, toggles on Space, and gets announced with its label, role, and state together requires physically testing with the keyboard alone and with a screen reader running, since none of those three things can be verified by looking at the rendered page.

Why This Works

None of these fixes change what the control looks like — the entire point is that the native semantics and the custom visual appearance are independent layers that can coexist when the real input stays present, focusable, and in the accessibility tree, with the custom styling applied to a purely decorative sibling rather than replacing the input outright. Where a true div-based control is unavoidable, deliberately reimplementing the exact same role, state, and keyboard contract a native input gets for free closes the same gap through explicit code instead.

Conclusion

A custom checkbox or radio button that only works with a mouse is missing native semantics that a plain HTML input provides automatically — role, checked state, tab order, and Space-to-toggle keyboard handling. Visually hiding the real input rather than removing it from the DOM, pairing it with a label for click delegation, and — only when a native input truly can't be used — explicitly rebuilding its ARIA role and keyboard behavior keeps the custom design fully usable by keyboard and screen reader users alike.