# Custom checkboxes that keyboards and screen readers skip

Source: https://easeweb.dev/learn/custom-checkboxes
Topics: Custom inputs > Custom checkboxes and radios keep the native input (WCAG 2.1.1, 4.1.2, 1.3.1)
The fix: Keep the real checkbox on the page and in the Tab order. Either draw the box on the input itself with appearance: none, or keep the drawn box and hide the input only from sight, over the box, drawing the tick and the focus ring from input:checked and input:focus-visible, or keep the browser's own box and colour it with accent-color.
Test with: keyboard, screen reader, mouse, forced colors, touch
References: https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html https://developer.mozilla.org/en-US/docs/Web/CSS/appearance https://developer.mozilla.org/en-US/docs/Web/CSS/accent-color https://developer.mozilla.org/en-US/docs/Web/CSS/@media/forced-colors https://www.sarasoueidan.com/blog/inclusively-hiding-and-styling-checkboxes-and-radio-buttons/ https://www.w3.org/WAI/ARIA/apg/patterns/radio/

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A sign-up form is drawn with its own checkboxes: rounded boxes with a white tick on violet, the brand's colour, because the browser's own box looks different in every browser and matches nothing else on the page. There are two: "Email me about offers", and a required "I agree to the terms" above the Create account button. The developer builds what was drawn. Each label holds the real checkbox, hidden with `display: none`, and a `span` drawn as the box, which shows the tick when the hidden checkbox is checked. Click either line and the box ticks, because clicking a label ticks its checkbox even when the checkbox can't be seen. With a mouse, the form looks right and works.

Put the mouse aside and press Tab from the email field. Focus goes straight to Create account: both boxes are skipped. There is nothing for Space to tick. A screen reader reads "I agree to the terms" as a line of text, with no checkbox and no state. Press Create account and nothing happens: the terms box is required, so the browser refuses to send the form, but it can't move focus to a box that isn't there. Chrome shows no message at all; its only trace is a line in the developer console: "An invalid form control with name='terms' is not focusable." A keyboard user can't create an account at all.

## Why it fails

`display: none` takes the checkbox out of the page entirely: out of the Tab order, and out of the accessibility tree. What is left on screen is a drawing of a checkbox. A mouse can still reach the function through the label, but the keyboard can't, which fails 2.1.1 Keyboard. And nothing that looks like a checkbox is exposed as one, with a name, a role and a checked state, which fails 4.1.2 Name, Role, Value; that the line is a choice someone can tick is shown only by the drawing, not in the code, which fails 1.3.1 Info and Relationships too.

The same drawing is built in other ways that fail the same way:

- The checkbox hidden with `visibility: hidden` or the `hidden` attribute instead: both also remove it from the Tab order and the accessibility tree, and a click on the label still ticks it.
- A `div` drawn as a checkbox, with a click handler and no input at all. Nothing can reach it from the keyboard, nothing announces it, and nothing is sent with the form unless a script adds it.
- Radio buttons drawn as option cards, such as delivery speeds, with the radios hidden. They lose Tab, and the arrow keys that move the choice within a group as well.

## Who is affected

Sign-up, checkout and settings pages are where the required choices are: agreeing to the terms, confirming an age, choosing a delivery option. When only a mouse can tick them, the form ends at that box, and nothing on screen says why.

## What automation and AI miss

A hidden input is out of reach of most checks: axe tests what is rendered, so it skips the `display: none` checkbox, and the label beside it is just text with nothing wrong in it. The failure is in what is missing, and a scan of what is there finds nothing. EaseWeb's audit reports a checkbox or radio button that is hidden while its label is shown, as `ew:hidden-native-input`. When asked to fix it, AI coding tools can add `tabindex="0"` to the label or the drawn box: Tab then stops there, but Space doesn't tick a label, and nothing announces a checkbox or its state. Swapping `display: none` for `visibility: hidden` changes nothing. Put the mouse aside and try the form.

## Before: the checkbox hidden with display: none

```html
<form action="/sign-up" method="post">
  …
  <label class="check">
    <input type="checkbox" name="offers">
    <span class="box"></span>
    Email me about offers
  </label>
  <label class="check">
    <input type="checkbox" name="terms" required>
    <span class="box"></span>
    I agree to the terms
  </label>
  <button type="submit">Create account</button>
</form>
```

```css
.check {
  display: flex;
  align-items: center;
  gap: 0.625rem;
}
.check input {
  display: none;
}
/* The box, drawn after the hidden checkbox */
.check .box {
  display: inline-grid;
  place-content: center;
  box-sizing: border-box;
  inline-size: 1.25rem;
  block-size: 1.25rem;
  border: 2px solid #6b7280;
  border-radius: 0.375rem;
}
/* The tick: two borders of a turned box */
.check .box::before {
  content: "";
  inline-size: 0.375rem;
  block-size: 0.625rem;
  border: solid #fff;
  border-width: 0 3px 3px 0;
  rotate: 45deg;
  visibility: hidden;
}
.check input:checked + .box {
  border-color: #4f46e5;
  background: #4f46e5;
}
.check input:checked + .box::before {
  visibility: visible;
}
```

A click on the label still ticks the hidden checkbox, so the box is drawn ticked, but `display: none` keeps the checkbox out of the Tab order and the accessibility tree. The keyboard can't reach it, and a screen reader doesn't know it is there.

## After: the box drawn on the input itself

```html
<form action="/sign-up" method="post">
  …
  <label class="check">
    <input type="checkbox" name="offers">
    Email me about offers
  </label>
  <label class="check">
    <input type="checkbox" name="terms" required>
    I agree to the terms
  </label>
  <button type="submit">Create account</button>
</form>
```

```css
.check {
  display: flex;
  align-items: center;
  gap: 0.625rem;
}
/* The box, drawn on the checkbox itself */
.check input {
  appearance: none;
  display: inline-grid;
  place-content: center;
  margin: 0;
  inline-size: 1.25rem;
  block-size: 1.25rem;
  border: 2px solid #6b7280;
  border-radius: 0.375rem;
}
/* The tick: two borders of a turned box */
.check input::before {
  content: "";
  inline-size: 0.375rem;
  block-size: 0.625rem;
  border: solid #fff;
  border-width: 0 3px 3px 0;
  rotate: 45deg;
  visibility: hidden;
}
.check input:checked {
  border-color: #4f46e5;
  background: #4f46e5;
}
.check input:checked::before {
  visibility: visible;
}
.check input:focus-visible {
  outline: 2px solid #4f46e5;
  outline-offset: 2px;
}
```

`appearance: none` removes the browser's drawing and leaves the checkbox itself, so the CSS draws the same rounded box and tick on it. Nothing is hidden: Tab stops on the box, Space ticks it, a screen reader announces "I agree to the terms, checkbox, not checked, required", and the browser can move focus to the box to say it is required. The tick and the ring come from the checkbox's own `:checked` and `:focus-visible`, so what is drawn can't drift from what is announced.

## After: the drawn box kept, the input over it

```html
<form action="/sign-up" method="post">
  …
  <label class="check">
    <input type="checkbox" name="offers">
    <span class="box"></span>
    Email me about offers
  </label>
  <label class="check">
    <input type="checkbox" name="terms" required>
    <span class="box"></span>
    I agree to the terms
  </label>
  <button type="submit">Create account</button>
</form>
```

```css
.check {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.625rem;
}
/* Hidden from sight only: the box's size, over the box */
.check input {
  position: absolute;
  inline-size: 1.25rem;
  block-size: 1.25rem;
  margin: 0;
  opacity: 0;
}
/* The box, drawn after the checkbox */
.check .box {
  display: inline-grid;
  place-content: center;
  box-sizing: border-box;
  inline-size: 1.25rem;
  block-size: 1.25rem;
  border: 2px solid #6b7280;
  border-radius: 0.375rem;
}
/* The tick: two borders of a turned box */
.check .box::before {
  content: "";
  inline-size: 0.375rem;
  block-size: 0.625rem;
  border: solid #fff;
  border-width: 0 3px 3px 0;
  rotate: 45deg;
  visibility: hidden;
}
.check input:checked + .box {
  border-color: #4f46e5;
  background: #4f46e5;
}
.check input:checked + .box::before {
  visibility: visible;
}
.check input:focus-visible + .box {
  outline: 2px solid #4f46e5;
  outline-offset: 2px;
}
```

The markup doesn't change. The checkbox stays on the page, in the Tab order and in the accessibility tree, made transparent instead of removed, and the box after it shows its state and its focus. This relies on two things the snippet keeps: the box must be the checkbox's next sibling, or `+ .box` matches nothing and the box never ticks or shows focus; and the checkbox must sit over the box, at its size. Here it is placed absolutely with no offsets, so it stays at the start of the label, where the box is. A screen reader on a phone finds things by where they are on screen, so a checkbox shrunk to a pixel or moved off screen is found somewhere other than the box someone touches.

## After: the browser's box in the brand colour

```html
<form action="/sign-up" method="post">
  …
  <label class="check">
    <input type="checkbox" name="offers">
    Email me about offers
  </label>
  <label class="check">
    <input type="checkbox" name="terms" required>
    I agree to the terms
  </label>
  <button type="submit">Create account</button>
</form>
```

```css
.check {
  display: flex;
  align-items: center;
  gap: 0.625rem;
}
/* The browser's own box, in the brand colour */
.check input {
  margin: 0;
  inline-size: 1.25rem;
  block-size: 1.25rem;
  accent-color: #4f46e5;
}
```

The smallest change: delete the drawn box and colour the browser's checkbox with `accent-color`. The browser draws the box, the tick, the focus ring and their look in a contrast theme, so it passes everything here with nothing to maintain. It changes the look: only the colour is yours. The shape, the corner radius, the border and the tick are the browser's, and they differ between Chrome, Firefox and Safari, and between operating systems. Browsers also keep the tick legible against the colour you give it, except Chrome for Android and Safari before 26.2.

## Which option to use

All three pass. Draw the box on the input itself unless you have a reason not to: it is one element, so nothing is hidden, there is no second element to place over the box or keep beside it, and the browser's focus, its touch position and its form behaviour stay with the box people see. That is the reason to recommend it for nearly every custom checkbox. Keep the drawn box with the input over it when the box has to hold what an input can't, such as an icon or a tick drawn in SVG, or when the component's markup can't change; then the box has to stay right after the checkbox, and the checkbox over the box. Use `accent-color` when the brand needs only its colour on the box, or as the first step on a large site: it removes the failure everywhere at once, and the drawn box can follow.

## Implementation decisions

Radio buttons take the same three fixes. Draw the circle on the input with `appearance: none` and `border-radius: 50%`, with its dot drawn from `:checked`; or keep a drawn circle or card after a radio hidden only from sight; or colour the browser's radio with `accent-color`. Put each group in a `fieldset` with a `legend`, and give its radios one `name`: Tab then stops on the group once, and the arrow keys move the choice, which is what a group of hidden radios loses. For option cards, put the whole card inside the label, so a click anywhere on it chooses it, and keep the radio's circle visible in the card.

In a Windows contrast theme, the browser replaces fills with the page's colour and removes box shadows. The tick here is drawn with borders, which the browser redraws in a colour from the user's palette, so a ticked box keeps its tick even though its violet fill is gone. A tick or a dot drawn as a filled shape, with `background-color` and a `clip-path` or a mask, disappears with the fill: give it `background-color: CanvasText` inside `@media (forced-colors: active)`. Draw the focus ring with `outline`, not `box-shadow`, for the same reason.

Keep `required` on the real checkbox. With the checkbox on the page, the browser can move focus to it and say what is missing when the form is sent; with it hidden, there is nowhere to put the message. If the form shows its own error messages, tie them to the checkbox, not to the drawn box.

Component libraries and themes often ship the hidden input. Check the rendered markup, not the component's name: a checkbox with `display: none` or `hidden` beside a styled label fails, whatever the component is called. A menu opened by a hidden checkbox and its label, the CSS-only "checkbox hack", fails the same way; build it as a button that says whether it is open, as [show and hide buttons that don't say whether they are open](/learn/show-hide-buttons) describes.

## Verify the fix

1. Put the mouse aside. (In Safari on a Mac, first turn on "Press Tab to highlight each item on a webpage" in its Advanced settings, or Tab skips checkboxes everywhere.) Tab through the form: every checkbox, and each radio group once, is reached in the order shown, with a visible focus ring; Space ticks and unticks each box, and the arrow keys move the choice in a radio group.
2. Leave the required box empty and send the form from the keyboard: focus moves to the box and its message is shown. Tick it with Space and send again: the form goes.
3. With a screen reader, move through the form: each box is announced with its label, checkbox or radio button, and its state, the terms box as required, and each change of state is announced.
4. With a Windows contrast theme on, or with forced colors emulated, a ticked box can be told from an empty one, a chosen radio from the others, and the focus ring shows.
5. On a phone with its screen reader on, touch each drawn box: the checkbox is found there, and a double tap ticks it.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the shared form components or the theme change.

## Limits

This is a starting pattern, not a guarantee for every application. The CSS is a sketch: check the size of the box, the contrast of its border and the focus ring against your own palette. A rebuilt checkbox with ARIA and a wrong state is a different failure, covered in [invalid ARIA that misleads screen readers](/learn/valid-aria); a group of options with no question tied to it is covered in [radio buttons and checkboxes without a group name](/learn/field-groups); and a checkbox drawn as a switch in [toggle switches that don't say whether they are on](/learn/toggle-switches). Test the whole form with a keyboard, a screen reader and a contrast theme.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
