CSS Custom Checkboxes
Fully styled checkboxes using appearance:none and :checked.
Custom checkboxes in CSS start with appearance: none, which removes the box the operating system draws and hands you an empty inline element to style however you like. The input is still a real checkbox underneath. It focuses, it toggles with the space bar, it submits with the form, and a screen reader still announces it as a checkbox. Only the paint job changes.
The tick itself is not an image or a font icon. It is a small rectangle with two of its four borders removed, rotated 45 degrees so the remaining corner reads as a check mark. Two more versions follow: a to-do list where ticking an item strikes its text through, and a selectable card where the whole tile is the target rather than a 20 pixel square.
Styled checkbox with a drawn tick
HTML
<div class="check-group">
<label class="check-label"><input type="checkbox" checked> Subscribe to newsletter</label>
<label class="check-label"><input type="checkbox"> Enable notifications</label>
<label class="check-label"><input type="checkbox" checked> Agree to terms</label>
</div>
CSS
.check-group {
display: flex;
flex-direction: column;
gap: .5rem;
}
.check-label {
display: inline-flex;
align-items: center;
gap: .65rem;
margin: .25rem 0;
font-size: .95rem;
cursor: pointer;
user-select: none;
}
/* appearance: none clears the native box, position: relative holds the tick */
input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
position: relative;
width: 20px;
height: 20px;
flex-shrink: 0;
border: 2px solid #2a2a2d;
border-radius: 5px;
cursor: pointer;
transition: border-color .2s, background .2s;
}
input[type="checkbox"]:checked {
background: #b8ff57;
border-color: #b8ff57;
}
/* The tick is a bordered box with two sides removed, then rotated */
input[type="checkbox"]:checked::after {
content: "";
position: absolute;
top: 2px;
left: 5px;
width: 6px;
height: 10px;
border: 2px solid #0c0c0d;
border-top: none;
border-left: none;
transform: rotate(45deg);
}
Other ways to build it
To-do list that strikes through completed items
Putting the label text in its own span makes it selectable with the adjacent sibling combinator, so input:checked + .todo-text can strike it through and dim it. No relational selector is needed here, because the text comes immediately after the input inside the same label. Reach for :has() only when the thing you want to style sits before the input or wraps it, as in the card below.
HTML
<label class="check-label todo-label">
<input type="checkbox" checked>
<span class="todo-text">Write the migration script</span>
</label>
CSS
.todo-text {
transition: color .2s;
}
.todo-label input:checked + .todo-text {
text-decoration: line-through;
text-decoration-thickness: 2px;
color: #88888f;
}
.todo-label input:focus-visible {
outline: 2px solid #b8ff57;
outline-offset: 2px;
}
Checkbox as a selectable card
A 20 pixel square is a small target, and WCAG 2.2 asks for at least 24 by 24. Wrapping the input in a padded label turns the whole tile into the target without changing the size of the box, because a click anywhere on a label toggles the input inside it. :has(input:checked) then styles the card from the state of the checkbox, and :has(input:focus-visible) moves the focus ring out to the tile edge where it is actually visible. Tab between the two cards and press space.
HTML
<label class="check-card">
<input type="checkbox" checked>
<span class="check-card-body">
<span class="check-card-title">Priority delivery</span>
<span class="check-card-note">Arrives within two days</span>
</span>
</label>
CSS
.check-card {
display: flex;
align-items: flex-start;
gap: .75rem;
width: 220px;
padding: 1rem;
border: 2px solid #2a2a2d;
border-radius: 10px;
background: #1c1c1e;
cursor: pointer;
transition: border-color .2s, background .2s;
}
.check-card:has(input:checked) {
border-color: #b8ff57;
background: rgba(184, 255, 87, .1);
}
/* the ring belongs on the tile, not on the 20px box inside it */
.check-card:has(input:focus-visible) {
outline: 2px solid #b8ff57;
outline-offset: 3px;
}
.check-card input {
margin: 1px 0 0;
}
.check-card-body {
display: flex;
flex-direction: column;
gap: .15rem;
}
.check-card-note {
font-size: .8rem;
color: #88888f;
}
How it works
appearance:none strips the browser default checkbox styling and leaves a blank slate. The ::after pseudo-element draws the checkmark using a rotated bordered box. The :checked selector switches the background and border color, and shows the ::after checkmark.
appearance: none is the whole reason this pattern exists. Before it, a checkbox was a replaced element rendered by the platform, and CSS could change almost nothing about it. With appearance removed, the input becomes an ordinary box that honours width, height, border, border-radius and background like a <div> would. The -webkit-appearance: none line alongside it covers Safari before 15.4, which shipped the unprefixed property late.
The tick is built from borders. A 6 by 10 pixel box with border-top: none and border-left: none leaves an L shape made of the right and bottom edges. Rotating that L by 45 degrees stands it up as a check mark. Changing the width and height changes the proportions of the two strokes, and the top and left offsets are what center it inside the box. Those four numbers are usually what needs adjusting when the checkbox size changes.
Generated content on an <input> sits outside what the CSS specification promises, because an input is a replaced element and replaced elements are not supposed to have ::before or ::after boxes. In practice Chrome, Firefox and Safari all render them once appearance: none has taken the native rendering away, and the pattern is common enough that it is safe to rely on. If that makes you uneasy, put the tick in a sibling span and select it with input:checked + span instead.
The label wraps the input rather than pointing at it with for. Both are valid, and wrapping means no id has to be invented for every checkbox on the page. What matters is that one of the two is present. A checkbox with no associated label has no accessible name, and a screen reader announces it as an unlabelled checkbox with no way to tell what it controls.
The same appearance: none reset is what makes a toggle switch, a custom radio button and a styled select possible. Each one removes the native widget and rebuilds it, and each one inherits the same responsibility to put back the focus and state cues the native control had for free.
CSS properties used
appearancenoneremoves the browser's native checkbox rendering. Everything else on this page depends on it having run first.:checked- Matches the box when it is ticked. Drives both the background swap and the appearance of the
::aftertick. ::after- Draws the tick. A partially bordered box, rotated, rather than a character or an image.
transformrotate(45deg)turns the L shape made of two borders into a check mark.border- Removing
border-topandborder-leftfrom a small box leaves exactly the two strokes a tick needs. :has()- Lets a wrapper react to the checkbox inside it, which is how the card variant highlights the whole tile.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
appearance | 83 | 80 | 15.4 | 83 |
:checked | 4 | 3.5 | 3.2 | 12 |
::after | 4 | 2 | 3.1 | 12 |
transform | 4 | 3.5 | 3.1 | 12 |
:focus-visible | 86 | 4 | 15.4 | 86 |
:has() | 105 | 121 | 15.4 | 105 |
Figures are from Can I Use. The unprefixed appearance property is recent, which is why the -webkit-appearance: none line stays in the stylesheet: Safari needed it until 15.4 and WebKit still accepts it. A browser that ignores both lines shows the native checkbox, which is unstyled but fully working, so this degrades safely. :has() is only used by the card variant, and without it the card simply stops highlighting while the checkbox inside it keeps working.
Accessibility notes
Nothing here is hidden, and that is the reason this approach is preferable to the older technique of covering the input with a styled span. The real checkbox is still in the page, still in the tab order, and still announced with its checked state, so keyboard and screen reader behavior come from the browser rather than from anything the stylesheet has to reimplement.
The focus ring is the one thing appearance: none puts at risk. Some browsers draw the default focus indicator against the native widget, and removing that widget can make the ring faint or oddly placed. Add an explicit input:focus-visible outline with an offset so it clears the rounded corner. The card variant below forwards the ring to the whole tile with :has(input:focus-visible), since the 20 pixel box inside a large card is a confusing place to show focus.
The demo relies on color and the tick together, not color alone. That matters because a checked and unchecked box distinguished only by fill can be indistinguishable to someone with low vision or a color vision difference, especially at 20 pixels. Keep the tick, and keep enough contrast between the border and the background that an unchecked empty box is still clearly a box.
Target size is the other common failing. A 20 by 20 pixel checkbox is below the 24 by 24 minimum that WCAG 2.2 asks for. Wrapping the input in a label that is padded out to a larger clickable area fixes it without changing the visual size of the box, because clicking the label toggles the input. The card variant takes that to its logical end by making the whole tile the target.
What you can build with it
- Terms and consent rows. One checkbox with a long label, where the whole line has to be clickable and the state has to be obvious.
- Filter lists in a sidebar. A column of options where a native checkbox would clash with the rest of the interface.
- To-do and task lists. Ticking an item strikes its text through with a sibling selector, no script involved.
- Plan and add-on pickers. The card variant turns each option into a large tile with a title and supporting text.
- Settings pages that also use switches. Checkboxes for things that are saved on submit, and a toggle switch for things that apply immediately.
Mistakes worth avoiding
- Leaving
-webkit-appearance: noneout. Safari before 15.4 keeps drawing the native box on top of your styling, which looks like the CSS was ignored. - Forgetting
position: relativeon the input. The absolutely positioned::afterthen anchors to the nearest positioned ancestor instead, and the tick appears somewhere else on the page entirely. - Skipping an explicit focus style.
appearance: nonecan weaken or misplace the default ring, and the control ends up with no visible keyboard focus at all. - Sizing the box without moving the tick. The
topandleftoffsets on::afterare absolute pixel values, so a 24 pixel checkbox with a tick positioned for a 20 pixel one has the tick sitting off center. - Leaving
flex-shrink: 0off the input. Inside a flex row with a long label, the box compresses into a narrow rectangle as the text grows, and the tick ends up hanging over the edge.
Frequently asked questions
Why is my custom checkbox not showing the tick?
position: relative, so the absolutely positioned ::after is anchoring somewhere else, or appearance: none has not applied and the native widget is still being drawn over the top. Check both, and confirm the -webkit-appearance line is present for older Safari.Can I style a checkbox without appearance: none?
display: none.How do I make a checkbox bigger?
width and height on the input, then adjust the tick. The ::after box needs its width, height, top and left scaled by the same factor, otherwise the tick stays the old size and sits off center. Setting all six values in em and changing only the font size is a way to keep them in step.Can CSS show a checkbox in an indeterminate state?
:indeterminate, but it cannot put a checkbox into that state. Indeterminate is a JavaScript property with no matching HTML attribute, so a parent checkbox that shows a dash for a partly selected group needs a line of script to set it, even though the styling is pure CSS.Does the checkbox still submit with the form?
<input type="checkbox"> with a different paint job, so give it a name and a value and it behaves like any other. Unchecked boxes are left out of the submission entirely, which is standard HTML rather than anything the styling causes.