29 CSS Checkboxes25 / 29
Multi-State Checkbox
One control that cycles through three discrete states on each activation — unset → yes → not-applicable — the pattern for review checklists, moderation queues and audit forms where a plain on/off isn't enough. Uses the accessible tri-state button role, not a fragile hack.
Published Updated
The code
<section class="cb-25" id="cb-25" aria-label="Multi-state review checklist">
<div class="cb-25__panel">
<div class="cb-25__row"><button type="button" class="cb-25__btn" role="checkbox" aria-checked="true" data-cycle><span class="cb-25__box" aria-hidden="true"></span></button><span class="cb-25__label">Accessibility audit</span><span class="cb-25__state" aria-hidden="true"></span></div>
<div class="cb-25__row"><button type="button" class="cb-25__btn" role="checkbox" aria-checked="mixed" data-cycle><span class="cb-25__box" aria-hidden="true"></span></button><span class="cb-25__label">Performance budget</span><span class="cb-25__state" aria-hidden="true"></span></div>
<div class="cb-25__row"><button type="button" class="cb-25__btn" role="checkbox" aria-checked="false" data-cycle><span class="cb-25__box" aria-hidden="true"></span></button><span class="cb-25__label">Legal review</span><span class="cb-25__state" aria-hidden="true"></span></div>
</div>
</section><section class="cb-25" id="cb-25" aria-label="Multi-state review checklist">
<div class="cb-25__panel">
<div class="cb-25__row"><button type="button" class="cb-25__btn" role="checkbox" aria-checked="true" data-cycle><span class="cb-25__box" aria-hidden="true"></span></button><span class="cb-25__label">Accessibility audit</span><span class="cb-25__state" aria-hidden="true"></span></div>
<div class="cb-25__row"><button type="button" class="cb-25__btn" role="checkbox" aria-checked="mixed" data-cycle><span class="cb-25__box" aria-hidden="true"></span></button><span class="cb-25__label">Performance budget</span><span class="cb-25__state" aria-hidden="true"></span></div>
<div class="cb-25__row"><button type="button" class="cb-25__btn" role="checkbox" aria-checked="false" data-cycle><span class="cb-25__box" aria-hidden="true"></span></button><span class="cb-25__label">Legal review</span><span class="cb-25__state" aria-hidden="true"></span></div>
</div>
</section>.cb-25,
.cb-25 *,
.cb-25 *::before,
.cb-25 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.cb-25 ::selection {
background: oklch(0.62 0.16 250);
color: #fff;
}
.cb-25 {
--yes: oklch(0.56 0.14 155);
--na: oklch(0.68 0.13 70);
font-family: 'Segoe UI',system-ui,sans-serif;
width: 100%;
min-height: 100vh;
padding: 40px 20px;
background: #f4f5f7;
display: flex;
align-items: center;
justify-content: center;
}
.cb-25__panel {
width: min(430px,100%);
background: #fff;
border: 1px solid #e6e8ec;
border-radius: 14px;
padding: 8px 14px;
}
.cb-25__row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 8px;
border-bottom: 1px solid #f0f1f4;
}
.cb-25__row:last-child {
border-bottom: 0;
}
.cb-25__btn {
flex: none;
padding: 0;
border: 0;
background: none;
cursor: pointer;
line-height: 0;
border-radius: 8px;
}
.cb-25__box {
position: relative;
display: block;
width: 24px;
height: 24px;
border-radius: 7px;
border: 2px solid #c3c8d1;
background: #fff;
transition: background .2s,border-color .2s;
}
.cb-25__box::before,
.cb-25__box::after {
content: "";
position: absolute;
inset: 0;
background: #fff;
opacity: 0;
transition: opacity .18s;
}
.cb-25__box::before {
-webkit-mask: no-repeat center/14px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
mask: no-repeat center/14px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
}
.cb-25__box::after {
-webkit-mask: no-repeat center/13px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='4' stroke-linecap='round'%3E%3Cpath d='M6 12h12'/%3E%3C/svg%3E");
mask: no-repeat center/13px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='4' stroke-linecap='round'%3E%3Cpath d='M6 12h12'/%3E%3C/svg%3E");
}
.cb-25__btn[aria-checked="true"] .cb-25__box {
background: var(--yes);
border-color: var(--yes);
}
.cb-25__btn[aria-checked="true"] .cb-25__box::before {
opacity: 1;
}
.cb-25__btn[aria-checked="mixed"] .cb-25__box {
background: var(--na);
border-color: var(--na);
}
.cb-25__btn[aria-checked="mixed"] .cb-25__box::after {
opacity: 1;
}
.cb-25__btn:focus-visible {
outline: 3px solid oklch(0.62 0.16 250);
outline-offset: 3px;
}
.cb-25__label {
flex: 1;
font-size: 14.5px;
color: #2a2f3a;
}
.cb-25__state {
font: 600 11px ui-monospace,Menlo,monospace;
letter-spacing: .08em;
text-transform: uppercase;
color: #9aa0aa;
}
.cb-25__btn[aria-checked="false"] ~ .cb-25__state::after {
content: "unset";
}
.cb-25__btn[aria-checked="true"] ~ .cb-25__state::after {
content: "done";
color: var(--yes);
}
.cb-25__btn[aria-checked="mixed"] ~ .cb-25__state::after {
content: "n/a";
color: var(--na);
}
@media (prefers-reduced-motion: reduce) {
.cb-25__box,
.cb-25__box::before,
.cb-25__box::after {
transition: none;
}
}.cb-25,
.cb-25 *,
.cb-25 *::before,
.cb-25 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.cb-25 ::selection {
background: oklch(0.62 0.16 250);
color: #fff;
}
.cb-25 {
--yes: oklch(0.56 0.14 155);
--na: oklch(0.68 0.13 70);
font-family: 'Segoe UI',system-ui,sans-serif;
width: 100%;
min-height: 100vh;
padding: 40px 20px;
background: #f4f5f7;
display: flex;
align-items: center;
justify-content: center;
}
.cb-25__panel {
width: min(430px,100%);
background: #fff;
border: 1px solid #e6e8ec;
border-radius: 14px;
padding: 8px 14px;
}
.cb-25__row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 8px;
border-bottom: 1px solid #f0f1f4;
}
.cb-25__row:last-child {
border-bottom: 0;
}
.cb-25__btn {
flex: none;
padding: 0;
border: 0;
background: none;
cursor: pointer;
line-height: 0;
border-radius: 8px;
}
.cb-25__box {
position: relative;
display: block;
width: 24px;
height: 24px;
border-radius: 7px;
border: 2px solid #c3c8d1;
background: #fff;
transition: background .2s,border-color .2s;
}
.cb-25__box::before,
.cb-25__box::after {
content: "";
position: absolute;
inset: 0;
background: #fff;
opacity: 0;
transition: opacity .18s;
}
.cb-25__box::before {
-webkit-mask: no-repeat center/14px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
mask: no-repeat center/14px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
}
.cb-25__box::after {
-webkit-mask: no-repeat center/13px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='4' stroke-linecap='round'%3E%3Cpath d='M6 12h12'/%3E%3C/svg%3E");
mask: no-repeat center/13px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='4' stroke-linecap='round'%3E%3Cpath d='M6 12h12'/%3E%3C/svg%3E");
}
.cb-25__btn[aria-checked="true"] .cb-25__box {
background: var(--yes);
border-color: var(--yes);
}
.cb-25__btn[aria-checked="true"] .cb-25__box::before {
opacity: 1;
}
.cb-25__btn[aria-checked="mixed"] .cb-25__box {
background: var(--na);
border-color: var(--na);
}
.cb-25__btn[aria-checked="mixed"] .cb-25__box::after {
opacity: 1;
}
.cb-25__btn:focus-visible {
outline: 3px solid oklch(0.62 0.16 250);
outline-offset: 3px;
}
.cb-25__label {
flex: 1;
font-size: 14.5px;
color: #2a2f3a;
}
.cb-25__state {
font: 600 11px ui-monospace,Menlo,monospace;
letter-spacing: .08em;
text-transform: uppercase;
color: #9aa0aa;
}
.cb-25__btn[aria-checked="false"] ~ .cb-25__state::after {
content: "unset";
}
.cb-25__btn[aria-checked="true"] ~ .cb-25__state::after {
content: "done";
color: var(--yes);
}
.cb-25__btn[aria-checked="mixed"] ~ .cb-25__state::after {
content: "n/a";
color: var(--na);
}
@media (prefers-reduced-motion: reduce) {
.cb-25__box,
.cb-25__box::before,
.cb-25__box::after {
transition: none;
}
}(function(){
var root = document.getElementById('cb-25');
if(!root) return;
var CYCLE = ['false','true','mixed'];
root.querySelectorAll('[data-cycle]').forEach(function(btn){
btn.addEventListener('click', function(){
var cur = btn.getAttribute('aria-checked') || 'false';
var next = CYCLE[(CYCLE.indexOf(cur) + 1) % CYCLE.length];
btn.setAttribute('aria-checked', next);
});
});
})();(function(){
var root = document.getElementById('cb-25');
if(!root) return;
var CYCLE = ['false','true','mixed'];
root.querySelectorAll('[data-cycle]').forEach(function(btn){
btn.addEventListener('click', function(){
var cur = btn.getAttribute('aria-checked') || 'false';
var next = CYCLE[(CYCLE.indexOf(cur) + 1) % CYCLE.length];
btn.setAttribute('aria-checked', next);
});
});
})();Here's a working CSS Checkboxe from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Multi-State Checkbox
Source: https://codefronts.com/components/css-checkboxes/multi-state-checkbox/
One control that cycles through three discrete states on each activation — unset → yes → not-applicable — the pattern for review checklists, moderation queues and audit forms where a plain on/off isn't enough. Uses the accessible tri-state button role, not a fragile hack.
## HTML
```html
<section class="cb-25" id="cb-25" aria-label="Multi-state review checklist">
<div class="cb-25__panel">
<div class="cb-25__row"><button type="button" class="cb-25__btn" role="checkbox" aria-checked="true" data-cycle><span class="cb-25__box" aria-hidden="true"></span></button><span class="cb-25__label">Accessibility audit</span><span class="cb-25__state" aria-hidden="true"></span></div>
<div class="cb-25__row"><button type="button" class="cb-25__btn" role="checkbox" aria-checked="mixed" data-cycle><span class="cb-25__box" aria-hidden="true"></span></button><span class="cb-25__label">Performance budget</span><span class="cb-25__state" aria-hidden="true"></span></div>
<div class="cb-25__row"><button type="button" class="cb-25__btn" role="checkbox" aria-checked="false" data-cycle><span class="cb-25__box" aria-hidden="true"></span></button><span class="cb-25__label">Legal review</span><span class="cb-25__state" aria-hidden="true"></span></div>
</div>
</section>
```
## CSS
```css
.cb-25,
.cb-25 *,
.cb-25 *::before,
.cb-25 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.cb-25 ::selection {
background: oklch(0.62 0.16 250);
color: #fff;
}
.cb-25 {
--yes: oklch(0.56 0.14 155);
--na: oklch(0.68 0.13 70);
font-family: 'Segoe UI',system-ui,sans-serif;
width: 100%;
min-height: 100vh;
padding: 40px 20px;
background: #f4f5f7;
display: flex;
align-items: center;
justify-content: center;
}
.cb-25__panel {
width: min(430px,100%);
background: #fff;
border: 1px solid #e6e8ec;
border-radius: 14px;
padding: 8px 14px;
}
.cb-25__row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 8px;
border-bottom: 1px solid #f0f1f4;
}
.cb-25__row:last-child {
border-bottom: 0;
}
.cb-25__btn {
flex: none;
padding: 0;
border: 0;
background: none;
cursor: pointer;
line-height: 0;
border-radius: 8px;
}
.cb-25__box {
position: relative;
display: block;
width: 24px;
height: 24px;
border-radius: 7px;
border: 2px solid #c3c8d1;
background: #fff;
transition: background .2s,border-color .2s;
}
.cb-25__box::before,
.cb-25__box::after {
content: "";
position: absolute;
inset: 0;
background: #fff;
opacity: 0;
transition: opacity .18s;
}
.cb-25__box::before {
-webkit-mask: no-repeat center/14px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
mask: no-repeat center/14px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
}
.cb-25__box::after {
-webkit-mask: no-repeat center/13px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='4' stroke-linecap='round'%3E%3Cpath d='M6 12h12'/%3E%3C/svg%3E");
mask: no-repeat center/13px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='4' stroke-linecap='round'%3E%3Cpath d='M6 12h12'/%3E%3C/svg%3E");
}
.cb-25__btn[aria-checked="true"] .cb-25__box {
background: var(--yes);
border-color: var(--yes);
}
.cb-25__btn[aria-checked="true"] .cb-25__box::before {
opacity: 1;
}
.cb-25__btn[aria-checked="mixed"] .cb-25__box {
background: var(--na);
border-color: var(--na);
}
.cb-25__btn[aria-checked="mixed"] .cb-25__box::after {
opacity: 1;
}
.cb-25__btn:focus-visible {
outline: 3px solid oklch(0.62 0.16 250);
outline-offset: 3px;
}
.cb-25__label {
flex: 1;
font-size: 14.5px;
color: #2a2f3a;
}
.cb-25__state {
font: 600 11px ui-monospace,Menlo,monospace;
letter-spacing: .08em;
text-transform: uppercase;
color: #9aa0aa;
}
.cb-25__btn[aria-checked="false"] ~ .cb-25__state::after {
content: "unset";
}
.cb-25__btn[aria-checked="true"] ~ .cb-25__state::after {
content: "done";
color: var(--yes);
}
.cb-25__btn[aria-checked="mixed"] ~ .cb-25__state::after {
content: "n/a";
color: var(--na);
}
@media (prefers-reduced-motion: reduce) {
.cb-25__box,
.cb-25__box::before,
.cb-25__box::after {
transition: none;
}
}
```
## JavaScript
```js
(function(){
var root = document.getElementById('cb-25');
if(!root) return;
var CYCLE = ['false','true','mixed'];
root.querySelectorAll('[data-cycle]').forEach(function(btn){
btn.addEventListener('click', function(){
var cur = btn.getAttribute('aria-checked') || 'false';
var next = CYCLE[(CYCLE.indexOf(cur) + 1) % CYCLE.length];
btn.setAttribute('aria-checked', next);
});
});
})();
```Here's a working CSS Checkboxe from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Multi-State Checkbox
Source: https://codefronts.com/components/css-checkboxes/multi-state-checkbox/
One control that cycles through three discrete states on each activation — unset → yes → not-applicable — the pattern for review checklists, moderation queues and audit forms where a plain on/off isn't enough. Uses the accessible tri-state button role, not a fragile hack.
## HTML
```html
<section class="cb-25" id="cb-25" aria-label="Multi-state review checklist">
<div class="cb-25__panel">
<div class="cb-25__row"><button type="button" class="cb-25__btn" role="checkbox" aria-checked="true" data-cycle><span class="cb-25__box" aria-hidden="true"></span></button><span class="cb-25__label">Accessibility audit</span><span class="cb-25__state" aria-hidden="true"></span></div>
<div class="cb-25__row"><button type="button" class="cb-25__btn" role="checkbox" aria-checked="mixed" data-cycle><span class="cb-25__box" aria-hidden="true"></span></button><span class="cb-25__label">Performance budget</span><span class="cb-25__state" aria-hidden="true"></span></div>
<div class="cb-25__row"><button type="button" class="cb-25__btn" role="checkbox" aria-checked="false" data-cycle><span class="cb-25__box" aria-hidden="true"></span></button><span class="cb-25__label">Legal review</span><span class="cb-25__state" aria-hidden="true"></span></div>
</div>
</section>
```
## CSS
```css
.cb-25,
.cb-25 *,
.cb-25 *::before,
.cb-25 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.cb-25 ::selection {
background: oklch(0.62 0.16 250);
color: #fff;
}
.cb-25 {
--yes: oklch(0.56 0.14 155);
--na: oklch(0.68 0.13 70);
font-family: 'Segoe UI',system-ui,sans-serif;
width: 100%;
min-height: 100vh;
padding: 40px 20px;
background: #f4f5f7;
display: flex;
align-items: center;
justify-content: center;
}
.cb-25__panel {
width: min(430px,100%);
background: #fff;
border: 1px solid #e6e8ec;
border-radius: 14px;
padding: 8px 14px;
}
.cb-25__row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 8px;
border-bottom: 1px solid #f0f1f4;
}
.cb-25__row:last-child {
border-bottom: 0;
}
.cb-25__btn {
flex: none;
padding: 0;
border: 0;
background: none;
cursor: pointer;
line-height: 0;
border-radius: 8px;
}
.cb-25__box {
position: relative;
display: block;
width: 24px;
height: 24px;
border-radius: 7px;
border: 2px solid #c3c8d1;
background: #fff;
transition: background .2s,border-color .2s;
}
.cb-25__box::before,
.cb-25__box::after {
content: "";
position: absolute;
inset: 0;
background: #fff;
opacity: 0;
transition: opacity .18s;
}
.cb-25__box::before {
-webkit-mask: no-repeat center/14px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
mask: no-repeat center/14px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
}
.cb-25__box::after {
-webkit-mask: no-repeat center/13px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='4' stroke-linecap='round'%3E%3Cpath d='M6 12h12'/%3E%3C/svg%3E");
mask: no-repeat center/13px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='4' stroke-linecap='round'%3E%3Cpath d='M6 12h12'/%3E%3C/svg%3E");
}
.cb-25__btn[aria-checked="true"] .cb-25__box {
background: var(--yes);
border-color: var(--yes);
}
.cb-25__btn[aria-checked="true"] .cb-25__box::before {
opacity: 1;
}
.cb-25__btn[aria-checked="mixed"] .cb-25__box {
background: var(--na);
border-color: var(--na);
}
.cb-25__btn[aria-checked="mixed"] .cb-25__box::after {
opacity: 1;
}
.cb-25__btn:focus-visible {
outline: 3px solid oklch(0.62 0.16 250);
outline-offset: 3px;
}
.cb-25__label {
flex: 1;
font-size: 14.5px;
color: #2a2f3a;
}
.cb-25__state {
font: 600 11px ui-monospace,Menlo,monospace;
letter-spacing: .08em;
text-transform: uppercase;
color: #9aa0aa;
}
.cb-25__btn[aria-checked="false"] ~ .cb-25__state::after {
content: "unset";
}
.cb-25__btn[aria-checked="true"] ~ .cb-25__state::after {
content: "done";
color: var(--yes);
}
.cb-25__btn[aria-checked="mixed"] ~ .cb-25__state::after {
content: "n/a";
color: var(--na);
}
@media (prefers-reduced-motion: reduce) {
.cb-25__box,
.cb-25__box::before,
.cb-25__box::after {
transition: none;
}
}
```
## JavaScript
```js
(function(){
var root = document.getElementById('cb-25');
if(!root) return;
var CYCLE = ['false','true','mixed'];
root.querySelectorAll('[data-cycle]').forEach(function(btn){
btn.addEventListener('click', function(){
var cur = btn.getAttribute('aria-checked') || 'false';
var next = CYCLE[(CYCLE.indexOf(cur) + 1) % CYCLE.length];
btn.setAttribute('aria-checked', next);
});
});
})();
```How this works
A native checkbox only has two states plus JS-only indeterminate, so a true three-way cycle uses a <button role="checkbox"> with aria-checked taking false, true and mixed — the standard tri-state ARIA pattern. All visuals are pure CSS keyed off [aria-checked]: each value shows a different glyph (empty / check / dash) and colour, crossfading with opacity.
The only JavaScript is a tiny click handler that advances aria-checked to the next value — no per-state classes, no tag clutter. Because it's a real button with the checkbox role, keyboard users focus it with Tab and cycle it with Space/Enter, and AT announces each state correctly.
All three glyphs share one box; only opacity/colour change, so nothing reflows as states cycle.
Make it yours
- Rename/re-map the three states (e.g. no / yes / n-a) in the handler and the CSS.
- Recolour each state independently via the
[aria-checked]rules. - Swap glyphs — each state's mark is a CSS mask icon.
- Extend to more states by lengthening the cycle array (keep ARIA semantics sensible).
Gotchas — read before shipping
- A native checkbox can't hold three states — use
role="checkbox"+aria-checked="mixed"for real tri-state semantics. - Keep the element a
<button>so it's keyboard-operable by default; don't cycle a<div>. - Update
aria-checked(not just a class) so assistive tech tracks the state.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 15.4+ | 113+ | 111+ |
Floor set by oklch() as this demo is written. The core technique itself goes back further — Chrome 49+.
role/aria-checked tri-state + attribute-selector styling are universally supported.