47 CSS Toggles & Switches27 / 47

CSS + JSMIT licensed

Accessible ARIA role=switch Toggle

The reference implementation to ship in a design system: a button[role=switch] toggle with correct aria-checked handling, visible state text, a spec-grade focus ring — and it stays fully functional and visible in Windows High Contrast / forced-colors mode, where most CSS toggles silently vanish.

Published

Live Demo
Try it

The code

<section class="tgl-27" aria-label="Accessible ARIA switch">
  <div class="tgl-27__card">
    <div class="tgl-27__head"><b>Live captions</b><span>Immediate effect · not a form value</span></div>
    <button type="button" class="tgl-27__btn" id="tgl-27-btn" role="switch" aria-checked="false">
      <span class="tgl-27__track" aria-hidden="true"><span class="tgl-27__thumb"></span></span>
      <span class="tgl-27__state" aria-hidden="true"><i class="tgl-27__off">Off</i><i class="tgl-27__on">On</i></span>
    </button>
    <p class="tgl-27__live" id="tgl-27-live" aria-live="polite"></p>
    <p class="tgl-27__note">Space or Enter toggles · survives Windows High Contrast</p>
  </div>
</section>
.tgl-27,
.tgl-27 *,
.tgl-27 *::before,
.tgl-27 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.tgl-27 {
  --on: oklch(0.6 0.15 250);
  --track: #d6d9e0;
  --ring: oklch(0.6 0.15 250);
  font-family: 'Segoe UI',system-ui,sans-serif;
  width: 100%;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 60px 20px;
  background: #f5f6f9;
}

.tgl-27__card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
  background: #fff;
  border: 1px solid #e6e8ee;
  border-radius: 18px;
  padding: 28px 34px;
  width: min(340px,100%);
}

.tgl-27__head {
  text-align: center;
  display: flex;
  flex-direction: column;
  gap: 3px;
}

.tgl-27__head b {
  font-size: 17px;
  color: #242938;
}

.tgl-27__head span {
  font-size: 12.5px;
  color: #8a90a2;
}

.tgl-27__btn {
  display: flex;
  align-items: center;
  gap: 14px;
  border: none;
  background: none;
  cursor: pointer;
  padding: 6px 10px;
  border-radius: 12px;
  font: inherit;
}

.tgl-27__track {
  position: relative;
  display: block;
  width: 60px;
  height: 34px;
  border-radius: 18px;
  background: var(--track);
  transition: background-color .25s;
}

.tgl-27__btn[aria-checked="true"] .tgl-27__track {
  background: var(--on);
}

.tgl-27__thumb {
  position: absolute;
  top: 3px;
  left: 3px;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 2px 6px rgba(25,30,60,.35);
  transition: transform .25s cubic-bezier(.3,1,.35,1.05);
}

.tgl-27__btn[aria-checked="true"] .tgl-27__thumb {
  transform: translateX(26px);
}

.tgl-27__state {
  position: relative;
  width: 32px;
  height: 1.3em;
  font-size: 15px;
  font-weight: 700;
  color: #3c4358;
  text-align: left;
}

.tgl-27__state i {
  position: absolute;
  inset: 0;
  font-style: normal;
  transition: opacity .2s;
}

.tgl-27__on {
  opacity: 0;
}

.tgl-27__btn[aria-checked="true"] .tgl-27__off {
  opacity: 0;
}

.tgl-27__btn[aria-checked="true"] .tgl-27__on {
  opacity: 1;
  color: var(--on);
}

.tgl-27__btn:focus-visible {
  outline: 3px solid var(--ring);
  outline-offset: 2px;
}

.tgl-27__live {
  font-size: 12.5px;
  color: #8a90a2;
  min-height: 1.2em;
}

.tgl-27__note {
  font-size: 11.5px;
  color: #a7acbc;
  text-align: center;
}

@media (forced-colors: active) {
  .tgl-27__track {
    background: Canvas;
    border: 2px solid ButtonText;
  }

  .tgl-27__btn[aria-checked="true"] .tgl-27__track {
    background: Highlight;
    border-color: Highlight;
  }

  .tgl-27__thumb {
    background: ButtonText;
    border: 2px solid ButtonText;
    box-shadow: none;
  }

  .tgl-27__btn[aria-checked="true"] .tgl-27__thumb {
    background: Canvas;
    border-color: Canvas;
  }

  .tgl-27__btn:focus-visible {
    outline: 3px solid Highlight;
  }
}

@media (prefers-reduced-motion: reduce) {
  .tgl-27__track,
    .tgl-27__thumb,
    .tgl-27__state i {
    transition: none;
  }
}
(() => {
  const btn = document.querySelector('#tgl-27-btn');
  const live = document.querySelector('#tgl-27-live');
  btn.addEventListener('click', () => {
    const next = btn.getAttribute('aria-checked') !== 'true';
    btn.setAttribute('aria-checked', String(next));
    live.textContent = next ? 'Live captions on' : 'Live captions off';
  });
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Toggles & Switche from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Accessible ARIA role=switch Toggle
Source: https://codefronts.com/components/css-toggles-switches/accessible-aria-role-switch-toggle/

The reference implementation to ship in a design system: a button[role=switch] toggle with correct aria-checked handling, visible state text, a spec-grade focus ring — and it stays fully functional and visible in Windows High Contrast / forced-colors mode, where most CSS toggles silently vanish.
## HTML
```html
<section class="tgl-27" aria-label="Accessible ARIA switch">
  <div class="tgl-27__card">
    <div class="tgl-27__head"><b>Live captions</b><span>Immediate effect · not a form value</span></div>
    <button type="button" class="tgl-27__btn" id="tgl-27-btn" role="switch" aria-checked="false">
      <span class="tgl-27__track" aria-hidden="true"><span class="tgl-27__thumb"></span></span>
      <span class="tgl-27__state" aria-hidden="true"><i class="tgl-27__off">Off</i><i class="tgl-27__on">On</i></span>
    </button>
    <p class="tgl-27__live" id="tgl-27-live" aria-live="polite"></p>
    <p class="tgl-27__note">Space or Enter toggles · survives Windows High Contrast</p>
  </div>
</section>
```
## CSS
```css
.tgl-27,
.tgl-27 *,
.tgl-27 *::before,
.tgl-27 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.tgl-27 {
  --on: oklch(0.6 0.15 250);
  --track: #d6d9e0;
  --ring: oklch(0.6 0.15 250);
  font-family: 'Segoe UI',system-ui,sans-serif;
  width: 100%;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 60px 20px;
  background: #f5f6f9;
}

.tgl-27__card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
  background: #fff;
  border: 1px solid #e6e8ee;
  border-radius: 18px;
  padding: 28px 34px;
  width: min(340px,100%);
}

.tgl-27__head {
  text-align: center;
  display: flex;
  flex-direction: column;
  gap: 3px;
}

.tgl-27__head b {
  font-size: 17px;
  color: #242938;
}

.tgl-27__head span {
  font-size: 12.5px;
  color: #8a90a2;
}

.tgl-27__btn {
  display: flex;
  align-items: center;
  gap: 14px;
  border: none;
  background: none;
  cursor: pointer;
  padding: 6px 10px;
  border-radius: 12px;
  font: inherit;
}

.tgl-27__track {
  position: relative;
  display: block;
  width: 60px;
  height: 34px;
  border-radius: 18px;
  background: var(--track);
  transition: background-color .25s;
}

.tgl-27__btn[aria-checked="true"] .tgl-27__track {
  background: var(--on);
}

.tgl-27__thumb {
  position: absolute;
  top: 3px;
  left: 3px;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 2px 6px rgba(25,30,60,.35);
  transition: transform .25s cubic-bezier(.3,1,.35,1.05);
}

.tgl-27__btn[aria-checked="true"] .tgl-27__thumb {
  transform: translateX(26px);
}

.tgl-27__state {
  position: relative;
  width: 32px;
  height: 1.3em;
  font-size: 15px;
  font-weight: 700;
  color: #3c4358;
  text-align: left;
}

.tgl-27__state i {
  position: absolute;
  inset: 0;
  font-style: normal;
  transition: opacity .2s;
}

.tgl-27__on {
  opacity: 0;
}

.tgl-27__btn[aria-checked="true"] .tgl-27__off {
  opacity: 0;
}

.tgl-27__btn[aria-checked="true"] .tgl-27__on {
  opacity: 1;
  color: var(--on);
}

.tgl-27__btn:focus-visible {
  outline: 3px solid var(--ring);
  outline-offset: 2px;
}

.tgl-27__live {
  font-size: 12.5px;
  color: #8a90a2;
  min-height: 1.2em;
}

.tgl-27__note {
  font-size: 11.5px;
  color: #a7acbc;
  text-align: center;
}

@media (forced-colors: active) {
  .tgl-27__track {
    background: Canvas;
    border: 2px solid ButtonText;
  }

  .tgl-27__btn[aria-checked="true"] .tgl-27__track {
    background: Highlight;
    border-color: Highlight;
  }

  .tgl-27__thumb {
    background: ButtonText;
    border: 2px solid ButtonText;
    box-shadow: none;
  }

  .tgl-27__btn[aria-checked="true"] .tgl-27__thumb {
    background: Canvas;
    border-color: Canvas;
  }

  .tgl-27__btn:focus-visible {
    outline: 3px solid Highlight;
  }
}

@media (prefers-reduced-motion: reduce) {
  .tgl-27__track,
    .tgl-27__thumb,
    .tgl-27__state i {
    transition: none;
  }
}
```

## JavaScript
```js
(() => {
  const btn = document.querySelector('#tgl-27-btn');
  const live = document.querySelector('#tgl-27-live');
  btn.addEventListener('click', () => {
    const next = btn.getAttribute('aria-checked') !== 'true';
    btn.setAttribute('aria-checked', String(next));
    live.textContent = next ? 'Live captions on' : 'Live captions off';
  });
})();
```

How this works

When a toggle isn't a form control (no submitted value — it acts immediately, like play/pause), the correct pattern is <button role="switch" aria-checked>: buttons give Space AND Enter, focusability and AT semantics for free, and three lines of JS flip aria-checked — which is also the CSS state hook ([aria-checked="true"]), so visuals can never desync from what screen readers announce.

The forced-colors work is the differentiator: inside @media (forced-colors:active), decorative backgrounds are replaced with system colors (ButtonText, Highlight, Canvas), the thumb gets a real border (backgrounds are stripped in that mode), and state remains legible because it's also encoded in the visible ON/OFF text. A live region politely announces changes.

Make it yours

  • Reuse the pattern with any visuals — only the [aria-checked] selectors matter.
  • Swap the announcement strings, or drop the live region if the switch's context is self-evident.
  • For settings that SUBMIT with a form, use a real checkbox (demos 01–26) instead — this pattern is for immediate-action toggles.
  • Theme via the three custom properties; forced-colors overrides are already isolated.

Gotchas — read before shipping

  • role=switch + aria-checked is a contract: update the attribute, never a class, or SRs announce stale state.
  • In forced-colors mode backgrounds are removed — anything stateful must survive as borders or text (tested here).
  • Don't add aria-live to the button itself; announce via a separate polite region to avoid double-speak.

Browser support

ChromeSafariFirefoxEdge
111+15.4+113+111+

Floor set by oklch() as this demo is written. The core technique itself goes back further — Chrome 89+.

button[role=switch] works in all browsers and screen readers; forced-colors media query is Chromium/Edge + Firefox (Windows HCM). Safari simply ignores that block.

Techniques used in this demo

Search CodeFronts

Loading…