Build a toggle switch with HTML and CSS

A toggle switch is a checkbox in a different coat of paint. Keep the real checkbox, hide it from the eye, and draw the track and knob from its state.

A CSS toggle switch is an ordinary <input type="checkbox"> with a new look. Put it inside a <label> with two empty spans, a track and a knob, hide the checkbox from the eye (not with display: none), and paint the spans from :checked.

Try it first. Click the switch, or press Tab and then Space. The two boxes below show the layers the switch is made of.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Toggle switch anatomy</title>
<style>
  body { margin: 0; padding: 18px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .stage { max-width: 420px; margin: 0 auto; background: #fff; border-radius: 14px; padding: 22px 18px; box-shadow: 0 4px 16px rgba(0,0,0,.08); }

  /* 1. the label holds the text, the real checkbox and the drawing */
  .switch { display: inline-flex; align-items: center; gap: 12px; cursor: pointer; font-size: 17px; }

  /* 2. the real checkbox: hidden from the eye, still focusable (never display: none) */
  .switch input {
    position: absolute; width: 1px; height: 1px; margin: -1px;
    overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
  }

  /* 3. the track */
  .track {
    position: relative; flex: none; width: 52px; height: 30px; border-radius: 99px;
    background: #c7ccd4; transition: background-color .2s;
  }
  /* 4. the knob: always left: 3px, moved only by transform */
  .knob {
    position: absolute; left: 3px; top: 3px; width: 24px; height: 24px; border-radius: 50%;
    background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.35); transition: transform .2s;
  }

  /* states come from the checkbox */
  .switch input:checked + .track { background: #16a34a; }
  .switch input:checked + .track .knob { transform: translateX(22px); }
  .switch input:focus-visible + .track { outline: 3px solid #2563eb; outline-offset: 3px; }

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

  /* layer views for this demo only */
  .show-input .switch input { position: static; width: 18px; height: 18px; margin: 0; clip: auto; overflow: visible; outline: 2px dashed #c2410c; outline-offset: 2px; }
  .outline .track { outline: 2px dashed #2563eb; outline-offset: 2px; }
  .outline .knob { outline: 2px dashed #9333ea; }
  .outline .switch { outline: 1px dotted #6b7280; outline-offset: 6px; }

  .layers { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 22px; padding-top: 14px; border-top: 1px solid #eef0f3; font-size: 14px; }
  .layers label { display: flex; gap: 6px; align-items: center; cursor: pointer; }
  .key { margin: 10px 0 0; font-size: 12.5px; color: #5b6270; line-height: 1.6; }
  .key b.i { color: #c2410c; } .key b.t { color: #2563eb; } .key b.k { color: #9333ea; }
  #out { margin-top: 12px; padding: 9px 12px; border-radius: 10px; background: #1d2330; color: #e5e7eb; font: 13px/1.5 ui-monospace, Consolas, monospace; }
</style>
</head>
<body>
<div class="stage" id="stage">
  <label class="switch">
    <input type="checkbox" role="switch" id="wifi">
    <span class="track" aria-hidden="true"><span class="knob"></span></span>
    Wi-Fi
  </label>

  <div class="layers">
    <label><input type="checkbox" id="l-input"> Show the hidden input</label>
    <label><input type="checkbox" id="l-outline"> Outline label, track, knob</label>
  </div>
  <p class="key"><b class="i">orange</b> input &middot; <b class="t">blue</b> track &middot; <b class="k">purple</b> knob &middot; dotted = label</p>
  <div id="out" aria-live="polite"></div>
</div>

<script>
  const wifi = document.getElementById('wifi');
  const stage = document.getElementById('stage');
  const out = document.getElementById('out');

  // the drawing never holds state: read it from the real checkbox
  function show() { out.textContent = 'input.checked = ' + wifi.checked; }
  wifi.addEventListener('change', show);
  show();

  // layer toggles for this demo
  document.getElementById('l-input').addEventListener('change', (e) => stage.classList.toggle('show-input', e.target.checked));
  document.getElementById('l-outline').addEventListener('change', (e) => stage.classList.toggle('outline', e.target.checked));
</script>
</body>
</html>
Tick "Show the hidden input" to see the real checkbox flip with the drawing. The dark line reads its state.

The drawing never stores anything. The checkbox holds the state, and the CSS only reflects it. This guide goes deep on the switch itself. For how labels connect text to any control, see the label guide.

The markup: one label, one input, two spans

The label is the click target, the input holds the state, and the two spans are paint.
The label is the click target, the input holds the state, and the two spans are paint.
<label class="switch">
  <input type="checkbox" role="switch">
  <span class="track" aria-hidden="true"><span class="knob"></span></span>
  Wi-Fi
</label>
  1. The label wraps everything. A click on any part flips the checkbox.
  2. The input comes right before the track, so input:checked + .track can reach it.
  3. The track and knob are aria-hidden. They are paint, and the input already says everything.

Hide the checkbox, but keep it working

Hiding the input with display: none looks fine with a mouse, because the label still flips it. But an element with display: none cannot get focus. Tab skips it, Space does nothing, and screen readers do not find it.

Hide it from the eye only. This is the usual visually hidden pattern:

.switch input {
  position: absolute; width: 1px; height: 1px; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

Because the input is still there, Space toggles it like any checkbox. Since it is invisible, show its focus on the drawing instead:

.switch input:focus-visible + .track {
  outline: 3px solid #2563eb;
  outline-offset: 3px;
}

:focus-visible shows the ring for keyboard focus and usually not for a mouse click, so it does not flash on every tap.

role="switch" and the label text

Without a role, a screen reader describes the control as a checkbox that is checked or not checked. With role="switch", it describes a switch that is on or off. The exact words depend on the screen reader.

The label text is the switch's name. Keep it fixed, such as "Wi-Fi", and let the role carry the on or off state. If the label itself changes between "On" and "Off", the name and the announced state repeat or contradict each other.

Animate the knob with transform

The knob starts at left: 3px. When the input is checked, it moves by the track width minus the knob width, minus the two gaps:

.knob {
  position: absolute; left: 3px; top: 3px;
  width: 24px; height: 24px; border-radius: 50%;
  transition: transform .2s;
}
.switch input:checked + .track .knob { transform: translateX(22px); }

@media (prefers-reduced-motion: reduce) {
  .track, .knob { transition: none; }
}
Swapping left for right jumps. Moving one property between two values slides.
Swapping left for right jumps. Moving one property between two values slides.

Two details matter. The transition goes on the base rule, so it runs in both directions; on the :checked rule alone, only turning it on animates.

And the prefers-reduced-motion block lets people who turned off animation in their system settings get an instant flip. CSS transform covers translateX() in more detail.

Variants: size, colour, icons, text, disabled

Size the switch with three custom properties, and the knob and its travel follow:

.sw { --w: 48px; --h: 28px; --pad: 3px; }
.knob { width: calc(var(--h) - 2 * var(--pad)); }
.sw input:checked + .track .knob {
  transform: translateX(calc(var(--w) - var(--h)));
}
Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Toggle switch variants</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .grid { max-width: 560px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
  .card { background: #fff; border-radius: 12px; padding: 12px 14px; box-shadow: 0 3px 12px rgba(0,0,0,.07); }
  .card h2 { margin: 0 0 10px; font-size: 12px; color: #6b7280; text-transform: uppercase; letter-spacing: .4px; }
  .row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; }
  @media (max-width: 420px) { .grid { grid-template-columns: 1fr; } }

  /* one switch, sized by three variables */
  .sw { --w: 48px; --h: 28px; --pad: 3px; --on: #16a34a;
        display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }
  .sw input { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
  .track { position: relative; flex: none; width: var(--w); height: var(--h); border-radius: 99px;
           background: #c7ccd4; transition: background-color .2s; }
  .knob { position: absolute; left: var(--pad); top: var(--pad);
          width: calc(var(--h) - 2 * var(--pad)); height: calc(var(--h) - 2 * var(--pad));
          border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.35);
          display: grid; place-items: center; transition: transform .2s; }
  .sw input:checked + .track { background: var(--on); }
  .sw input:checked + .track .knob { transform: translateX(calc(var(--w) - var(--h))); }
  .sw input:focus-visible + .track { outline: 3px solid #2563eb; outline-offset: 2px; }
  .sw input:disabled + .track { opacity: .45; }
  .sw:has(input:disabled) { cursor: not-allowed; color: #9ca3af; }

  /* sizes */
  .sm { --w: 34px; --h: 20px; --pad: 2px; font-size: 13px; }
  .lg { --w: 64px; --h: 36px; --pad: 4px; font-size: 16px; }
  /* colours */
  .blue { --on: #2563eb; } .red { --on: #dc2626; } .ios { --on: #34c759; --w: 51px; --h: 31px; --pad: 2px; }

  /* icons inside the knob: show one or the other */
  .knob svg { width: 60%; height: 60%; }
  .knob .ic-on { display: none; }
  .sw input:checked + .track .ic-on { display: block; }
  .sw input:checked + .track .ic-off { display: none; }

  /* text inside the track */
  .txt { --w: 62px; }
  .txt .track::before { content: "OFF"; position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font: 700 10px system-ui, sans-serif; color: #4b5563; }
  .txt input:checked + .track::before { content: "ON"; left: 9px; right: auto; color: #fff; }

  /* text outside: the word follows the checkbox */
  .state::after { content: "Off"; color: #6b7280; }
  .sw input:checked ~ .state::after { content: "On"; color: #15803d; }

  /* button version: the state lives in aria-pressed */
  .tbtn { display: inline-flex; align-items: center; gap: 8px; border: 0; background: none; padding: 0; font: inherit; font-size: 14px; color: inherit; cursor: pointer; }
  .tbtn .track { --w: 48px; --h: 28px; --pad: 3px; }
  .tbtn[aria-pressed="true"] .track { background: #7c3aed; }
  .tbtn[aria-pressed="true"] .knob { transform: translateX(20px); }
  .tbtn:focus-visible .track { outline: 3px solid #2563eb; outline-offset: 2px; }
  .tbtn:focus-visible { outline: none; }

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

  #log { max-width: 560px; margin: 10px auto 0; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; background: #1d2330; color: #e5e7eb; font: 12.5px/1.5 ui-monospace, Consolas, monospace; }
</style>
</head>
<body>
<div class="grid">
  <div class="card"><h2>Sizes</h2><div class="row">
    <label class="sw sm"><input type="checkbox" role="switch" name="small"><span class="track" aria-hidden="true"><span class="knob"></span></span>Small</label>
    <label class="sw"><input type="checkbox" role="switch" name="medium" checked><span class="track" aria-hidden="true"><span class="knob"></span></span>Medium</label>
    <label class="sw lg"><input type="checkbox" role="switch" name="large"><span class="track" aria-hidden="true"><span class="knob"></span></span>Large</label>
  </div></div>

  <div class="card"><h2>Colours</h2><div class="row">
    <label class="sw blue"><input type="checkbox" role="switch" name="blue" checked><span class="track" aria-hidden="true"><span class="knob"></span></span>Blue</label>
    <label class="sw red"><input type="checkbox" role="switch" name="red" checked><span class="track" aria-hidden="true"><span class="knob"></span></span>Red</label>
    <label class="sw ios"><input type="checkbox" role="switch" name="ios" checked><span class="track" aria-hidden="true"><span class="knob"></span></span>iOS look</label>
  </div></div>

  <div class="card"><h2>Icon in the knob</h2><div class="row">
    <label class="sw lg"><input type="checkbox" role="switch" name="dark"><span class="track" aria-hidden="true"><span class="knob">
      <svg class="ic-off" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2.5"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1 7 17M17 7l2.1-2.1"/></svg>
      <svg class="ic-on" viewBox="0 0 24 24" fill="#16a34a"><path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z"/></svg>
    </span></span>Dark theme</label>
  </div></div>

  <div class="card"><h2>Text inside / outside</h2><div class="row">
    <label class="sw txt"><input type="checkbox" role="switch" name="inside"><span class="track" aria-hidden="true"><span class="knob"></span></span>Inside</label>
    <label class="sw"><input type="checkbox" role="switch" name="outside"><span class="track" aria-hidden="true"><span class="knob"></span></span>Outside: <span class="state" aria-hidden="true"></span></label>
  </div></div>

  <div class="card"><h2>Disabled</h2><div class="row">
    <label class="sw"><input type="checkbox" role="switch" name="locked-off" disabled><span class="track" aria-hidden="true"><span class="knob"></span></span>Off</label>
    <label class="sw"><input type="checkbox" role="switch" name="locked-on" checked disabled><span class="track" aria-hidden="true"><span class="knob"></span></span>On</label>
  </div></div>

  <div class="card"><h2>Button + aria-pressed</h2><div class="row">
    <button type="button" class="tbtn" aria-pressed="false" id="mute"><span class="track" aria-hidden="true"><span class="knob"></span></span>Mute</button>
  </div></div>
</div>
<div id="log" aria-live="polite">Click any switch.</div>

<script>
  const log = document.getElementById('log');

  // checkbox switches: the browser flips .checked for us
  document.querySelectorAll('.sw input').forEach((input) => {
    input.addEventListener('change', () => {
      log.textContent = input.name + ': ' + (input.checked ? 'on' : 'off');
    });
  });

  // button switch: we flip aria-pressed ourselves
  const mute = document.getElementById('mute');
  mute.addEventListener('click', () => {
    const on = mute.getAttribute('aria-pressed') !== 'true';
    mute.setAttribute('aria-pressed', on);
    log.textContent = 'mute (button): aria-pressed="' + on + '"';
  });
</script>
</body>
</html>
Sizes, colours, an icon in the knob, text inside and outside, disabled switches, and a button version at the end.
Variant How Watch out for
Small or large Change --w, --h, --pad Keep the whole label big enough to tap
Colour Change the :checked background The off track needs contrast with the page too
Icon in the knob Two inline SVGs, one shown per state Mark them aria-hidden with the track
On/Off text inside ::before text that swaps on :checked Leave room for longer words
On/Off text outside A span after the input, filled by :checked ~ .state Keep it aria-hidden; the role says it
Disabled The disabled attribute and :disabled + .track Also dim the text and change the cursor

The disabled switches cannot be focused or flipped. That comes from the real input, not from the CSS.

A button switch with aria-pressed

Sometimes the control is not a setting but an action you can undo, such as Mute in a player. A <button> with aria-pressed suits that. Screen readers announce it as a toggle button that is pressed or not pressed.

<button type="button" class="tbtn" aria-pressed="false">
  <span class="track" aria-hidden="true"><span class="knob"></span></span>
  Mute
</button>

A button holds no checked state, so a script flips aria-pressed on click, and the CSS reads [aria-pressed="true"]. A button answers both Enter and Space, and it is not sent with a form. The last card in the variants demo shows this version.

Switch or checkbox?

A switch acts at once. A checkbox waits for the form to be sent.
A switch acts at once. A checkbox waits for the form to be sent.

A switch looks like a light switch, so people expect the change to happen the moment they flip it. Use one where that is true: settings, a theme, turning notifications on.

If nothing happens until the user presses Save or Submit, use a plain checkbox.

In an order form, "Gift wrap" is a choice sent with the order, not something that takes effect right away. For simple two-way questions, yes and no buttons may fit better than either.

A finished example: settings that apply at once

This panel has no Save button. Each switch runs its change on the change event: the theme turns dark, the text grows, and the main notifications switch disables the two below it.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Settings with live switches</title>
<style>
  :root { --bg: #f4f5f7; --panel: #fff; --text: #1d2330; --muted: #6b7280; --line: #eef0f3; --off: #c7ccd4; --on: #16a34a; }
  body.dark { --bg: #0f141c; --panel: #1b2230; --text: #e5e7eb; --muted: #9aa3b2; --line: #2a3344; --off: #4b5563; --on: #22c55e; }
  body.big { font-size: 18px; }
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; font-size: 15px; background: var(--bg); color: var(--text); transition: background-color .25s, color .25s; }

  .panel { max-width: 440px; margin: 0 auto; background: var(--panel); border-radius: 14px; padding: 4px 16px; box-shadow: 0 4px 16px rgba(0,0,0,.1); }
  h1 { font-size: 1.05em; margin: 12px 0 2px; }
  h2 { font-size: .75em; letter-spacing: .5px; text-transform: uppercase; color: var(--muted); margin: 14px 0 0; }

  /* each row is one label: text + hidden checkbox + drawing */
  .row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; border-top: 1px solid var(--line); cursor: pointer; }
  .row small { display: block; color: var(--muted); font-size: .8em; margin-top: 2px; }
  .row.sub { padding-left: 16px; }
  .row:has(input:disabled) { cursor: not-allowed; opacity: .5; }

  .row input { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
  .track { position: relative; flex: none; width: 46px; height: 26px; border-radius: 99px; background: var(--off); transition: background-color .2s; }
  .knob { position: absolute; left: 3px; top: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.35); transition: transform .2s; }
  .row input:checked + .track { background: var(--on); }
  .row input:checked + .track .knob { transform: translateX(20px); }
  .row input:focus-visible + .track { outline: 3px solid #3b82f6; outline-offset: 2px; }

  @media (prefers-reduced-motion: reduce) { body, .track, .knob { transition: none; } }

  #status { max-width: 440px; margin: 12px auto 0; box-sizing: border-box; padding: 10px 12px; border-radius: 10px; background: #1d2330; color: #e5e7eb; font: 13px/1.5 ui-monospace, Consolas, monospace; }
  body.dark #status { background: #000; }
</style>
</head>
<body>
<div class="panel">
  <h1>Settings</h1>

  <h2>Display</h2>
  <label class="row"><span>Dark theme<small>Changes the page right away</small></span>
    <input type="checkbox" role="switch" id="dark"><span class="track" aria-hidden="true"><span class="knob"></span></span></label>
  <label class="row"><span>Larger text</span>
    <input type="checkbox" role="switch" id="big"><span class="track" aria-hidden="true"><span class="knob"></span></span></label>

  <h2>Notifications</h2>
  <label class="row"><span>Allow notifications<small>Turn off to silence everything below</small></span>
    <input type="checkbox" role="switch" id="notify" checked><span class="track" aria-hidden="true"><span class="knob"></span></span></label>
  <label class="row sub"><span>Email</span>
    <input type="checkbox" role="switch" id="email" checked><span class="track" aria-hidden="true"><span class="knob"></span></span></label>
  <label class="row sub"><span>Push</span>
    <input type="checkbox" role="switch" id="push"><span class="track" aria-hidden="true"><span class="knob"></span></span></label>
</div>
<p id="status" role="status">No changes yet.</p>

<script>
  const $ = (id) => document.getElementById(id);
  const status = $('status');

  // a switch acts the moment it changes: no Save button
  function say(name, on) { status.textContent = name + ' ' + (on ? 'on' : 'off') + '. Applied.'; }

  $('dark').addEventListener('change', (e) => {
    document.body.classList.toggle('dark', e.target.checked);
    say('Dark theme', e.target.checked);
  });

  $('big').addEventListener('change', (e) => {
    document.body.classList.toggle('big', e.target.checked);
    say('Larger text', e.target.checked);
  });

  // the main switch enables or disables the two below it
  $('notify').addEventListener('change', (e) => {
    $('email').disabled = $('push').disabled = !e.target.checked;
    say('Notifications', e.target.checked);
  });

  $('email').addEventListener('change', (e) => say('Email', e.target.checked));
  $('push').addEventListener('change', (e) => say('Push', e.target.checked));
</script>
</body>
</html>
Flip Dark theme and the whole panel changes. Turn off Allow notifications and Email and Push become disabled.
  • One listener per switch: change fires for a click, a tap and Space.
  • Theme with variables: the dark switch adds a class to body, and CSS custom properties swap the colours. Dark mode CSS covers the full pattern.
  • Status line: a role="status" box repeats the last change, so it is announced too.

When it does not work

What you see Cause Fix
Works with a mouse, but Tab skips it The input is display: none or visibility: hidden Use the visually hidden pattern
Clicking the knob or track does nothing The spans sit outside the label, or for does not match the input id Put everything inside the label, or match for and id exactly
The track colour never changes The spans come before the input, so + finds nothing Put the input directly before .track
The knob jumps instead of sliding It moves from left to right, or the transition is only on :checked Keep left fixed, move with transform, transition on the base rule
No focus ring on the switch The ring is on the hidden input Style input:focus-visible + .track
Screen reader says checkbox, or no name No role="switch", or no text in the label Add the role and visible label text
Users flip it and wait for something The switch sits in a form that only applies on submit Apply the change at once, or use a checkbox

A switch is something people want to click, and a screenshot cannot be clicked. An .html attachment may open as plain text, or not at all, on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can flip the switches and watch the theme change. If you change the code later, the same link shows the new version.

Questions people ask

Can I make a toggle switch with only HTML and CSS?

Yes. A visually hidden checkbox inside a label holds the state, and CSS draws the track and knob with the :checked selector. JavaScript is only needed when flipping the switch should do something, such as changing the theme.

Why use a checkbox instead of a div with a click handler?

A checkbox already takes keyboard focus, flips with Space, supports disabled, is announced by screen readers and is sent with a form. A div has none of that until you rebuild each part in JavaScript.

What does role="switch" change?

It changes how assistive technology describes the control. Screen readers announce it as a switch that is on or off, instead of a checkbox that is checked or not checked. The behaviour in the browser stays the same.

How do I make an iOS style toggle switch in CSS?

Use the same checkbox pattern with a rounded track, a white round knob with a soft shadow, and a green colour for the on state. The variants demo in this guide has one; only the size and colour variables differ from the plain switch.

Should the switch say On and Off inside the track?

It can, as long as the words are decoration. Mark the drawing aria-hidden, because the switch role already announces the state. Leave room for the longest word, since a fixed track may not fit other languages.

Keep reading