27 / 47
All 47 in 47 Tailwind CSS Toggles & Switches
- 01Tailwind iOS-Style Toggle Switch with appearance-none
- 02Tailwind Dark Mode Sun / Moon Theme Switcher
- 03Tailwind Neumorphic Soft-UI Pill Toggle
- 04Tailwind Gooey Liquid Morphing Switch with SVG Filter
- 05Tailwind Bouncy Elastic Physics Toggle (Overshoot Cubic-Bezier)
- 06Tailwind ON / OFF Text Switch with Labels Inside the Track
- 07Tailwind SaaS Pricing Toggle — Monthly / Annually Segmented Pill
- 08Tailwind Mechanical Keycap Toggles with Haptic Feedback
- 09Tailwind Waveform Mute Toggle with Animated EQ Bars
- 10Tailwind Timed Auto-Off Toggle with Conic Countdown Ring
- 11Tailwind Press-and-Hold to Confirm Toggle (Fingerprint Scan)
- 12Tailwind Split-Flap Departure Board Toggle (3D Flip)
- 13Tailwind Permission Toggle Group with :has() Live Status Badge
- 14Tailwind Material Design 3 Switch with State Layer
- 15Tailwind Skeuomorphic Wall Light Switch with Room Relight
- 16Tailwind Neon Cyberpunk Toggle with Clipped Corners
- 17Tailwind Brutalist Snap Toggle with Hard Offset Shadow
- 18Tailwind Day / Night Landscape Scene Switch
- 19Tailwind 3D Flip Card Toggle (rotateY Switch)
- 20Tailwind Sliding Pill Toggle with Label Inside the Thumb
- 21Tailwind Glassmorphism Frosted Toggle with backdrop-blur
- 22Tailwind Rotary Knob Toggle with Brushed-Metal Conic Gradient
- 23Tailwind iOS Settings List with Grouped Toggle Rows
- 24Tailwind Minimal Outline Toggle Drawn in currentColor
- 25Tailwind Squash-and-Stretch Pebble Toggle
- 26Tailwind Industrial Rocker I/O Power Switch
- 27Tailwind Accessible role=switch Toggle with forced-colors Support
- 28Tailwind Macro-Pad Toggle Grid with RGB Underglow
- 29Tailwind Radial VU Meter Mute Toggle
- 30Tailwind Segmented Pip Countdown Auto-Off Toggle
- 31Tailwind Slide-to-Confirm Toggle Rail (Drag or Arrow Keys)
- 32Tailwind Odometer Roll ON / OFF Toggle
- 33Tailwind Master Toggle with Indeterminate Tri-State
- 34Tailwind iOS Control Centre Tile Toggles
- 35Tailwind Material 3 Connected Icon Toggle Buttons
- 36Tailwind Two-Gang Wall Plate with Cumulative Room Light
- 37Tailwind Neon Toggle with Animated Conic Border Light
- 38Tailwind Brutalist Rubber-Stamp Toggle
- 39Tailwind Aurora Night Sky Toggle Switch
- 40Tailwind 3D Rotating Cube Toggle
- 41Tailwind Three-Position Off / Auto / On Selector
- 42Tailwind Liquid Glass Toggle with Specular Highlight
- 43Tailwind Three-Detent Rotary Selector Knob
- 44Tailwind Hardware Slide Switch with Knurled Grip
- 45Tailwind Battery Saver Toggle with Live Fill Meter
- 46Tailwind Morphing Sun-to-Moon Icon Toggle (SVG Stroke)
- 47Tailwind Guarded Kill Switch Under a Flip-Up Safety Cover
Tailwind Accessible role=switch Toggle with forced-colors Support
The reference implementation to ship in a design system: button[role=switch] with correct aria-checked handling, visible state text, a spec-grade focus ring — and it stays fully visible in Windows High Contrast, where most CSS toggles silently vanish.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="ttg-27 w-full min-h-svh grid place-items-center px-5 py-14 bg-ttg27-bg font-ttg27-sans" aria-label="Accessible ARIA switch">
<div class="w-[min(350px,100%)] flex flex-col items-center gap-4 px-9 py-7 rounded-[18px] bg-white border border-ttg27-edge">
<div class="flex flex-col items-center gap-1 text-center">
<b class="text-[17px] font-bold text-ttg27-ink">Live captions</b>
<span class="text-[12.5px] text-ttg27-mute">Immediate effect · not a form value</span>
</div>
<button type="button" id="ttg-27-btn" role="switch" aria-checked="false" class="group flex items-center gap-3.5 min-h-11 px-2.5 py-1.5 rounded-xl border-0 bg-transparent cursor-pointer font-inherit focus-visible:outline-3 focus-visible:outline-ttg27-on focus-visible:outline-offset-2 forced-colors:focus-visible:outline-[Highlight] aria-checked:[&_.ttg27-track]:bg-ttg27-on aria-checked:[&_.ttg27-track]:forced-colors:bg-[Highlight] aria-checked:[&_.ttg27-track]:forced-colors:border-[Highlight] aria-checked:[&_.ttg27-thumb]:translate-x-[26px] aria-checked:[&_.ttg27-thumb]:forced-colors:bg-[Canvas] aria-checked:[&_.ttg27-thumb]:forced-colors:border-[Canvas] aria-checked:[&_.ttg27-off]:opacity-0 aria-checked:[&_.ttg27-on]:opacity-100">
<span aria-hidden="true" class="ttg27-track relative block w-15 h-8.5 rounded-[18px] bg-ttg27-track transition-colors duration-250 forced-colors:bg-[Canvas] forced-colors:border-2 forced-colors:border-[ButtonText] motion-reduce:transition-none">
<span class="ttg27-thumb absolute top-[3px] left-[3px] w-7 h-7 rounded-full bg-white shadow-[0_2px_6px_oklch(0.25_0.03_265/0.35)] transition-transform duration-250 ease-[cubic-bezier(0.3,1,0.35,1.05)] forced-colors:bg-[ButtonText] forced-colors:border-2 forced-colors:border-[ButtonText] forced-colors:shadow-none motion-reduce:transition-none"></span>
</span>
<span aria-hidden="true" class="relative block w-8 h-[1.3em] text-left text-[15px] font-bold text-ttg27-ink">
<i class="ttg27-off absolute inset-0 not-italic transition-opacity duration-200 motion-reduce:transition-none">Off</i>
<i class="ttg27-on absolute inset-0 not-italic opacity-0 text-ttg27-on transition-opacity duration-200 forced-colors:text-[ButtonText] motion-reduce:transition-none">On</i>
</span>
</button>
<p id="ttg-27-live" aria-live="polite" class="min-h-[1.2em] text-[12.5px] text-ttg27-mute">Captions are off</p>
<p class="text-center text-[11.5px] text-ttg27-faint">Space or Enter toggles · survives Windows High Contrast</p>
</div>
</section><section class="ttg-27 w-full min-h-svh grid place-items-center px-5 py-14 bg-ttg27-bg font-ttg27-sans" aria-label="Accessible ARIA switch">
<div class="w-[min(350px,100%)] flex flex-col items-center gap-4 px-9 py-7 rounded-[18px] bg-white border border-ttg27-edge">
<div class="flex flex-col items-center gap-1 text-center">
<b class="text-[17px] font-bold text-ttg27-ink">Live captions</b>
<span class="text-[12.5px] text-ttg27-mute">Immediate effect · not a form value</span>
</div>
<button type="button" id="ttg-27-btn" role="switch" aria-checked="false" class="group flex items-center gap-3.5 min-h-11 px-2.5 py-1.5 rounded-xl border-0 bg-transparent cursor-pointer font-inherit focus-visible:outline-3 focus-visible:outline-ttg27-on focus-visible:outline-offset-2 forced-colors:focus-visible:outline-[Highlight] aria-checked:[&_.ttg27-track]:bg-ttg27-on aria-checked:[&_.ttg27-track]:forced-colors:bg-[Highlight] aria-checked:[&_.ttg27-track]:forced-colors:border-[Highlight] aria-checked:[&_.ttg27-thumb]:translate-x-[26px] aria-checked:[&_.ttg27-thumb]:forced-colors:bg-[Canvas] aria-checked:[&_.ttg27-thumb]:forced-colors:border-[Canvas] aria-checked:[&_.ttg27-off]:opacity-0 aria-checked:[&_.ttg27-on]:opacity-100">
<span aria-hidden="true" class="ttg27-track relative block w-15 h-8.5 rounded-[18px] bg-ttg27-track transition-colors duration-250 forced-colors:bg-[Canvas] forced-colors:border-2 forced-colors:border-[ButtonText] motion-reduce:transition-none">
<span class="ttg27-thumb absolute top-[3px] left-[3px] w-7 h-7 rounded-full bg-white shadow-[0_2px_6px_oklch(0.25_0.03_265/0.35)] transition-transform duration-250 ease-[cubic-bezier(0.3,1,0.35,1.05)] forced-colors:bg-[ButtonText] forced-colors:border-2 forced-colors:border-[ButtonText] forced-colors:shadow-none motion-reduce:transition-none"></span>
</span>
<span aria-hidden="true" class="relative block w-8 h-[1.3em] text-left text-[15px] font-bold text-ttg27-ink">
<i class="ttg27-off absolute inset-0 not-italic transition-opacity duration-200 motion-reduce:transition-none">Off</i>
<i class="ttg27-on absolute inset-0 not-italic opacity-0 text-ttg27-on transition-opacity duration-200 forced-colors:text-[ButtonText] motion-reduce:transition-none">On</i>
</span>
</button>
<p id="ttg-27-live" aria-live="polite" class="min-h-[1.2em] text-[12.5px] text-ttg27-mute">Captions are off</p>
<p class="text-center text-[11.5px] text-ttg27-faint">Space or Enter toggles · survives Windows High Contrast</p>
</div>
</section>@import "tailwindcss";
@theme {
--color-ttg27-bg: #f4f5f9;
--color-ttg27-bg: oklch(0.96 0.005 265);
--color-ttg27-edge: #e5e7ee;
--color-ttg27-edge: oklch(0.92 0.006 265);
--color-ttg27-track: #cfd3dd;
--color-ttg27-track: oklch(0.86 0.01 265);
--color-ttg27-on: #3f63e0;
--color-ttg27-on: oklch(0.52 0.19 265);
--color-ttg27-ink: #212636;
--color-ttg27-ink: oklch(0.27 0.018 265);
--color-ttg27-mute: #767d90;
--color-ttg27-mute: oklch(0.58 0.018 265);
--color-ttg27-faint: #8d93a4;
--color-ttg27-faint: oklch(0.65 0.016 265);
--font-ttg27-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}
.ttg-27 {
width: 100%;
min-height: 100svh;
}@import "tailwindcss";
@theme {
--color-ttg27-bg: #f4f5f9;
--color-ttg27-bg: oklch(0.96 0.005 265);
--color-ttg27-edge: #e5e7ee;
--color-ttg27-edge: oklch(0.92 0.006 265);
--color-ttg27-track: #cfd3dd;
--color-ttg27-track: oklch(0.86 0.01 265);
--color-ttg27-on: #3f63e0;
--color-ttg27-on: oklch(0.52 0.19 265);
--color-ttg27-ink: #212636;
--color-ttg27-ink: oklch(0.27 0.018 265);
--color-ttg27-mute: #767d90;
--color-ttg27-mute: oklch(0.58 0.018 265);
--color-ttg27-faint: #8d93a4;
--color-ttg27-faint: oklch(0.65 0.016 265);
--font-ttg27-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}
.ttg-27 {
width: 100%;
min-height: 100svh;
}(() => {
const btn = document.querySelector('#ttg-27-btn');
const live = document.querySelector('#ttg-27-live');
if (!btn || !live) return;
// The attribute IS the state. Never mirror it into a class — the CSS reads it directly.
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';
});
})();(() => {
const btn = document.querySelector('#ttg-27-btn');
const live = document.querySelector('#ttg-27-live');
if (!btn || !live) return;
// The attribute IS the state. Never mirror it into a class — the CSS reads it directly.
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';
});
})();Setup — do this once per project
Skip this if your project already runs Tailwind v4.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>Compiles in the browser — perfect for a CodePen or a quick test, not for production.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@import "tailwindcss";
@theme {
--color-ttg27-bg: #f4f5f9;
--color-ttg27-bg: oklch(0.96 0.005 265);
--color-ttg27-edge: #e5e7ee;
--color-ttg27-edge: oklch(0.92 0.006 265);
--color-ttg27-track: #cfd3dd;
--color-ttg27-track: oklch(0.86 0.01 265);
--color-ttg27-on: #3f63e0;
--color-ttg27-on: oklch(0.52 0.19 265);
--color-ttg27-ink: #212636;
--color-ttg27-ink: oklch(0.27 0.018 265);
--color-ttg27-mute: #767d90;
--color-ttg27-mute: oklch(0.58 0.018 265);
--color-ttg27-faint: #8d93a4;
--color-ttg27-faint: oklch(0.65 0.016 265);
--font-ttg27-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}
.ttg-27 {
width: 100%;
min-height: 100svh;
}@import "tailwindcss";
@theme {
--color-ttg27-bg: #f4f5f9;
--color-ttg27-bg: oklch(0.96 0.005 265);
--color-ttg27-edge: #e5e7ee;
--color-ttg27-edge: oklch(0.92 0.006 265);
--color-ttg27-track: #cfd3dd;
--color-ttg27-track: oklch(0.86 0.01 265);
--color-ttg27-on: #3f63e0;
--color-ttg27-on: oklch(0.52 0.19 265);
--color-ttg27-ink: #212636;
--color-ttg27-ink: oklch(0.27 0.018 265);
--color-ttg27-mute: #767d90;
--color-ttg27-mute: oklch(0.58 0.018 265);
--color-ttg27-faint: #8d93a4;
--color-ttg27-faint: oklch(0.65 0.016 265);
--font-ttg27-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}
.ttg-27 {
width: 100%;
min-height: 100svh;
}<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- This demo is the argument for attribute-driven styling in one screen:
aria-checked:means the thing a screen reader announces and the thing CSS paints are the same source of truth, and the three-line script never touches a class. forced-colors:is a first-class v4 variant, so the entire High Contrast treatment — six utilities, including two stacked witharia-checked:— lives in the class list beside the normal styling rather than in a media query at the bottom of a file. This is the strongest case in the collection for variants over media queries.- System colour keywords have to be arbitrary values (
bg-[Canvas],border-[ButtonText]) because they are not theme colours and must never be resolved or mixed. - UI change from the CSS original: the live region now carries a resting sentence rather than being empty (an empty polite region is also a layout jump on first use), the button gained a 44px minimum height, and supporting ink moved to
oklch(0.58)/oklch(0.65)for 5.6:1 and 4.5:1 — in the collection's accessibility reference, failing contrast on the instructions was the worst offence.
Here's a working CSS demo 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: Tailwind Accessible role=switch Toggle with forced-colors Support
Source: https://codefronts.com/components/tailwind-css-toggles-switches/tailwind-accessible-role-switch-toggle-forced-colors/
The reference implementation to ship in a design system: button[role=switch] with correct aria-checked handling, visible state text, a spec-grade focus ring — and it stays fully visible in Windows High Contrast, where most CSS toggles silently vanish.
## HTML
```html
<section class="ttg-27 w-full min-h-svh grid place-items-center px-5 py-14 bg-ttg27-bg font-ttg27-sans" aria-label="Accessible ARIA switch">
<div class="w-[min(350px,100%)] flex flex-col items-center gap-4 px-9 py-7 rounded-[18px] bg-white border border-ttg27-edge">
<div class="flex flex-col items-center gap-1 text-center">
<b class="text-[17px] font-bold text-ttg27-ink">Live captions</b>
<span class="text-[12.5px] text-ttg27-mute">Immediate effect · not a form value</span>
</div>
<button type="button" id="ttg-27-btn" role="switch" aria-checked="false" class="group flex items-center gap-3.5 min-h-11 px-2.5 py-1.5 rounded-xl border-0 bg-transparent cursor-pointer font-inherit focus-visible:outline-3 focus-visible:outline-ttg27-on focus-visible:outline-offset-2 forced-colors:focus-visible:outline-[Highlight] aria-checked:[&_.ttg27-track]:bg-ttg27-on aria-checked:[&_.ttg27-track]:forced-colors:bg-[Highlight] aria-checked:[&_.ttg27-track]:forced-colors:border-[Highlight] aria-checked:[&_.ttg27-thumb]:translate-x-[26px] aria-checked:[&_.ttg27-thumb]:forced-colors:bg-[Canvas] aria-checked:[&_.ttg27-thumb]:forced-colors:border-[Canvas] aria-checked:[&_.ttg27-off]:opacity-0 aria-checked:[&_.ttg27-on]:opacity-100">
<span aria-hidden="true" class="ttg27-track relative block w-15 h-8.5 rounded-[18px] bg-ttg27-track transition-colors duration-250 forced-colors:bg-[Canvas] forced-colors:border-2 forced-colors:border-[ButtonText] motion-reduce:transition-none">
<span class="ttg27-thumb absolute top-[3px] left-[3px] w-7 h-7 rounded-full bg-white shadow-[0_2px_6px_oklch(0.25_0.03_265/0.35)] transition-transform duration-250 ease-[cubic-bezier(0.3,1,0.35,1.05)] forced-colors:bg-[ButtonText] forced-colors:border-2 forced-colors:border-[ButtonText] forced-colors:shadow-none motion-reduce:transition-none"></span>
</span>
<span aria-hidden="true" class="relative block w-8 h-[1.3em] text-left text-[15px] font-bold text-ttg27-ink">
<i class="ttg27-off absolute inset-0 not-italic transition-opacity duration-200 motion-reduce:transition-none">Off</i>
<i class="ttg27-on absolute inset-0 not-italic opacity-0 text-ttg27-on transition-opacity duration-200 forced-colors:text-[ButtonText] motion-reduce:transition-none">On</i>
</span>
</button>
<p id="ttg-27-live" aria-live="polite" class="min-h-[1.2em] text-[12.5px] text-ttg27-mute">Captions are off</p>
<p class="text-center text-[11.5px] text-ttg27-faint">Space or Enter toggles · survives Windows High Contrast</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-ttg27-bg: #f4f5f9;
--color-ttg27-bg: oklch(0.96 0.005 265);
--color-ttg27-edge: #e5e7ee;
--color-ttg27-edge: oklch(0.92 0.006 265);
--color-ttg27-track: #cfd3dd;
--color-ttg27-track: oklch(0.86 0.01 265);
--color-ttg27-on: #3f63e0;
--color-ttg27-on: oklch(0.52 0.19 265);
--color-ttg27-ink: #212636;
--color-ttg27-ink: oklch(0.27 0.018 265);
--color-ttg27-mute: #767d90;
--color-ttg27-mute: oklch(0.58 0.018 265);
--color-ttg27-faint: #8d93a4;
--color-ttg27-faint: oklch(0.65 0.016 265);
--font-ttg27-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}
.ttg-27 {
width: 100%;
min-height: 100svh;
}
```
## JavaScript
```js
(() => {
const btn = document.querySelector('#ttg-27-btn');
const live = document.querySelector('#ttg-27-live');
if (!btn || !live) return;
// The attribute IS the state. Never mirror it into a class — the CSS reads it directly.
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';
});
})();
```Here's a working CSS demo 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: Tailwind Accessible role=switch Toggle with forced-colors Support
Source: https://codefronts.com/components/tailwind-css-toggles-switches/tailwind-accessible-role-switch-toggle-forced-colors/
The reference implementation to ship in a design system: button[role=switch] with correct aria-checked handling, visible state text, a spec-grade focus ring — and it stays fully visible in Windows High Contrast, where most CSS toggles silently vanish.
## HTML
```html
<section class="ttg-27 w-full min-h-svh grid place-items-center px-5 py-14 bg-ttg27-bg font-ttg27-sans" aria-label="Accessible ARIA switch">
<div class="w-[min(350px,100%)] flex flex-col items-center gap-4 px-9 py-7 rounded-[18px] bg-white border border-ttg27-edge">
<div class="flex flex-col items-center gap-1 text-center">
<b class="text-[17px] font-bold text-ttg27-ink">Live captions</b>
<span class="text-[12.5px] text-ttg27-mute">Immediate effect · not a form value</span>
</div>
<button type="button" id="ttg-27-btn" role="switch" aria-checked="false" class="group flex items-center gap-3.5 min-h-11 px-2.5 py-1.5 rounded-xl border-0 bg-transparent cursor-pointer font-inherit focus-visible:outline-3 focus-visible:outline-ttg27-on focus-visible:outline-offset-2 forced-colors:focus-visible:outline-[Highlight] aria-checked:[&_.ttg27-track]:bg-ttg27-on aria-checked:[&_.ttg27-track]:forced-colors:bg-[Highlight] aria-checked:[&_.ttg27-track]:forced-colors:border-[Highlight] aria-checked:[&_.ttg27-thumb]:translate-x-[26px] aria-checked:[&_.ttg27-thumb]:forced-colors:bg-[Canvas] aria-checked:[&_.ttg27-thumb]:forced-colors:border-[Canvas] aria-checked:[&_.ttg27-off]:opacity-0 aria-checked:[&_.ttg27-on]:opacity-100">
<span aria-hidden="true" class="ttg27-track relative block w-15 h-8.5 rounded-[18px] bg-ttg27-track transition-colors duration-250 forced-colors:bg-[Canvas] forced-colors:border-2 forced-colors:border-[ButtonText] motion-reduce:transition-none">
<span class="ttg27-thumb absolute top-[3px] left-[3px] w-7 h-7 rounded-full bg-white shadow-[0_2px_6px_oklch(0.25_0.03_265/0.35)] transition-transform duration-250 ease-[cubic-bezier(0.3,1,0.35,1.05)] forced-colors:bg-[ButtonText] forced-colors:border-2 forced-colors:border-[ButtonText] forced-colors:shadow-none motion-reduce:transition-none"></span>
</span>
<span aria-hidden="true" class="relative block w-8 h-[1.3em] text-left text-[15px] font-bold text-ttg27-ink">
<i class="ttg27-off absolute inset-0 not-italic transition-opacity duration-200 motion-reduce:transition-none">Off</i>
<i class="ttg27-on absolute inset-0 not-italic opacity-0 text-ttg27-on transition-opacity duration-200 forced-colors:text-[ButtonText] motion-reduce:transition-none">On</i>
</span>
</button>
<p id="ttg-27-live" aria-live="polite" class="min-h-[1.2em] text-[12.5px] text-ttg27-mute">Captions are off</p>
<p class="text-center text-[11.5px] text-ttg27-faint">Space or Enter toggles · survives Windows High Contrast</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-ttg27-bg: #f4f5f9;
--color-ttg27-bg: oklch(0.96 0.005 265);
--color-ttg27-edge: #e5e7ee;
--color-ttg27-edge: oklch(0.92 0.006 265);
--color-ttg27-track: #cfd3dd;
--color-ttg27-track: oklch(0.86 0.01 265);
--color-ttg27-on: #3f63e0;
--color-ttg27-on: oklch(0.52 0.19 265);
--color-ttg27-ink: #212636;
--color-ttg27-ink: oklch(0.27 0.018 265);
--color-ttg27-mute: #767d90;
--color-ttg27-mute: oklch(0.58 0.018 265);
--color-ttg27-faint: #8d93a4;
--color-ttg27-faint: oklch(0.65 0.016 265);
--font-ttg27-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}
.ttg-27 {
width: 100%;
min-height: 100svh;
}
```
## JavaScript
```js
(() => {
const btn = document.querySelector('#ttg-27-btn');
const live = document.querySelector('#ttg-27-live');
if (!btn || !live) return;
// The attribute IS the state. Never mirror it into a class — the CSS reads it directly.
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
Button, not checkbox — and know why. When a toggle acts immediately and submits nothing, <button role="switch" aria-checked> is the correct pattern: Space and Enter both work, focus is free, and the attribute that screen readers announce is the same one CSS styles. For a setting that submits with a form, use a real checkbox (demos 01–26).
Forced colours is where most toggles die. Windows High Contrast strips backgrounds and shadows. Inside forced-colors: the track becomes Canvas with a ButtonText border, the checked track becomes Highlight, and the thumb gains a real border — because a background-only thumb disappears entirely. The visible On/Off text is the belt to that braces.
The live region is separate. Putting aria-live on the button double-speaks: the switch announces its own state change, then the region repeats it.
Make it yours
- Reuse the pattern with any visuals — only the
aria-checked:selectors matter. - Drop the live region where the consequence is self-evident on screen.
- Theme through the three tokens; the
forced-colors:utilities are already isolated from them. - For form-submitting settings, swap in a real checkbox and keep everything else.
Gotchas — read before shipping
role="switch"plusaria-checkedis a contract: update the attribute, never a class, or screen readers announce stale state.- Test in real forced-colors mode.
forced-colors: activeremoves backgrounds, box-shadows and background-images — if your state signal is any of those, it is gone. - Do not put
aria-liveon the button. Use a sibling region, or users hear everything twice. - System colour keywords (
Canvas,ButtonText,Highlight) are only meaningful inside the forced-colors variant; elsewhere they are inconsistent across browsers.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
button[role=switch] works in every browser and screen reader; forced-colors is Chromium/Edge and Firefox on Windows, and Safari simply ignores those utilities.