Checkbox and label markup that CSS can style
Everything starts with one input and one label, tied together by nesting or by a matching for and id. Get that right before touching any property.
Nesting is the simplest. Put the input inside the label and you don’t need an id at all, and the label text becomes part of the click area. Keeping the two apart gives you more layout freedom, but the id has to be unique on the page, or a click toggles the wrong box.
<label class="field">
<input type="checkbox" name="updates">
Email me product updates
</label>
A bare input gives screen readers nothing to announce and gives sighted users a very small target. Pairing every control with a visible label is the baseline rule behind accessible forms.
Native widgets render with the operating system theme, and MDN Web Docs notes that in this default state they have limited, if any, features that CSS can style. Width and height still work though. MDN’s own accent-color demo sets a checkbox to 40px by 40px and centers it against its label with flex and align-items.
transform: scale() enlarges the painted box but leaves the layout box alone, so neighbors overlap unless you add margin. Sizing with width and height avoids that.
.field {
display: flex;
align-items: center;
gap: 0.5rem;
}
.field input[type="checkbox"] {
width: 1.5rem;
height: 1.5rem;
}
WCAG 2.2 criterion 2.5.8 sets a minimum pointer target of 24 by 24 CSS pixels (W3C). A native checkbox at its default size falls under the user agent control exception. A box you resize yourself does not. At the default 16px root size, 1.5rem equals 24px.
How accent-color recolors a native checkbox
Write accent-color: #0f766e; and the checked state of the native control takes that color. The box keeps its native shape, size and behavior, and the browser picks the checkmark color itself.
Per MDN, supporting browsers apply the property to checkbox, radio, range and progress elements. The value is either auto, which uses the platform accent, or any color. It’s inherited, so one rule on :root reaches every control on the page.
MDN’s browser-compat-data lists the first supporting versions like this:
| Browser | First version | Status |
|---|
| Chrome | 93 | Supported |
| Edge | 93 | Supported |
| Firefox | 92 | Supported |
| Safari | 15.4 | Partial through 26.1, full from 26.2 |
The partial entries are about legibility. Safari before 26.2 and Chrome for Android do not maintain minimum contrast for the control, and MDN’s September 2026 revision still labels accent-color as limited availability rather than Baseline.
Most browsers adjust the checkmark so it stays readable. Those two don’t. Contrast between the checked fill and the page background is your job either way, so run your brand color through a color contrast check before you ship it.
Dark surfaces need attention too. CSS-Tricks documents that accent-color respects the color scheme, so pair it with color-scheme and swap the value when your brand color goes dim on dark backgrounds.
:root {
color-scheme: light dark;
accent-color: #0f766e;
}
@media (prefers-color-scheme: dark) {
:root { accent-color: #2dd4bf; }
}
It stops short in a few places. You get no control over box shape, border style or the checkmark glyph, and the unchecked box keeps the native look. Safari 15.4 through 26.1 and Chrome for Android also don’t guarantee a legible checkmark contrast.
If only the brand color matters, stop here. I’d do that for most internal forms. The extra work in the next section buys design freedom, not better behavior.
How to build a custom checkbox with appearance: none
Set appearance: none on the input, then draw your own box and checkmark with a border, a background and a pseudo-element. The real checkbox stays in the DOM, so keyboard behavior, form submission and label clicks keep working.
Build it in this order.
- Pair the input with a label, as covered above.
- Set
appearance: none on the input. - Give the empty box a width, height, border, border-radius and background so it is visible.
- Draw the checkmark in
::before and hide it with transform: scale(0). - Reveal it with
:checked::before set to scale(1). - Add a
:focus-visible outline and a :disabled style.
input[type="checkbox"] {
appearance: none;
display: grid;
place-content: center;
width: 1.5rem;
height: 1.5rem;
border: 2px solid #475569;
border-radius: 0.25rem;
}
input[type="checkbox"]::before {
content: "";
width: 0.75rem;
height: 0.75rem;
background: #0f766e;
transform: scale(0);
transition: transform 120ms ease-in-out;
}
input[type="checkbox"]:checked::before {
transform: scale(1);
}
input[type="checkbox"]:focus-visible {
outline: 2px solid #0f766e;
outline-offset: 2px;
}
input[type="checkbox"]:disabled {
border-color: #94a3b8;
}
Removing the native rendering
MDN describes appearance: none as giving the widget a primitive look that CSS can style while native functionality stays intact. It also warns that some widgets disappear completely, and that a hidden checkbox still toggles when its label is clicked.
So a checkbox with appearance none and no other styling is invisible but live. Draw the box in the same rule that removes the native one.
MDN marks unprefixed appearance as Baseline since March 2022 and widely available since September 2024. Keep -webkit-appearance only when your support list reaches back to Safari versions older than 15.4. That’s a cross-browser compatibility call, not a default.
Drawing the checkmark
For a plain tick, a pseudo-element is the simplest route. A shape or border-built tick in ::before needs no extra file and custom properties recolor it, though the geometry takes some hand-tuning.
An SVG background image works too, with background-image: url(checkbox.svg) on the checked state. It stays crisp at any size, but the color is baked into the file. mask-image on a pseudo-element gets around that, since one background-color value tints the icon.
When the checkmark has to match an existing icon set, drawing it as an SVG keeps stroke weight consistent with the rest of the interface.
Which checkbox states need their own CSS?
Checked, disabled, hover, focus-visible and indeterminate all need rules. Skip any of them and the control looks broken in that state.
:checked matches a box whose checked state is true, whether it came from the checked attribute or a click.:disabled matches controls with the disabled attribute, which can’t take focus or clicks.:hover matches while a pointer is over the element. Touch screens have no hover, so never rely on it alone.:focus-visible matches when the browser decides a focus ring should show, typically for keyboard focus.:indeterminate matches a checkbox whose indeterminate property is true.
Indeterminate is the odd one, and the one people forget. MDN states it cannot be set with an HTML attribute, only through the indeterminate property in JavaScript, which makes it the one state CSS alone cannot trigger.
document.querySelector("#select-all").indeterminate = true;
input[type="checkbox"]:indeterminate::before {
width: 0.75rem;
height: 0.125rem;
transform: scale(1);
}
The state is visual only. CSS-Tricks points out that it masks the checked value without replacing it, so the form still submits based on checked.
Keep the palette in custom properties so checked, hover and disabled all pull from one set of values. A short transition on the checkmark is the sort of micro-interactions users register without noticing. Wrap it in prefers-reduced-motion: reduce and set transition: none there.
Disabled styling gets more freedom than the rest. W3C’s WCAG 2.2 guidance exempts inactive components from the 1.4.11 contrast rule, so a faded disabled box is acceptable.
Toggle switches and parent styling without JavaScript
Place the input before the element you want to restyle, then select that element with :checked and the adjacent sibling combinator. To restyle a wrapper or a whole card, use :has() instead. Neither needs a script.
Designs vary a lot, but every CSS toggle switch comes down to a track drawn on the input and a thumb that slides when the box is checked.
input.toggle {
appearance: none;
position: relative;
width: 3rem;
height: 1.75rem;
border-radius: 999px;
background: #64748b;
transition: background-color 150ms;
}
input.toggle::before {
content: "";
position: absolute;
top: 0.125rem;
left: 0.125rem;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: #fff;
transition: transform 150ms;
}
input.toggle:checked { background: #0f766e; }
input.toggle:checked::before { transform: translateX(1.25rem); }
The thumb travels 1.25rem. That’s a 3rem track, minus the 1.5rem thumb, minus 0.125rem of gap on each side.
Add role="switch" when the control applies a setting immediately, such as dark mode. The ARIA switch role makes screen readers announce on and off instead of checked and unchecked.
Sibling combinators only look forward. A target that sits before the input in the DOM can’t be reached with + or ~, so reorder the markup or reach it with :has().
Reach and support, per MDN’s browser-compat-data and the WebKit blog:
| Technique | Reaches | Support |
|---|
| Sibling combinators (+, ~) | Later siblings of the input | Long-established |
| :has() | Any element that contains the input | Chrome and Edge 105, Firefox 121, Safari 15.4 |
| switch attribute | The input itself, drawn as a native switch | Safari 17.4 and later |
.option:has(input:checked) {
border-color: #0f766e;
background: #f0fdfa;
}
A browser that doesn’t know the switch attribute treats the input as a plain checkbox, according to the WebKit blog. Pair it with appearance: none and you control the look completely.
In Safari the attribute also maps the input to the ARIA switch role, so screen readers announce a switch, per the same WebKit post.
What makes a custom checkbox accessible?
The real input has to stay focusable and in the accessibility tree, every state needs a visible indicator, and those indicators have to hold up under contrast and forced-colors checks.
A few numbers and behaviors are worth testing against.
- The box border and the checked indicator need 3:1 against adjacent colors (W3C, WCAG 2.2 criterion 1.4.11).
- Every keyboard-operable control needs a visible focus indicator at Level AA (W3C, criterion 2.4.7).
- Tab moves focus to the checkbox and Space toggles it (WAI-ARIA Authoring Practices Guide).
W3C pairs 2.4.7 with 1.4.11, so the 3:1 rule covers your focus ring too.
One exception explains why custom builds carry more risk. Criterion 1.4.11 exempts components whose appearance is determined by the user agent and not modified by the author. Restyle the box with appearance: none and that exemption ends.
You can leave the input visible with appearance: none, as in the build above, or hide it visually and draw the box on the label.
Avoid display: none for the hidden version. The ally.js documentation lists it as removing an element from both the accessibility tree and keyboard navigation, so the custom box could never take focus.
.sr-input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.sr-input:focus-visible + label::before {
outline: 2px solid #0f766e;
outline-offset: 2px;
}
Replacing the input with a div that carries role="checkbox" is the obvious route and the wrong one. The APG’s div-based example needs tabindex, a role and an aria-checked value kept in sync with the visuals, all things the native input already handles.
Forced-colors mode swaps your palette for the user’s. MDN documents box-shadow and text-shadow forced to none, and background-image forced to none for values that are not url-based.
Url-based images stay. Early drafts of the spec reverted background-image on user input controls, but the CSS Working Group dropped that special case in 2020, and the current css-color-adjust spec only removes images without a url(). A url-based SVG checkmark can still clash with the forced palette, so test it before you rely on it.
Draw the mark with a system color keyword instead. A system color in your CSS wins over the forced value.
@media (forced-colors: active) {
input[type="checkbox"] { border-color: ButtonText; }
input[type="checkbox"]::before { background: CanvasText; }
input[type="checkbox"]:focus-visible { outline-color: Highlight; }
}
Before release, run the control through a web accessibility checklist. Then use it with the keyboard alone, and with NVDA or VoiceOver.
Native or custom, and which method fits your project
Pick the cheapest method that meets the brief. accent-color covers it when only the brand color matters. Reach for appearance: none when shape or icon is a requirement, and use a hidden input with a drawn label only when the layout can’t be built on the input itself.
| Method | Control | Effort | Main risk |
|---|
| accent-color | Checked color only | Low | Unchecked box stays native |
| appearance: none | Shape, border, icon, states | Medium | Contrast and focus ring become your job |
| Hidden input plus drawn label | Full layout freedom | High | Focus and forced colors need manual repair |
A settings page with ten toggles rarely needs more than accent-color. A survey with icon checkboxes calls for appearance none. Animated option tiles where the whole card is the target are the case for a hidden input with a label.
Framework shortcuts
Tailwind CSS exposes accent-color as accent-* utilities, such as accent-pink-500. Its docs add a caveat: the opacity modifier, as in accent-purple-500/25, has limited browser support and works only in Firefox.
For custom boxes, the Tailwind forms plugin, @tailwindcss/forms, resets form controls so utilities can style them. Per its README, a text color utility such as text-pink-500 changes the color of a checkbox.
Bootstrap’s checkbox markup takes the class route. form-check goes on the wrapper, form-check-input on the input, form-check-label on the label, and form-switch turns it into a toggle. The docs also show an indeterminate example, which still needs the JavaScript property.
Where CSS checkboxes break
They break where styling ends and behavior begins, and in a few support gaps. Each of these has a workaround, but none of them goes away.
- CSS reads the checked state and never writes it. A parent box that checks its children runs on script, and the APG models the tri-state case with aria-checked set to mixed.
- A browser below the
:has() thresholds above drops the entire rule, so keep a working baseline and layer the parent styling on top. - MDN notes that some browsers return a fixed value for
auto to limit fingerprinting, so don’t design around matching the operating system accent. - MDN lists a
base value for appearance in the spec but says no browser supports it yet (October 2026 revision). A checkbox is either native or fully yours, with no halfway option. - The @tailwindcss/forms README warns that its default approach may be too heavy-handed for an existing project, and its class strategy limits the styling to elements that carry the generated form-* classes, such as
form-checkbox.
@supports selector(:has(*)) {
.option:has(input:checked) {
background: #f0fdfa;
}
}
Skip customization altogether when the form is internal, when the brand color is the only requirement, or when nobody will own cross-browser testing. accent-color covers all of those.
CSS Checkboxes FAQ
How do you make a checkbox round?
Remove the native rendering with appearance: none, then set border-radius: 50% on the input. Give the ::before checkmark the same radius so the fill follows the curve.
How do you style a group of checkboxes?
Wrap them in a fieldset with a legend, which names the group for screen readers. Give every input one shared class, lay them out with flex or grid and a gap, and drive colors from one set of custom properties.
Can CSS check a checkbox by default?
No. The HTML checked attribute sets the default state, and CSS only reads it through :checked.
A pre-checked box has to come from the markup or from script.
Is styling a checkbox different from styling a radio button?
Mostly no. The same appearance, :checked and accent-color rules apply to both, and a round shape is the usual radio default.
The difference is behavior. Radios that share a name attribute allow one selection, and clicking a checked radio does not uncheck it.
How to Test a Custom Checkbox Before Release
Start with the keyboard, move to forced-colors emulation, and finish with a screen reader. That order runs from cheapest to most expensive.
- Keyboard only, with Tab to focus and Space to toggle
- Forced-colors emulation in the DevTools Rendering panel
- NVDA on Windows, VoiceOver on macOS
Keyboard failures block operation and take two minutes to find. Screen readers go last, because they confirm names and states only after focus and visibility work.
Then check the support floor. Chrome 93 through 104 and Firefox 92 through 120 run accent-color without :has(), so parent highlighting needs the sibling fallback there.
The same passes apply to every other control in a form, which makes the guide to CSS forms the next stop.