Safari 17.4 is the only browser that draws a real switch from a checkbox. WebDX browser data lists Safari 17.4, released March 2024, as the sole engine that renders one from the checkbox switch attribute. Chrome, Edge and Firefox still show a plain checkbox.

That’s why most people style a checkbox themselves. The browser keeps the on/off state, a :checked selector changes the look, and no JavaScript is involved.

You’ll mostly see these in settings panels, next to options like notifications or dark mode, where the change should land the moment you click.

What Is a CSS Toggle Switch and How Does It Work?

It’s a checkbox wearing a different skin. CSS draws a track and a thumb over it, while the checkbox underneath still decides whether the thing is on or off.

The markup is a label, an input and one decorative span, all plain HTML.

Since the state sits in the checkbox, the switch flips before any script has loaded. CSS only reads it.

How the :checked state drives the switch

The checkbox stores the real value and responds to the Space key by itself. A label wrapped around it passes every click or tap through, so the whole control is clickable, and one sibling selector, input:checked + .track, restyles the track and thumb whenever the box is ticked.

MDN Web Docs lists :checked as Baseline widely available, supported across browsers since July 2015.

You don’t need a fallback or a feature check for it. The adjacent sibling combinator (+) wants the styled element right after the input in the markup, while the general sibling combinator (~) accepts any later sibling.

Visual state and real state are the same thing here, so they can’t drift apart. Nothing on the page has to keep two values in sync.

Switch, checkbox or toggle button

Choose by what the control means to the user. How it looks matters less.

ControlCommunicatesFits
SwitchOn or off, applied right awaySettings such as notifications or dark mode
CheckboxSelected or not, can be partialOptions sent with a form, list selection
Toggle buttonPressed or not pressedA mode, such as bold in an editor

Apple’s Human Interface Guidelines describe a toggle as a choice between a pair of opposing states, and they generally advise against swapping a checkbox for a switch.

Is responsive design still a top priority?

Explore the latest responsive design statistics: adoption rates, performance impact, user behavior, and trends shaping modern websites.

See the Numbers →

The WebKit team’s post on the Safari 17.4 switch control lands in the same place. A checkbox suits something the user thinks of as an item to select.

How to Build a CSS Toggle Switch Step by Step

  1. Wrap a checkbox and a track element inside a label.
  2. Hide the checkbox visually while keeping it focusable.
  3. Draw the track as a pill and the thumb with ::before.
  4. Move the thumb and recolor the track under :checked.

Write the markup

Everything goes inside the label. Its text becomes the checkbox’s accessible name, and a click anywhere on it toggles the box.

<label class="toggle">
<input class="toggle__input" type="checkbox">
<span class="toggle__track"></span>
<span class="toggle__text">Notifications</span>
</label>

Keep the track directly after the input. The selector in the last step relies on that order.

Hide the checkbox without losing keyboard access

The obvious ways to hide the input, display: none and visibility: hidden, both drop it from the tab order. Anyone who isn’t using a mouse can no longer operate the switch.

Clip the input to a 1px box and position it absolutely instead. It stays focusable and takes up no space.

.toggle {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
}
.toggle__input {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}

Alvaro Montoro’s walkthrough names the two mistakes that come up most. One is hiding the input where the keyboard can’t reach it, and the other is removing the focus style.

Most other styled checkbox designs rest on this same hiding pattern, custom radio buttons included.

Draw the track and thumb

The track is a pill. The thumb is a circle drawn with ::before, so you don’t need another element in the markup.

Set the track to 3rem wide and 1.75rem tall with a 999px radius. The thumb is a 1.25rem circle inset 0.25rem on every side, which leaves 1.25rem of travel (track width minus thumb minus both insets).

.toggle__track {
position: relative;
flex: none;
width: 3rem;
height: 1.75rem;
border-radius: 999px;
background: #767676;
}
.toggle__track::before {
content: "";
position: absolute;
top: 0.25rem;
left: 0.25rem;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: #fff;
}

A border radius generator previews pill shapes fast, though 999px does the same job without one.

Apply the checked state

Recolor the track first, then slide the thumb across by the travel distance.

.toggle__input:checked + .toggle__track {
background: #1a73e8;
}
.toggle__input:checked + .toggle__track::before {
transform: translateX(1.25rem);
}
.toggle__input:focus-visible + .toggle__track {
outline: 3px solid #1a73e8;
outline-offset: 2px;
}

Don’t skip the focus-visible rule. A keyboard user who tabs onto the hidden checkbox would otherwise see nothing happen.

Styling the Track, Thumb and States

The shape of a switch comes down to track width, track height, thumb size and thumb inset. After that it’s just color and state.

Size and shape

Bootstrap’s 6.0 documentation sets switch width at 1.75 times switch height, with 0.0625rem of padding inside the track. Bootstrap’s switch component exposes those values as CSS variables.

That ratio works as a starting point for your own version. Thumb diameter is track height minus twice the inset, and keeping the inset equal on all sides centers the thumb. A radius of half the track height gives a pill, and any larger value gives the same shape.

Ionut Colceriu’s css-toggle-switch library builds in em by default, so each switch scales with its parent’s font size. Separate rem and px stylesheets cover fixed sizes.

Which one you want depends on context, and there’s a longer breakdown of em or rem units if you need it. Em suits a switch sitting beside text that should grow with it. Rem keeps one size across the page.

Colors and custom properties

Put every size and color into custom properties on the label and override them per variant.

.toggle {
--toggle-width: 3rem;
--toggle-height: 1.75rem;
--toggle-inset: 0.25rem;
--toggle-off: #767676;
--toggle-on: #1a73e8;
--toggle-thumb: #fff;
}
.toggle__track {
width: var(--toggle-width);
height: var(--toggle-height);
background: var(--toggle-off);
}
.toggle__track::before {
top: var(--toggle-inset);
left: var(--toggle-inset);
width: calc(var(--toggle-height) - 2 * var(--toggle-inset));
height: calc(var(--toggle-height) - 2 * var(--toggle-inset));
background: var(--toggle-thumb);
}
.toggle__input:checked + .toggle__track {
background: var(--toggle-on);
}
.toggle__input:checked + .toggle__track::before {
transform: translateX(calc(var(--toggle-width) - var(--toggle-height)));
}

Travel distance reduces to width minus height. Change either one and the thumb still ends up flush against the far inset.

Google’s Material Web component uses the same split, with tokens like –md-switch-track-color and –md-switch-selected-track-color. Its shape tokens default to a fully rounded corner.

The accent-color property only tints native controls, so it does nothing here. A hand-built switch gets its colors from your own rules.

Disabled, hover and focus states

Disabled gets lower opacity and cursor: not-allowed on input:disabled + .toggle__track. The disabled attribute already blocks clicks and keyboard focus, so that rule is purely cosmetic.

Hover is optional. If you add it, darken the off color a little and wrap the rule in @media (hover: hover) so touch screens ignore it. Focus keeps the :focus-visible outline from the build step, with a 2px offset so it doesn’t merge into the track edge.

The :has() selector can restyle the whole row from the checkbox state, as in label:has(input:checked). MDN lists :has() as Baseline widely available, supported across browsers since December 2023, and a browser that lacks it drops the entire rule.

Dark mode

A dark theme only needs new values for the same custom properties.

@media (prefers-color-scheme: dark) {
.toggle {
--toggle-off: #9e9e9e;
--toggle-on: #8ab4f8;
--toggle-thumb: #121212;
}
}

Check the track against the dark background again. A color that stands out on white won’t always stand out on near-black.

How to Animate the Thumb Without Layout Shift

Animate transform on the thumb and background-color on the track. Leave left and margin alone.

Google’s web.dev guide says to limit animations to transform and opacity where you can, since that keeps them on the compositing stage of the rendering path. A companion guide adds that these animations can run on the compositor thread, so they keep moving even when the main thread is busy.

The slide itself is a micro-interaction, and it exists to confirm the tap.

PropertyWork per frameVerdict
transformCompositing onlyUse for the thumb
background-colorPaintFine for the track
left or margin-leftLayout, paint, compositeAvoid for the thumb

A transition handles both directions on its own, so a keyframe animation adds code and no extra control.

.toggle__track {
transition: background-color 0.2s ease;
}
.toggle__track::before {
transition: transform 0.2s ease;
}

The classic slip is declaring the transition only inside the :checked rule. Checking animates, unchecking snaps, so put it on the base rule.

The pseudo-element is another trap. A transition on the track doesn’t move the ::before thumb, which needs its own declaration. And transition: all will animate properties you never meant to touch, so name the ones you want.

MDN says the prefers-reduced-motion media query is Baseline widely available and has been supported across browsers since January 2020. The value to match is reduce, not none.

MDN also notes that people expect unneeded motion to be off, even motion triggered by their own interaction, unless the animation is what carries the information.

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

Color and position still change when the switch flips. Only the slide goes away, so nothing is lost.

How to Make a CSS Toggle Switch Accessible

Use a real checkbox with a visible label, and make sure the focus ring survives hiding the input. That handles most of it.

Whether to add role=”switch” is where the sources disagree.

Native checkbox or role=”switch”

The W3C ARIA Authoring Practices Guide puts role=”switch” on an input type=”checkbox” in its checkbox-based switch example and leans on the native checked state instead of aria-checked. Bootstrap’s switch markup does the same. MacMyths takes the opposite view and advises against adding a redundant aria-checked or role=”switch” to a native checkbox switch.

It’s a trade between announcement and minimal ARIA. The APG notes that a screen reader reports a role=”switch” control as a switch with an on or off state, which fits a setting better than a checked checkbox does.

That same guide warns that careless ARIA does more harm than none, and it labels its examples as illustrations to test with assistive technology before production.

My pick is role=”switch” on the input with aria-checked left off. The role changes what gets announced, and the native checked state already supplies the value.

The role also gives CSS something to hook onto. Alvaro Montoro styles input[role=”switch”] separately from plain checkboxes, so a single stylesheet can serve both.

ARIA attributes really earn their place when the switch is built from a button or a div. MDN lists aria-checked as required for the switch role, and a script has to flip it on every toggle.

Keyboard and focus

A real checkbox gives you keyboard behavior for free, which is the strongest argument against rebuilding the control from a div.

KeyResultRequirement
TabMoves focus to the switchRequired
SpaceToggles on and offRequired
EnterToggles on and offOptional in the APG pattern

The APG’s checkbox example documents only Tab and Space, so a native switch needs no extra key handling.

Draw the focus ring on the track, since the input itself is clipped to 1px. The ring counts as part of the component, so it needs the same 3:1 contrast as the track.

WCAG 2.2 values to meet

  • Components and their states need 3:1 contrast against adjacent colors (SC 1.4.11, Level AA).
  • Targets should measure at least 24 by 24 CSS pixels (SC 2.5.8, Level AA).
  • An undersized target still passes when a 24 CSS pixel circle centered on it intersects no other target and no circle of another undersized target (SC 2.5.8).
  • Inactive components are exempt from the contrast requirement, so a disabled switch can go lighter (SC 1.4.11).

The track from the build step is 3rem by 1.75rem, which works out to 48 by 28 CSS pixels at the default 16px root size. Both dimensions clear the 24 pixel minimum.

A small variant under 1.5rem tall falls below it and needs the spacing exception. Measure the off track and the on track against the page background separately, because each state has to meet the color contrast threshold on its own.

Toggle Switch Methods Compared

Build on the label and hidden checkbox pattern and treat the native switch attribute as an add-on. As of October 2026 only Safari renders it.

MethodBrowser supportMain drawback
Label with hidden checkboxAll current browsersExtra track element
Styled input with appearance: noneAcross browsers since March 2022 (MDN)Older browsers show a plain checkbox
Native switch attributeSafari 17.4 and later onlyChrome, Edge and Firefox show a plain checkbox
Framework componentSame as the CSS underneathAdds a dependency for one control

WebDX browser data puts the switch attribute at limited availability. Safari 17.4, released 2024-03-05, is the only engine shipping it, and Chrome, Edge and Firefox are marked not supported.

Styling the input directly with appearance: none strips the native look but leaves behavior alone, and MDN notes that a label click still toggles the checkbox. Go Make Things builds Kelp UI’s switch this way, using ::after for the thumb and label:has([role=”switch”]) for the label layout.

The native attribute behaves oddly in practice. WebKit says a browser without support treats input type=”checkbox” switch as a plain checkbox. In Safari the control reports through the ARIA switch role as On or Off, and appearance: none hands the styling back to you.

At launch WebKit exposed the ::thumb and ::track pseudo-elements only behind a Safari feature flag. Bootstrap’s documentation adds the attribute for one reason, haptic feedback on iOS 17.4 and later.

Bootstrap’s form-switch class and Tailwind toggle components wrap the same techniques. Tailwind’s peer marker and peer-checked variant write the sibling rule for you, with the input hidden.

Reach for a framework version only if the framework already loads on the page. A new dependency costs more than the CSS it replaces.

With one engine rendering the native switch, the same page looks different from browser to browser. So cross-browser compatibility testing belongs inside the build and not at the end of it.

When a CSS-Only Toggle Switch Falls Short

A CSS-only toggle switch changes how a setting looks and nothing beyond that. It stores no value, applies no setting and sends nothing to the server.

The page keeps no record of the choice, so saving it takes a form submission or a script. A switch that reveals content can run on :checked alone, but one that triggers a request or changes data can’t.

Custom elements make it worse. A button or div with role=”switch” has no built-in state, and MDN says the developer must update aria-checked on every toggle. MDN’s switch role also doesn’t support the mixed value of aria-checked, so a partial selection needs a checkbox.

Forced colors mode is the failure tutorial code rarely shows. It’s a web accessibility setting where the browser swaps authored colors for system colors.

The track’s background-color gets overridden, so a switch that signals state only through color shows no difference between on and off. Go Make Things documents the fix, credited to Stephanie Eckles. System color keywords such as CanvasText survive as backgrounds.

@media (forced-colors: active) {
.toggle__track {
border: 2px solid CanvasText;
}
.toggle__track::before {
background: CanvasText;
}
.toggle__input:checked + .toggle__track {
background: CanvasText;
}
.toggle__input:checked + .toggle__track::before {
background: Canvas;
}
}

Put the block after the base rules so it wins the cascade. The on state then shows a filled track with a light thumb, and the off state shows an outlined track with a dark one.

GOV.UK Frontend ran into the same class of problem. The focus state on its checkboxes and radios was barely visible in forced colors mode, and a transparent outline, which turns solid in that mode, fixed it (pull request 2264).

As a rule of thumb, CSS owns the appearance and script owns anything that has to persist or leave the page.

CSS Toggle Switch Examles To Check Out

Toggle Radial Effect

See the Pen
#028 – Toggle Radial Effect
by Florin Pop (@FlorinPop17)
on CodePen.

Project Management Triangle

CSS Pancake

Jellyfish UI Kit

Gender Toggle Pure CSS

Kitty Toggle

Pure CSS Bulb Switch

Egg Toggle

BB-8 Toggle Switch|Pure CSS

Emo Toggle

Flight Toggle

Funny toggle switch

Toggle Switch with Rolling Label

Eye Switch

Subscribe Buttons (CSS)

Accessible Toggle Switch with check

Awesome Toggle Button

Totoro Toggle

Sun And Moon Toggle Switch

Flat Toggle Switch

Oldschool Toggeler in CSS3

Day/night toggle switch animated

Skew Switch

Funky CSS3 Toggle Buttons

Realistic Toggle Button

Faction Toggle

Google CSS Only Toggle Switch

Weekday Toggle Switch with Checkbox and CSS3

Antsy Toggles

Checkbox Rocker Toggle Switch

Press the Button

CSS Toggle Switch FAQ

How do I show ON and OFF text inside the switch?

Put two spans inside the track, one reading ON and one reading OFF, and reveal the matching one with the :checked rule. Mark both aria-hidden=”true”, as the W3C APG examples do, since the control already announces its own state.

Which values change for small and large sizes?

Only the width and height custom properties. Add a modifier class such as .toggle–small that overrides –toggle-width and –toggle-height, and the thumb size and travel distance follow through the calc() rules.

How do I read the switch state in JavaScript?

Listen for the change event on the input and read its checked property. Change fires for mouse clicks, label taps and the Space key alike, so one handler covers every input method.

Can a dark mode switch remember the visitor’s choice without JavaScript?

No. The prefers-color-scheme media query supplies the default, but CSS stores nothing. A short script has to save the choice in localStorage and apply it as an attribute on the html element.

Does the switch send a value when a form is submitted?

Yes, but only while it’s on. A checked checkbox submits its name with its value attribute (on by default), and an unchecked one submits nothing, so the server has to treat a missing field as off.

Testing a CSS Toggle Switch Before It Ships

Start with the keyboard. Tab to the switch and flip it with Space. Then listen for the label and the on or off state in a screen reader, and finally switch on forced colors mode to see whether on and off still look different.

If the keyboard test fails, the others don’t matter yet. A switch that can’t take focus can’t be announced, and its focus ring can’t be checked.

The hidden-checkbox pattern stays the base layer until Chrome, Edge or Firefox ships the native switch attribute (position verified October 2026). After that, the pattern becomes the fallback.

The web accessibility checklist covers the rest of the page the switch sits in.

Bogdan Sandu
Latest posts by Bogdan Sandu (see all)