CSS-Only Toggle Switches and Checkboxes That Keep the Native Input

A designer hands you a switch: a rounded track, a thumb that slides across it, a colour change, and a satisfying ease. The temptation is to build it out of a <div> with a click handler. The narrow problem this page solves is the opposite — to render exactly that visual on top of an unmodified <input type="checkbox">, so that the control is still a control: focusable, announced correctly, operable with the Space key, restorable by browser autofill, and serialised with the form on submit. This is one of the state-driven patterns in the Keyframe Animation Patterns section of CSS-Only Micro-Interactions & Animations, and it is the one where getting the CSS wrong quietly destroys something that used to work.

Everything below assumes the interaction really is a boolean choice the user is selecting — a setting, a consent, a filter. If you are expanding and collapsing a region instead, the control you want is <details>, and the reasoning is laid out in CSS-only accordions and disclosure; do not reach for a hidden checkbox there.


The contract you are agreeing to when you style an input

A native checkbox is not just a painted square. It arrives with a bundle of behaviour that the platform maintains for you, and styling it is an agreement not to break any of the following.

It is a focusable form control, so it appears in the sequential focus order at its position in the DOM and toggles on Space. It exposes the checkbox role with a checked state that updates as the user interacts — no aria-checked bookkeeping of your own. It has a label association: a <label> whose for attribute matches the input's id, or a <label> that wraps the input, does two separate jobs at once. It supplies the accessible name, so a screen reader announces "Enable notifications, checkbox, not checked" rather than an anonymous control, and it extends the hit target — a click anywhere in the label is forwarded to the input as a synthetic activation. That second job is what makes a CSS-only switch possible at all, because the visible track and thumb are inside the label, not inside the input.

It participates in form submission and in form state restoration: reloading the page or navigating back restores the checked value, and a password manager or autofill routine can find it. It also participates in constraint validation, so required on a single checkbox produces a native "Please check this box if you want to proceed" message anchored to the control.

The argument for doing all this in CSS rather than JavaScript is not merely that it saves bytes. It is that every one of those behaviours is something a JavaScript re-implementation has to rebuild, and rebuilds are where accessibility regressions live. The genuine tradeoff runs the other way: CSS gives you no way to set state, so anything that requires writing to the control — programmatic indeterminate, optimistic UI, a server round-trip — still needs script. Be honest about which side of that line your component sits on.


What breaks when you hide the input wrong

The visual switch is drawn by the elements next to the input, so the input itself must be invisible. There are two ways to make an element invisible and only one of them is safe.

State routing and safe visual hiding for a CSS-only toggle The top row shows a checked input matched by a sibling combinator which translates the thumb. The bottom row compares hiding the input with display none, which removes it from the accessibility tree, against absolute positioning with zero opacity, which keeps it operable. How a checkbox drives the thumb Native input :checked Sibling match input:checked + Thumb moves translateX compositor-only: transform and opacity Two ways to make the input invisible display: none dropped from the accessibility tree no focus stop, no Space toggle validation cannot focus it never do this absolute + opacity: 0 still in the accessibility tree focusable, Space still toggles label click forwards to it use this

display: none and visibility: hidden both remove the element from the accessibility tree and from the focus order. The switch will still look right and still respond to mouse clicks, because the label forwards those — which is exactly why the bug survives review. It is only reachable by keyboard through the label, and a <label> is not focusable, so there is no keyboard path to the control at all. Screen reader users get a piece of decorative text where a setting should be. If the input is required, the browser cannot scroll to or focus an invisible control to show its validation message, and submission fails with no visible explanation.

width: 0; height: 0 alone is a middle case: the element remains in the tree and in the focus order, but a zero-size box can confuse the scroll-into-view that happens on focus. The reliable recipe is position: absolute; opacity: 0; with a real size, sitting behind the visual, so the browser has a genuine box to scroll to and the pointer still hits the label. Two closely related traps: never add pointer-events: none to the input, and never move it off-screen with a large negative offset in a direction that can trigger a horizontal scroll jump on focus.


A complete working implementation

Click the switch, then Tab away and Tab back — the focus ring appears only for keyboard users, because it is bound to :focus-visible. The nuance between that and plain :focus is worked through in focus-visible versus focus polyfill alternatives.

Live demoCSS-only toggle switch
Click the switch, then Tab to it — the focus ring only appears for keyboard users.
<!-- The label wraps everything: it names the control AND enlarges the hit
     target, so the track, thumb and text are all clickable. -->
<label class="toggle">
  <input type="checkbox" class="toggle__input" name="notifications">
  <span class="toggle__track"><span class="toggle__thumb"></span></span>
  <span class="toggle__text">Enable notifications</span>
</label>
.toggle { display: flex; align-items: center; gap: .75rem; cursor: pointer; }

/* Visually hidden, functionally intact. Absolute keeps it out of the flex
   flow; opacity: 0 (not display: none) keeps it focusable and announced. */
.toggle__input { position: absolute; opacity: 0; width: 0; height: 0; }

.toggle__track {
  display: block; width: 48px; height: 24px;
  background: #cbd5e1; border-radius: 12px; position: relative;
  transition: background .2s ease;
}

/* The thumb is the only thing that moves, and it moves with transform,
   so the browser never re-runs layout while the switch animates. */
.toggle__thumb {
  position: absolute; top: 2px; left: 2px; width: 20px; height: 20px;
  background: #fff; border-radius: 50%;
  transition: transform .2s cubic-bezier(.4, 0, .2, 1);
  will-change: transform;
}

/* State routing: the input is the source of truth, the sibling is the paint. */
.toggle__input:checked + .toggle__track { background: #3b82f6; }
.toggle__input:checked + .toggle__track .toggle__thumb { transform: translateX(24px); }

/* Ring on the visible track, driven by focus on the invisible input. */
.toggle__input:focus-visible + .toggle__track { outline: 2px solid #2563eb; outline-offset: 2px; }

Three details are load-bearing. The + combinator requires the track to be the input's immediate next sibling — reorder the markup and every state rule silently stops matching, which is the most common reason a switch renders but never animates. translateX(24px) is derived from the geometry: a 48px track, a 20px thumb, and 2px of inset on each side leaves exactly 24px of travel. And the outline is applied to .toggle__track rather than to the input, because an element with zero size draws a zero-size ring; the visual element has to borrow the input's focus state through the same combinator that carries :checked. Sizing and contrast of that ring are covered in creating accessible focus indicators.


The key technique: the input holds state, the sibling holds pixels

The whole pattern is one idea — a strict split between the element that is the state and the elements that depict it. :checked is a live pseudo-class reflecting the control's checkedness, so the browser re-evaluates any selector containing it the instant the user toggles, with no event listener involved. Because a combinator can carry that match sideways, input:checked + .track lets a completely different element repaint on state change.

The direction of the combinator is the constraint: + and ~ only look forward among siblings, so the visual must come after the input in source order. Two escape hatches exist. :has() reads upward, so .toggle:has(:checked) styles the wrapper itself and frees you from source order entirely, which matters when the design needs the label's background to change. And accent-color: #3b82f6 on a plain unstyled checkbox recolours the native control with a single declaration, correct in both light and dark schemes and in forced-colors mode — worth reaching for before any of this when the only requirement is a brand colour.

On form submission, the split has one consequence worth stating plainly. A checkbox that is checked submits name=value, where value defaults to the string on unless you set a value attribute. A checkbox that is unchecked submits nothing at all — the key is simply absent from the payload. That is native HTML behaviour, unchanged by any styling here, and it means server code must read a missing key as false rather than expecting false. The usual workaround, a hidden input with the same name and a falsy value placed immediately before the checkbox, still works; just keep it out of the sibling chain your selectors depend on.


Variation: a single-element checkbox with an indeterminate state

When the design is a box with a mark inside rather than a track with a thumb, you do not need a wrapper span at all. appearance: none strips the platform rendering from the input and leaves you a normal styleable box, which you then decorate with ::before and ::after. One element, one hit target, no combinators.

<input type="checkbox" class="cbx" id="opt-in" name="opt_in">
<label for="opt-in" class="cbx-label">Enable notifications</label>
.cbx {
  /* appearance: none removes the platform widget but keeps the semantics. */
  appearance: none;
  inline-size: 20px; block-size: 20px; margin: 0;
  background: #e2e8f0;
  border: 1px solid #94a3b8;
  border-radius: 4px;
  cursor: pointer;
  position: relative;
  transition: background .18s ease, border-color .18s ease;
}

.cbx:checked { background: #10b981; border-color: #10b981; }

/* Tick drawn as two borders of a rotated box — no image, no font. */
.cbx:checked::after {
  content: "";
  position: absolute; inset-block-start: 50%; inset-inline-start: 50%;
  inline-size: 6px; block-size: 10px;
  border: solid #fff; border-width: 0 2px 2px 0;
  transform: translate(-50%, -60%) rotate(45deg);
}

/* Mixed state: neither checked nor unchecked. Styled here, but only
   settable from script — el.indeterminate = true. */
.cbx:indeterminate { background: #f59e0b; border-color: #f59e0b; }
.cbx:indeterminate::after {
  content: "";
  position: absolute; inset-block-start: 50%; inset-inline-start: 50%;
  inline-size: 10px; block-size: 2px;
  background: #fff;
  transform: translate(-50%, -50%);
}

.cbx:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }

@media (prefers-reduced-motion: reduce) {
  .cbx { transition: none; }
}

The indeterminate state deserves care because it is asymmetric: :indeterminate is a CSS pseudo-class you can style, but the state behind it is an IDL attribute with no HTML counterpart, so it cannot be expressed in markup and cannot be set in CSS. It is the "some children selected" look of a parent row in a tree of checkboxes, and it is purely visual — the control still submits as checked or unchecked according to checked, never as a third value. Assistive technology reports it as "mixed". If your page is genuinely script-free, style it anyway for robustness but do not design a feature around it.

The @media (prefers-reduced-motion: reduce) block is not optional decoration. Both patterns here move or fade on every interaction, and users who have asked for reduced motion should get an instant state change; the broader approach is in reducing motion preferences in CSS. Independently, check the hit area: a 20px box or a 48×24 track is below the 24×24 CSS pixel minimum that WCAG 2.2 asks for unless the label around it is large enough, which is the subject of target size and pointer accessibility.


Browser support note

Nothing here is new. Unprefixed appearance: none and accent-color are both available across every current engine, with -webkit-appearance covering older WebKit builds; :focus-visible shipped in Chrome and Edge 86, Firefox 85, and Safari 15.4. The one selector worth guarding is :has(), which arrived in Chrome and Edge 105 and Safari 15.4 but only reached Firefox in version 121, so wrap wrapper-level styling in @supports selector(:has(*)) and keep a sibling-combinator rule as the baseline. Every pattern on this page degrades to a plain, fully operable native checkbox if a rule is dropped, because the control was never removed in the first place.


FAQ

Why is display: none the wrong way to hide the checkbox? An input with display: none is removed from the accessibility tree and from the focus order, so it cannot be reached by keyboard or announced by a screen reader, and the browser cannot focus it to show a validation message. Hide it with position: absolute; opacity: 0 instead, which keeps the control real while the sibling elements do the drawing.

Does a CSS-styled toggle still submit its value with the form? Yes, as long as the input is still in the DOM and has a name attribute. A checked checkbox submits name=value, with value defaulting to the string on; an unchecked one submits nothing at all, so server code must treat a missing key as false rather than expecting false.

Can CSS set or detect the indeterminate state? CSS can style it but not set it. :indeterminate matches when the element's IDL indeterminate property is true, and that property can only be assigned from script, so a strictly script-free page can define the tri-state look but never enter it.

Should I use appearance: none or a visually hidden input plus a span? Use appearance: none when the control is a single box you can draw on directly — one element, one hit target, no combinators to break. Use a hidden input plus sibling spans when the design has several moving parts, such as a track and a thumb, because pseudo-elements alone do not supply enough boxes.


Related articles

More pages in the same section.