CSS Toggle Switch

A styled on/off toggle built with a checkbox and CSS.

Published May 19, 2026 Intermediate 8 min read

A CSS toggle switch is a checkbox wearing a different coat. The input holds the on and off state, the browser handles the click and the keyboard, and CSS repaints the whole thing as a sliding track and thumb. Nothing here needs JavaScript, because :checked already tells the stylesheet which state to draw.

The version below hides the checkbox and styles a sibling element instead. That is the pattern most tutorials show, and it has a flaw worth knowing about before you ship it. Two more versions follow: one that keeps the input reachable from the keyboard and prints ON and OFF inside the track, and one that styles the checkbox itself with appearance: none so there is no extra element at all.

Switch driven by a checkbox

HTML

<label class="toggle-label">
  <input class="toggle-input" type="checkbox" checked>
  <span class="toggle-track"></span>
  Toggle
</label>

CSS

.toggle-label {
  display: inline-flex;
  align-items: center;
  gap: 1rem;
  cursor: pointer;
  font-family: system-ui, sans-serif;
  font-size: 1rem;
  user-select: none;
}

.toggle-input {
  display: none;
}

.toggle-track {
  width: 52px;
  height: 28px;
  background: #2a2a2d;
  border-radius: 999px;
  position: relative;
  transition: background .25s;
}

.toggle-track::after {
  content: "";
  position: absolute;
  top: 3px;
  left: 3px;
  width: 22px;
  height: 22px;
  background: #141415;
  border-radius: 50%;
  box-shadow: 0 1px 4px rgba(0, 0, 0, .2);
  transition: transform .25s;
}

.toggle-input:checked + .toggle-track {
  background: #b8ff57;
}

.toggle-input:checked + .toggle-track::after {
  transform: translateX(24px);
}

Other ways to build it

Keyboard reachable, with ON and OFF in the track

The input is clipped to a single pixel rather than removed, so it still takes keyboard focus and a screen reader still announces it. The focus ring is forwarded to the track with :focus-visible + .toggle-track, since an outline drawn on a one pixel element would be invisible. The state word comes from the track's ::before, which swaps its content and its side when the input is checked. Tab to it and press space.

HTML

<label class="toggle-label toggle-labeled">
  <input class="toggle-input toggle-input-clipped" type="checkbox">
  <span class="toggle-track"></span>
  Email digest
</label>

CSS

.toggle-labeled {
  position: relative;
}

/* clipped, not display:none, so it stays focusable */
.toggle-input-clipped {
  display: block;
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
}

.toggle-labeled .toggle-track {
  width: 66px;
}

.toggle-labeled .toggle-track::before {
  content: "OFF";
  position: absolute;
  top: 0;
  right: 8px;
  line-height: 28px;
  font-size: .6rem;
  font-weight: 700;
  letter-spacing: .06em;
  color: #88888f;
}

.toggle-labeled .toggle-input:checked + .toggle-track::before {
  content: "ON";
  right: auto;
  left: 9px;
  color: #0c0c0d;
}

.toggle-labeled .toggle-input:checked + .toggle-track::after {
  transform: translateX(38px);
}

/* the ring belongs on the track, not on the 1px input */
.toggle-input-clipped:focus-visible + .toggle-track {
  outline: 2px solid #b8ff57;
  outline-offset: 3px;
}

One element switch with appearance: none

Setting appearance: none on the checkbox strips its native rendering and leaves an ordinary box to style, so the track is the input and there is no sibling span at all. The thumb is a radial-gradient in the background image, and checking the box slides it with background-position. Because nothing is hidden, focus and screen reader announcement work without any forwarding. The same technique is what makes custom checkboxes and a styled select possible.

HTML

<label class="toggle-solo-label">
  <input class="toggle-solo" type="checkbox" checked>
  Dark mode
</label>

CSS

.toggle-solo {
  -webkit-appearance: none;
  appearance: none;
  margin: 0;
  border: none;
  width: 52px;
  height: 28px;
  flex: 0 0 auto;
  border-radius: 999px;
  cursor: pointer;
  background-color: #2a2a2d;
  /* the thumb is a gradient, not a pseudo-element */
  background-image: radial-gradient(circle at 14px 14px,
                    #141415 0 10.5px, transparent 11px);
  background-repeat: no-repeat;
  background-position: 0 0;
  transition: background-color .25s, background-position .25s;
}

.toggle-solo:checked {
  background-color: #b8ff57;
  background-position: 24px 0;
}

.toggle-solo:focus-visible {
  outline: 2px solid #b8ff57;
  outline-offset: 3px;
}

How it works

The real checkbox is hidden with display:none and an adjacent sibling <span> becomes the visual track. Its ::after pseudo-element is the thumb. When the checkbox matches :checked, CSS shifts that thumb with translateX and repaints the track, so the switch changes state with no script attached to it.

The whole pattern rests on one selector: .toggle-input:checked + .toggle-track. The adjacent sibling combinator lets a checked input restyle the element that follows it, which is how CSS reads form state without help. The input must come first in the markup for that to work, since CSS has no way to select backwards from a sibling to something before it. Wrapping both in a <label> means a click anywhere on the row toggles the input, including the text.

The track is a 52 by 28 pixel box with border-radius: 999px. Any radius larger than half the height produces the same pill, so 999px is a common shorthand for saying fully rounded rather than calculating the value. The thumb is not a real element. It is the track's ::after, absolutely positioned three pixels in from the top and left, sized 22 by 22, and rounded to a circle with border-radius: 50%.

Movement comes from transform: translateX(24px), not from changing left. The arithmetic is track width minus thumb width minus twice the inset, so 52 minus 22 minus 6 gives 24. Using a transform matters for more than tidiness. Transforms are handled by the compositor, so the thumb slides without the browser recalculating layout on every frame, while animating left forces a layout pass each time.

Two transitions run at once and they are declared on different elements. The track transitions background, the thumb transitions transform. Both use the same 0.25 second duration so the color change and the slide finish together. If you give them different durations the switch looks broken in a way that is hard to pin down, because the eye reads the slower of the two as the real state change.

The same :checked mechanism drives every other stateful control on this site. It is what holds the on state on custom checkboxes and custom radio buttons, and the reason a group of radios can act as a star rating without a line of script.

CSS properties used

:checked
Matches a checkbox or radio that is currently on. Combined with a sibling combinator it is the whole state machine here.
+ (adjacent sibling)
Selects the element immediately after the input. This is why the input has to be written before the track in the HTML.
transform
translateX slides the thumb. Runs on the compositor, so the animation does not trigger layout the way animating left would.
transition
Animates the track color and the thumb position. Both set to the same duration so the two halves of the change land together.
border-radius
999px on the track makes a pill, 50% on the thumb makes a circle. Any value over half the height gives the same fully rounded result.
appearance
none strips the browser's native checkbox rendering so the input itself can be styled as the track, with no sibling element involved.

Browser support

FeatureChromeFirefoxSafariEdge
:checked43.53.212
transition455.112
transform43.53.112
appearance838015.483
:focus-visible86415.486

Figures come from Can I Use, with :checked covered by the CSS3 selectors entry. The first two versions work everywhere. The appearance: none version needs the -webkit-appearance: none prefix for Safari before 15.4, which is why the snippet declares both lines. :focus-visible degrades quietly: browsers that do not know it simply drop the rule, so pair it with a plain :focus fallback if you support Safari 15.3 or older.

Accessibility notes

The first demo hides the checkbox with display: none, and that removes it from the tab order and from the accessibility tree at the same time. A keyboard user cannot reach the switch, and a screen reader will not announce it, because as far as the browser is concerned the control is not there. The visual switch still responds to a mouse click through the label, which is exactly what makes the bug easy to miss during testing.

The fix is to hide the input from sight without hiding it from the browser. Both variants below do that differently. The labeled version clips the input to a single pixel with clip-path: inset(50%), which keeps it focusable, and moves the focus ring onto the track with :focus-visible + .toggle-track. The appearance: none version does not hide anything at all, so the focus ring lands on the real control and needs no forwarding.

State is announced by the checkbox itself. A screen reader says checked or unchecked, which is close enough for most switches. If the control genuinely acts as an on and off switch rather than a form field, add role="switch" to the input and the announcement changes to on and off. Do not add aria-checked by hand alongside it, because the browser already keeps that in sync with the real checkbox and a hand written value will drift out of date.

Color alone should not carry the state. The demos here shift the thumb across the track as well as changing the track color, so the difference is still visible to someone who cannot separate the gray from the green. The ON and OFF text in the second variant goes further and states it outright.

What you can build with it

  • Settings panels. Notifications, autoplay, email digests. Anything that applies immediately rather than waiting for a save button.
  • Dark mode switches. Paired with the :has() selector or a small script, the checked state can drive a whole theme without touching the DOM.
  • Pricing page billing toggles. Monthly against yearly, with the sibling combinator swapping the visible price block.
  • Filter bars. In stock only, free shipping, on sale. Several switches in a row read faster than a column of native checkboxes.
  • Feature flags in admin tools. The on and off metaphor matches what the control actually does better than a checkbox does.

Mistakes worth avoiding

  • Hiding the input with display: none or visibility: hidden. Both make the switch unreachable by keyboard and invisible to screen readers. Clip it instead, or style the input directly with appearance: none.
  • Putting the track before the input in the markup. The sibling combinator only looks forward, so the checked state never reaches the track and the switch appears frozen in the off position.
  • Forgetting a focus style. The default outline is drawn on the hidden input, where nobody can see it, so the switch has no visible focus at all until you forward the ring to the track.
  • Animating left instead of transform. It works, but every frame triggers layout, and on a settings page with twenty switches the whole list stutters when one moves.
  • Sizing the thumb without accounting for the inset. If the thumb sits 3px from the left and you translate it by the full track width minus its own width, it slides 3px past the right edge and clips.

Frequently asked questions

Why is my toggle switch not keyboard accessible?
Almost certainly because the checkbox is hidden with display: none. That takes the element out of the tab order entirely. Replace it with a clipping technique such as position: absolute; width: 1px; height: 1px; clip-path: inset(50%), which keeps the input focusable while making it invisible, then move the focus ring onto the track with :focus-visible.
Can I build a toggle switch without an extra span?
Yes. Set appearance: none on the checkbox and style the input itself as the track. The thumb becomes a radial-gradient in the background image, shifted with background-position when the input is checked. The third demo on this page does exactly that, and it keeps the native focus and announcement behavior for free.
Should I use role="switch" on the checkbox?
Use it when the control turns something on and off right away, and leave it off when the control is a normal form field that gets submitted. With the role, a screen reader announces on and off. Without it, checked and unchecked. Neither is wrong, but the wording should match what the control does.
How do I make the switch bigger or smaller?
Change the track width and height, keep the thumb inset consistent, and recalculate the translate distance as track width minus thumb width minus twice the inset. Setting the four values in em units instead of pixels lets the whole switch scale with the surrounding font size.
Does a CSS toggle switch submit with a form?
Yes, because it is still a real checkbox. Give it a name and a value and it posts like any other checkbox. Unchecked boxes are not submitted at all, which is standard HTML behavior and not something the styling changes.