Toggle switch
A switch built on a real checkbox, with sizes, labels and a disabled state.
<div class="stack">
<label class="switch">
<input type="checkbox" checked>
<span class="track" aria-hidden="true"><span class="knob"></span></span>
<span class="text">
<strong>Email notifications</strong>
<span>A summary when a deploy finishes.</span>
</span>
</label>
<label class="switch">
<input type="checkbox">
<span class="track" aria-hidden="true"><span class="knob"></span></span>
<span class="text">
<strong>Weekly digest</strong>
<span>Every Monday morning, in your timezone.</span>
</span>
</label>
<label class="switch">
<input type="checkbox" disabled>
<span class="track" aria-hidden="true"><span class="knob"></span></span>
<span class="text">
<strong>SMS alerts</strong>
<span>Available on the Agency plan.</span>
</span>
</label>
<hr>
<div class="row">
<label class="switch sm inline">
<input type="checkbox" checked>
<span class="track" aria-hidden="true"><span class="knob"></span></span>
<span class="text"><strong>Small</strong></span>
</label>
<label class="switch inline">
<input type="checkbox" checked>
<span class="track" aria-hidden="true"><span class="knob"></span></span>
<span class="text"><strong>Default</strong></span>
</label>
<label class="switch lg inline">
<input type="checkbox" checked>
<span class="track" aria-hidden="true"><span class="knob"></span></span>
<span class="text"><strong>Large</strong></span>
</label>
</div>
</div>* { box-sizing: border-box; margin: 0; }
body { font: 16px/1.6 system-ui, -apple-system, sans-serif; padding: 24px; background: #fff; }
.stack { max-width: 460px; margin: 0 auto; display: grid; gap: 18px; }
hr { border: 0; border-top: 1px solid #eef1f6; }
.row { display: flex; gap: 22px; align-items: center; flex-wrap: wrap; }
/* ============ switch ============ */
.switch {
--w: 42px;
--h: 24px;
--pad: 3px;
display: flex;
align-items: flex-start;
gap: 12px;
cursor: pointer;
}
.switch.sm { --w: 34px; --h: 20px; --pad: 2px; }
.switch.lg { --w: 52px; --h: 30px; --pad: 4px; }
/* the real control stays in the page, visually hidden but focusable */
.switch input {
position: absolute;
width: 1px; height: 1px;
opacity: 0;
margin: 0;
}
.track {
flex-shrink: 0;
width: var(--w);
height: var(--h);
padding: var(--pad);
border-radius: 999px;
background: #c9d3e2;
transition: background .18s ease;
}
.knob {
display: block;
width: calc(var(--h) - var(--pad) * 2);
height: calc(var(--h) - var(--pad) * 2);
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(22,32,46,.3);
transition: transform .18s ease;
}
/* state comes from the checkbox, so the visual can never disagree with it */
.switch input:checked + .track { background: #3b4fe4; }
.switch input:checked + .track .knob { transform: translateX(calc(var(--w) - var(--h))); }
.switch input:focus-visible + .track { outline: 2px solid #3b4fe4; outline-offset: 2px; }
.switch input:disabled + .track { background: #e2e7ef; }
.switch input:disabled + .track .knob { box-shadow: none; }
.switch:has(input:disabled) { cursor: not-allowed; opacity: .6; }
.text strong { display: block; font-size: .93rem; font-weight: 500; color: #16202e; }
.text span { display: block; font-size: .84rem; color: #8593ab; margin-top: 1px; }
.inline .text strong { font-size: .88rem; }
@media (prefers-reduced-motion: reduce) {
.track, .knob { transition: none; }
}How it works
It is a checkbox. The input is visually hidden but present and focusable, so Tab reaches it, Space toggles it, screen readers announce "checkbox, checked", and it submits with the form. None of that has to be built.
Hidden with opacity, not display: none. display: none and visibility: hidden both remove the element from the accessibility tree and from the tab order, which would defeat the entire approach. A 1×1 transparent input stays real.
The whole thing is a label. Wrapping the input, the track and the text in one <label> means clicking anywhere — including the description — toggles the switch. No for attribute and no generated ids needed.
Sizes come from three custom properties. The knob size and its travel distance are both calculated from them, so a new size is one line and nothing has to be measured by hand.
The state is driven by :checked. The visual can never drift out of step with the control, which is the failure mode of switches built from divs and a class.
:has() for the disabled cursor. .switch:has(input:disabled) styles the label based on its child's state — something that previously needed a class on the parent.
Accessibility notes
The track and knob are aria-hidden: they are a picture of the checkbox's state, and announcing them would duplicate what the input already says.
The focus ring is on the track via input:focus-visible + .track, because the real input is one pixel across and its own outline would be invisible.
disabled removes the control from the tab order entirely. If the reason it is unavailable matters — "Available on the Agency plan" — consider aria-disabled="true" instead, which keeps it focusable so the explanation can be read.
Label both what the switch controls and what state means what. "Email notifications" is clear; a bare switch with no text beside it is a guess.
Making it yours
Should it be role="switch"? Both are defensible. A checkbox announces "checked" and a switch announces "on", and the switch role better matches a control that takes effect immediately rather than on submit. Adding role="switch" to the input gives you that — but keep the checkbox underneath either way.
For a switch that saves immediately, give feedback. A silent toggle leaves people wondering whether it took. A brief toast from the alerts template is usually enough.
Do not use a switch for something that only applies when a form is submitted. A checkbox is the honest control there, because a switch implies the change is already live.
Related templates
Button set
Primary, secondary, ghost and danger, in three sizes, with every state covered.
HTMLCSSTailwindLoaders and skeletons
Six loading indicators in pure CSS, plus the skeleton pattern that usually beats all of them.
HTMLCSSModal dialog
A modal built on the native dialog element — focus trap and backdrop included, for free.
HTMLCSSJavaScriptAccordion and FAQ
Built on details and summary — open, close and keyboard support with no JavaScript.
HTMLCSSCheck your version
Once you have edited this, the HTML validator will catch any tag you left unclosed, and the formatter will tidy the indentation. Both run in your browser.