Tailwind Toggle: 16 Switch and Toggle Button Examples

Most Tailwind toggles are a checkbox in disguise. Tailwind CSS ships no toggle component, so you hide the input and style a sibling through the peer-checked variant, or use a button with role="switch" and let script flip aria-checked.

Below are 16 Tailwind toggle examples you can copy, followed by a guide to building, styling, wiring and testing them.

By Bogdan Sandu ยท Updated October 29, 2026

Every example is built with Tailwind CSS v4.3 utility classes. To try one quickly, add the Play CDN tags below to your page and paste the example's HTML, CSS and JS. The dark variant line makes dark: classes follow a .dark class instead of the system setting. For production, install Tailwind with Vite or the CLI.

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
  @custom-variant dark (&:where(.dark, .dark *));
</style>

Switches built on a real checkbox

A native checkbox with role="switch" keeps the form value, the Space key and the on/off announcement for free. Tailwind styles the track and thumb from its state with peer-checked:, has-checked:, checked: or group-has-checked:, and none of these demos needs JavaScript.

Peer checkbox switch in three sizes

  • peer sr-only
  • peer-checked:
  • after:
  • peer-checked:after:translate-x-*
  • motion-safe:

Click a switch, or Tab to it and press Space.

The input sits before the track with peer sr-only, so peer-checked:bg-indigo-600 colors the track and peer-checked:after:translate-x-5 slides the after: thumb. Thumb travel is track width minus thumb width minus both insets: 9 - 4 - 1 = 4, 11 - 5 - 1 = 5 and 14 - 7 - 1 = 6 spacing units. The slide sits behind motion-safe: so reduced-motion users get an instant switch.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10">
  <h2 class="text-base font-semibold text-slate-900">Editor preferences</h2>
  <p class="mt-1 text-sm text-slate-600">The same switch at compact, default and touch sizes.</p>
  <div class="mt-4 divide-y divide-slate-200">
    <label class="relative flex min-h-11 cursor-pointer items-center justify-between gap-4 py-3">
      <span class="min-w-0">
        <span class="block text-sm font-medium text-slate-900">Autosave drafts</span>
        <span class="mt-0.5 block font-mono text-xs text-slate-600">h-5 w-9, thumb size-4</span>
      </span>
      <input id="ttg-ps-sm" type="checkbox" role="switch" class="peer sr-only" checked>
      <span aria-hidden="true" class="relative h-5 w-9 shrink-0 rounded-full bg-slate-500 peer-checked:bg-indigo-600 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-indigo-600 after:absolute after:top-0.5 after:left-0.5 after:size-4 after:rounded-full after:bg-white after:shadow-sm peer-checked:after:translate-x-4 motion-safe:transition-colors motion-safe:after:transition-transform motion-safe:after:duration-200"></span>
    </label>
    <label class="relative flex min-h-11 cursor-pointer items-center justify-between gap-4 py-3">
      <span class="min-w-0">
        <span class="block text-sm font-medium text-slate-900">Spell check</span>
        <span class="mt-0.5 block font-mono text-xs text-slate-600">h-6 w-11, thumb size-5</span>
      </span>
      <input id="ttg-ps-md" type="checkbox" role="switch" class="peer sr-only">
      <span aria-hidden="true" class="relative h-6 w-11 shrink-0 rounded-full bg-slate-500 peer-checked:bg-indigo-600 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-indigo-600 after:absolute after:top-0.5 after:left-0.5 after:size-5 after:rounded-full after:bg-white after:shadow-sm peer-checked:after:translate-x-5 motion-safe:transition-colors motion-safe:after:transition-transform motion-safe:after:duration-200"></span>
    </label>
    <label class="relative flex min-h-11 cursor-pointer items-center justify-between gap-4 py-3">
      <span class="min-w-0">
        <span class="block text-base font-medium text-slate-900">Focus mode</span>
        <span class="mt-0.5 block font-mono text-xs text-slate-600">h-8 w-14, thumb size-7</span>
      </span>
      <input id="ttg-ps-lg" type="checkbox" role="switch" class="peer sr-only" checked>
      <span aria-hidden="true" class="relative h-8 w-14 shrink-0 rounded-full bg-slate-500 peer-checked:bg-indigo-600 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-indigo-600 after:absolute after:top-0.5 after:left-0.5 after:size-7 after:rounded-full after:bg-white after:shadow-sm peer-checked:after:translate-x-6 motion-safe:transition-colors motion-safe:after:transition-transform motion-safe:after:duration-200"></span>
    </label>
  </div>
</div>

The checkbox is the switch: appearance-none and a before: thumb

  • appearance-none
  • checked:
  • before:
  • checked:before:translate-x-5
  • focus-visible:outline-*

Click a switch, or Tab to it and press Space.

No extra track element: appearance-none strips the native box, the input gets its own size, radius and colors, checked:bg-teal-700 handles the on state and the thumb is a before: pseudo-element that checked:before:translate-x-5 moves. Because the focus ring sits on the input itself, plain focus-visible: works without peer. The visible label is a regular label with a for attribute.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-stone-900/10">
  <h2 class="text-base font-semibold text-stone-900">Privacy</h2>
  <p class="mt-1 text-sm text-stone-600">Choose what other members can see.</p>
  <ul class="mt-4 space-y-1">
    <li class="flex min-h-11 items-center justify-between gap-4">
      <label for="ttg-an-status" class="text-sm text-stone-800">Show when I am online</label>
      <input id="ttg-an-status" type="checkbox" role="switch" checked class="relative h-6 w-11 shrink-0 cursor-pointer appearance-none rounded-full bg-stone-500 checked:bg-teal-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700 before:absolute before:top-0.5 before:left-0.5 before:size-5 before:rounded-full before:bg-white before:shadow-sm checked:before:translate-x-5 motion-safe:transition-colors motion-safe:before:transition-transform motion-safe:before:duration-200">
    </li>
    <li class="flex min-h-11 items-center justify-between gap-4">
      <label for="ttg-an-receipts" class="text-sm text-stone-800">Send read receipts</label>
      <input id="ttg-an-receipts" type="checkbox" role="switch" class="relative h-6 w-11 shrink-0 cursor-pointer appearance-none rounded-full bg-stone-500 checked:bg-teal-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700 before:absolute before:top-0.5 before:left-0.5 before:size-5 before:rounded-full before:bg-white before:shadow-sm checked:before:translate-x-5 motion-safe:transition-colors motion-safe:before:transition-transform motion-safe:before:duration-200">
    </li>
    <li class="flex min-h-11 items-center justify-between gap-4">
      <label for="ttg-an-search" class="text-sm text-stone-800">Let people find me by email</label>
      <input id="ttg-an-search" type="checkbox" role="switch" class="relative h-6 w-11 shrink-0 cursor-pointer appearance-none rounded-full bg-stone-500 checked:bg-teal-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700 before:absolute before:top-0.5 before:left-0.5 before:size-5 before:rounded-full before:bg-white before:shadow-sm checked:before:translate-x-5 motion-safe:transition-colors motion-safe:before:transition-transform motion-safe:before:duration-200">
    </li>
  </ul>
</div>

Color variants with has-checked: and an @theme token

  • has-checked:
  • has-focus-visible:
  • peer-checked:translate-x-5
  • @theme
  • bg-ttg-clay

Click a switch, or Tab to it and press Space.

Here the track wraps the input, so has-checked:bg-sky-600 colors it from inside and has-focus-visible: draws the ring, while the thumb is a sibling of the input and moves with peer-checked:translate-x-5. Every on color is a 600 or 700 shade so the track keeps 3:1 against white. The last row uses a custom --color-ttg-clay token from @theme, which generates has-checked:bg-ttg-clay like any built-in color.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10">
  <h2 class="text-base font-semibold text-slate-900">Calendars</h2>
  <p class="mt-1 text-sm text-slate-600">Show or hide each calendar layer.</p>
  <ul class="mt-4 grid gap-1">
    <li><label class="flex min-h-11 cursor-pointer items-center gap-3"><span aria-hidden="true" class="size-2.5 shrink-0 rounded-full bg-sky-600"></span><span class="min-w-0 flex-1 text-sm text-slate-800">Work</span><span class="relative inline-flex h-6 w-11 shrink-0 rounded-full bg-slate-500 has-checked:bg-sky-600 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-sky-600 motion-safe:transition-colors"><input type="checkbox" role="switch" class="peer sr-only" checked><span aria-hidden="true" class="absolute top-0.5 left-0.5 size-5 rounded-full bg-white shadow-sm peer-checked:translate-x-5 motion-safe:transition-transform"></span></span></label></li>
    <li><label class="flex min-h-11 cursor-pointer items-center gap-3"><span aria-hidden="true" class="size-2.5 shrink-0 rounded-full bg-emerald-600"></span><span class="min-w-0 flex-1 text-sm text-slate-800">Personal</span><span class="relative inline-flex h-6 w-11 shrink-0 rounded-full bg-slate-500 has-checked:bg-emerald-600 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-emerald-600 motion-safe:transition-colors"><input type="checkbox" role="switch" class="peer sr-only" checked><span aria-hidden="true" class="absolute top-0.5 left-0.5 size-5 rounded-full bg-white shadow-sm peer-checked:translate-x-5 motion-safe:transition-transform"></span></span></label></li>
    <li><label class="flex min-h-11 cursor-pointer items-center gap-3"><span aria-hidden="true" class="size-2.5 shrink-0 rounded-full bg-rose-600"></span><span class="min-w-0 flex-1 text-sm text-slate-800">Family</span><span class="relative inline-flex h-6 w-11 shrink-0 rounded-full bg-slate-500 has-checked:bg-rose-600 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-rose-600 motion-safe:transition-colors"><input type="checkbox" role="switch" class="peer sr-only"><span aria-hidden="true" class="absolute top-0.5 left-0.5 size-5 rounded-full bg-white shadow-sm peer-checked:translate-x-5 motion-safe:transition-transform"></span></span></label></li>
    <li><label class="flex min-h-11 cursor-pointer items-center gap-3"><span aria-hidden="true" class="size-2.5 shrink-0 rounded-full bg-amber-700"></span><span class="min-w-0 flex-1 text-sm text-slate-800">Travel</span><span class="relative inline-flex h-6 w-11 shrink-0 rounded-full bg-slate-500 has-checked:bg-amber-700 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-amber-700 motion-safe:transition-colors"><input type="checkbox" role="switch" class="peer sr-only" checked><span aria-hidden="true" class="absolute top-0.5 left-0.5 size-5 rounded-full bg-white shadow-sm peer-checked:translate-x-5 motion-safe:transition-transform"></span></span></label></li>
    <li><label class="flex min-h-11 cursor-pointer items-center gap-3"><span aria-hidden="true" class="size-2.5 shrink-0 rounded-full bg-violet-600"></span><span class="min-w-0 flex-1 text-sm text-slate-800">Public holidays</span><span class="relative inline-flex h-6 w-11 shrink-0 rounded-full bg-slate-500 has-checked:bg-violet-600 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-violet-600 motion-safe:transition-colors"><input type="checkbox" role="switch" class="peer sr-only"><span aria-hidden="true" class="absolute top-0.5 left-0.5 size-5 rounded-full bg-white shadow-sm peer-checked:translate-x-5 motion-safe:transition-transform"></span></span></label></li>
    <li><label class="flex min-h-11 cursor-pointer items-center gap-3"><span aria-hidden="true" class="size-2.5 shrink-0 rounded-full bg-ttg-clay"></span><span class="min-w-0 flex-1 text-sm text-slate-800">Studio bookings <span class="font-mono text-xs text-slate-600">ttg-clay</span></span><span class="relative inline-flex h-6 w-11 shrink-0 rounded-full bg-slate-500 has-checked:bg-ttg-clay has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-ttg-clay motion-safe:transition-colors"><input id="ttg-co-studio" type="checkbox" role="switch" class="peer sr-only"><span aria-hidden="true" class="absolute top-0.5 left-0.5 size-5 rounded-full bg-white shadow-sm peer-checked:translate-x-5 motion-safe:transition-transform"></span></span></label></li>
  </ul>
</div>

On and off text, and icons inside the thumb

  • group
  • group-has-checked:
  • group-has-checked:hidden
  • translate-x-*
  • aria-hidden

Click a switch, or Tab to it and press Space.

The thumb is a real span inside the track, which is not a sibling of the input, so the label carries group and the thumb reads the state with group-has-checked:translate-x-6. Each icon pair swaps with hidden and group-has-checked:block, and the On and Off words in the track swap the same way. All of it is aria-hidden, because the switch role already announces on or off.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-zinc-900/10">
  <h2 class="text-base font-semibold text-zinc-900">Account</h2>
  <div class="mt-4 space-y-2">
    <label class="group relative flex min-h-11 cursor-pointer items-center justify-between gap-4">
      <span class="text-sm text-zinc-800">Public profile</span>
      <input id="ttg-ti-public" type="checkbox" role="switch" class="sr-only">
      <span aria-hidden="true" class="relative flex h-8 w-14 shrink-0 items-center rounded-full bg-zinc-500 p-0.5 group-has-checked:bg-emerald-700 group-has-focus-visible:outline-2 group-has-focus-visible:outline-offset-2 group-has-focus-visible:outline-emerald-700 motion-safe:transition-colors">
        <span class="grid size-7 place-items-center rounded-full bg-white shadow-sm group-has-checked:translate-x-6 motion-safe:transition-transform motion-safe:duration-200">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-3.5 text-zinc-600 group-has-checked:hidden"><path d="m4.5 4.5 7 7m0-7-7 7"/></svg>
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="hidden size-3.5 text-emerald-700 group-has-checked:block"><path d="m3.5 8.5 3 3 6-7"/></svg>
        </span>
      </span>
    </label>
    <label class="group relative flex min-h-11 cursor-pointer items-center justify-between gap-4">
      <span class="text-sm text-zinc-800">Auto-renew subscription</span>
      <input id="ttg-ti-renew" type="checkbox" role="switch" class="sr-only" checked>
      <span aria-hidden="true" class="relative flex h-8 w-16 shrink-0 items-center rounded-full bg-zinc-600 p-0.5 group-has-checked:bg-blue-700 group-has-focus-visible:outline-2 group-has-focus-visible:outline-offset-2 group-has-focus-visible:outline-blue-700 motion-safe:transition-colors">
        <span class="absolute right-2.5 text-[11px] font-bold tracking-wide text-white uppercase group-has-checked:hidden">Off</span>
        <span class="absolute left-2.5 hidden text-[11px] font-bold tracking-wide text-white uppercase group-has-checked:block">On</span>
        <span class="size-7 rounded-full bg-white shadow-sm group-has-checked:translate-x-8 motion-safe:transition-transform motion-safe:duration-200"></span>
      </span>
    </label>
    <label class="group relative flex min-h-11 cursor-pointer items-center justify-between gap-4">
      <span class="text-sm text-zinc-800">Message sounds</span>
      <input id="ttg-ti-sound" type="checkbox" role="switch" class="sr-only" checked>
      <span aria-hidden="true" class="relative flex h-8 w-14 shrink-0 items-center rounded-full bg-zinc-500 p-0.5 group-has-checked:bg-fuchsia-700 group-has-focus-visible:outline-2 group-has-focus-visible:outline-offset-2 group-has-focus-visible:outline-fuchsia-700 motion-safe:transition-colors">
        <span class="grid size-7 place-items-center rounded-full bg-white shadow-sm group-has-checked:translate-x-6 motion-safe:transition-transform motion-safe:duration-200">
          <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" class="size-4 text-zinc-600 group-has-checked:hidden"><path d="M9 4.5 5.5 7.5H3v5h2.5L9 15.5zM13 8l4 4m0-4-4 4"/></svg>
          <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" class="hidden size-4 text-fuchsia-700 group-has-checked:block"><path d="M9 4.5 5.5 7.5H3v5h2.5L9 15.5zM12.5 7.5a3.5 3.5 0 0 1 0 5M14.5 5.5a6.5 6.5 0 0 1 0 9"/></svg>
        </span>
      </span>
    </label>
  </div>
</div>

Switch that stays readable in Windows contrast themes

  • forced-colors:
  • forced-color-adjust-none
  • bg-[color:Highlight]
  • border-transparent
  • group-has-checked:

To preview, emulate forced-colors: active in the DevTools Rendering panel.

In forced colors mode the browser replaces author background colors with the theme Canvas color, so a gray track and a blue track look the same and a white thumb disappears. forced-colors:forced-color-adjust-none keeps control of the track and thumb, which then use system colors: ButtonText for the border and off thumb, Highlight for the on track and HighlightText for the on thumb. A check icon in the thumb adds a cue that does not depend on color at all.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10">
  <h2 class="text-base font-semibold text-slate-900">Display</h2>
  <p class="mt-1 text-sm text-slate-600">These switches keep their on and off states visible in Windows contrast themes.</p>
  <div class="mt-4 space-y-2">
    <label class="group relative flex min-h-11 cursor-pointer items-center justify-between gap-4">
      <span class="text-sm text-slate-800">Reduce motion</span>
      <input id="ttg-fc-motion" type="checkbox" role="switch" class="sr-only" checked>
      <span aria-hidden="true" class="relative flex h-7 w-12 shrink-0 items-center rounded-full border-2 border-transparent bg-slate-500 group-has-checked:bg-blue-700 group-has-focus-visible:outline-2 group-has-focus-visible:outline-offset-2 group-has-focus-visible:outline-blue-700 forced-colors:forced-color-adjust-none forced-colors:border-[color:ButtonText] forced-colors:bg-[color:ButtonFace] forced-colors:group-has-checked:bg-[color:Highlight] forced-colors:group-has-focus-visible:outline-[color:Highlight] motion-safe:transition-colors">
        <span class="ml-0.5 grid size-5 place-items-center rounded-full bg-white group-has-checked:translate-x-5 forced-colors:bg-[color:ButtonText] forced-colors:group-has-checked:bg-[color:HighlightText] motion-safe:transition-transform">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" class="hidden size-3 text-blue-700 group-has-checked:block forced-colors:text-[color:Highlight]"><path d="m3.5 8.5 3 3 6-7"/></svg>
        </span>
      </span>
    </label>
    <label class="group relative flex min-h-11 cursor-pointer items-center justify-between gap-4">
      <span class="text-sm text-slate-800">Always underline links</span>
      <input id="ttg-fc-links" type="checkbox" role="switch" class="sr-only">
      <span aria-hidden="true" class="relative flex h-7 w-12 shrink-0 items-center rounded-full border-2 border-transparent bg-slate-500 group-has-checked:bg-blue-700 group-has-focus-visible:outline-2 group-has-focus-visible:outline-offset-2 group-has-focus-visible:outline-blue-700 forced-colors:forced-color-adjust-none forced-colors:border-[color:ButtonText] forced-colors:bg-[color:ButtonFace] forced-colors:group-has-checked:bg-[color:Highlight] forced-colors:group-has-focus-visible:outline-[color:Highlight] motion-safe:transition-colors">
        <span class="ml-0.5 grid size-5 place-items-center rounded-full bg-white group-has-checked:translate-x-5 forced-colors:bg-[color:ButtonText] forced-colors:group-has-checked:bg-[color:HighlightText] motion-safe:transition-transform">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" class="hidden size-3 text-blue-700 group-has-checked:block forced-colors:text-[color:Highlight]"><path d="m3.5 8.5 3 3 6-7"/></svg>
        </span>
      </span>
    </label>
    <label class="group relative flex min-h-11 cursor-pointer items-center justify-between gap-4">
      <span class="text-sm text-slate-800">Larger interface text</span>
      <input id="ttg-fc-text" type="checkbox" role="switch" class="sr-only">
      <span aria-hidden="true" class="relative flex h-7 w-12 shrink-0 items-center rounded-full border-2 border-transparent bg-slate-500 group-has-checked:bg-blue-700 group-has-focus-visible:outline-2 group-has-focus-visible:outline-offset-2 group-has-focus-visible:outline-blue-700 forced-colors:forced-color-adjust-none forced-colors:border-[color:ButtonText] forced-colors:bg-[color:ButtonFace] forced-colors:group-has-checked:bg-[color:Highlight] forced-colors:group-has-focus-visible:outline-[color:Highlight] motion-safe:transition-colors">
        <span class="ml-0.5 grid size-5 place-items-center rounded-full bg-white group-has-checked:translate-x-5 forced-colors:bg-[color:ButtonText] forced-colors:group-has-checked:bg-[color:HighlightText] motion-safe:transition-transform">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" class="hidden size-3 text-blue-700 group-has-checked:block forced-colors:text-[color:Highlight]"><path d="m3.5 8.5 3 3 6-7"/></svg>
        </span>
      </span>
    </label>
  </div>
</div>

Button switches and toggle buttons

When script owns the state, a button carries it in aria-checked or aria-pressed and Tailwind reads it back with the aria-checked: and aria-pressed: variants. Radios cover the two-option segmented control, styled with has-checked:.

Button switch styled from aria-checked

  • role="switch"
  • aria-checked:
  • group-aria-checked:
  • before:-inset-2
  • aria-live

Flip a flag and watch the counter.

Each switch is a button with role="switch", so Enter and Space work natively and the script only flips aria-checked. aria-checked:bg-violet-700 colors the track and group-aria-checked:translate-x-5 moves the thumb, so the visual state can never drift from what assistive technology hears. A before:-inset-2 pseudo-element grows the 28px track to a 44px hit area.

View code
<div class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10">
  <div class="flex flex-wrap items-baseline justify-between gap-2">
    <h2 class="text-base font-semibold text-slate-900">Feature flags</h2>
    <p id="ttg-bs-count" class="text-sm text-slate-600" aria-live="polite">2 of 4 flags on</p>
  </div>
  <ul class="mt-2 divide-y divide-slate-200">
    <li class="flex items-start justify-between gap-4 py-4">
      <div class="min-w-0">
        <p id="ttg-bs-l1" class="text-sm font-semibold text-slate-900">One-page checkout</p>
        <p id="ttg-bs-d1" class="mt-0.5 text-sm text-slate-600">Serve the new checkout to 10% of visitors.</p>
        <code class="mt-1.5 inline-block rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-700">checkout_v2</code>
      </div>
      <button type="button" role="switch" aria-checked="true" aria-labelledby="ttg-bs-l1" aria-describedby="ttg-bs-d1" class="ttg-bs-switch group relative mt-1 inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full bg-slate-500 p-1 before:absolute before:-inset-2 aria-checked:bg-violet-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-700 motion-safe:transition-colors"><span aria-hidden="true" class="size-5 rounded-full bg-white shadow-sm group-aria-checked:translate-x-5 motion-safe:transition-transform motion-safe:duration-200"></span></button>
    </li>
    <li class="flex items-start justify-between gap-4 py-4">
      <div class="min-w-0">
        <p id="ttg-bs-l2" class="text-sm font-semibold text-slate-900">AI search suggestions</p>
        <p id="ttg-bs-d2" class="mt-0.5 text-sm text-slate-600">Internal staff only while the model is tuned.</p>
        <code class="mt-1.5 inline-block rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-700">search_suggest</code>
      </div>
      <button type="button" role="switch" aria-checked="false" aria-labelledby="ttg-bs-l2" aria-describedby="ttg-bs-d2" class="ttg-bs-switch group relative mt-1 inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full bg-slate-500 p-1 before:absolute before:-inset-2 aria-checked:bg-violet-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-700 motion-safe:transition-colors"><span aria-hidden="true" class="size-5 rounded-full bg-white shadow-sm group-aria-checked:translate-x-5 motion-safe:transition-transform motion-safe:duration-200"></span></button>
    </li>
    <li class="flex items-start justify-between gap-4 py-4">
      <div class="min-w-0">
        <p id="ttg-bs-l3" class="text-sm font-semibold text-slate-900">Invoice PDF v3</p>
        <p id="ttg-bs-d3" class="mt-0.5 text-sm text-slate-600">New layout with line-item tax breakdown.</p>
        <code class="mt-1.5 inline-block rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-700">invoice_pdf_v3</code>
      </div>
      <button type="button" role="switch" aria-checked="true" aria-labelledby="ttg-bs-l3" aria-describedby="ttg-bs-d3" class="ttg-bs-switch group relative mt-1 inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full bg-slate-500 p-1 before:absolute before:-inset-2 aria-checked:bg-violet-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-700 motion-safe:transition-colors"><span aria-hidden="true" class="size-5 rounded-full bg-white shadow-sm group-aria-checked:translate-x-5 motion-safe:transition-transform motion-safe:duration-200"></span></button>
    </li>
    <li class="flex items-start justify-between gap-4 py-4">
      <div class="min-w-0">
        <p id="ttg-bs-l4" class="text-sm font-semibold text-slate-900">Maintenance banner</p>
        <p id="ttg-bs-d4" class="mt-0.5 text-sm text-slate-600">Shows a notice at the top of every page.</p>
        <code class="mt-1.5 inline-block rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-700">maint_banner</code>
      </div>
      <button type="button" role="switch" aria-checked="false" aria-labelledby="ttg-bs-l4" aria-describedby="ttg-bs-d4" class="ttg-bs-switch group relative mt-1 inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full bg-slate-500 p-1 before:absolute before:-inset-2 aria-checked:bg-violet-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-700 motion-safe:transition-colors"><span aria-hidden="true" class="size-5 rounded-full bg-white shadow-sm group-aria-checked:translate-x-5 motion-safe:transition-transform motion-safe:duration-200"></span></button>
    </li>
  </ul>
</div>

Toggle button group with aria-pressed

  • aria-pressed:
  • role="group"
  • divide-x
  • focus-visible:z-10
  • aria-label

Press the style buttons.

A toggle button keeps its label and reports pressed or not pressed, which suits formatting controls better than a switch. aria-pressed:bg-slate-900 aria-pressed:text-white styles the pressed state straight from the attribute the script flips, and focus-visible:z-10 keeps the focus ring above the neighbouring buttons. The script adds the full font-bold, italic, underline and line-through class names to the preview.

View code
<div class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10">
  <div class="flex flex-wrap items-center justify-between gap-3">
    <h2 class="text-base font-semibold text-slate-900">Headline style</h2>
    <div role="group" aria-label="Text style" class="inline-flex divide-x divide-slate-300 overflow-hidden rounded-lg ring-1 ring-slate-300">
      <button type="button" aria-pressed="true" data-ttg-style="font-bold" class="ttg-pg-btn relative grid size-11 place-items-center bg-white text-slate-700 hover:bg-slate-100 focus-visible:z-10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-sky-600 aria-pressed:bg-slate-900 aria-pressed:text-white" aria-label="Bold"><svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linejoin="round" class="size-4"><path d="M6 4h5a3 3 0 0 1 0 6H6zM6 10h6a3 3 0 0 1 0 6H6z"/></svg></button>
      <button type="button" aria-pressed="false" data-ttg-style="italic" class="ttg-pg-btn relative grid size-11 place-items-center bg-white text-slate-700 hover:bg-slate-100 focus-visible:z-10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-sky-600 aria-pressed:bg-slate-900 aria-pressed:text-white" aria-label="Italic"><svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-4"><path d="M12 4H8.5M11.5 16H8M11 4l-2 12"/></svg></button>
      <button type="button" aria-pressed="false" data-ttg-style="underline" class="ttg-pg-btn relative grid size-11 place-items-center bg-white text-slate-700 hover:bg-slate-100 focus-visible:z-10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-sky-600 aria-pressed:bg-slate-900 aria-pressed:text-white" aria-label="Underline"><svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-4"><path d="M6 3.5v5a4 4 0 0 0 8 0v-5M5 16.5h10"/></svg></button>
      <button type="button" aria-pressed="false" data-ttg-style="line-through" class="ttg-pg-btn relative grid size-11 place-items-center bg-white text-slate-700 hover:bg-slate-100 focus-visible:z-10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-sky-600 aria-pressed:bg-slate-900 aria-pressed:text-white" aria-label="Strikethrough"><svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-4"><path d="M4 10h12M13.5 6.5C13 5 11.7 4 10 4 8 4 6.5 5.2 6.5 6.8M6.5 13.5C7 15 8.4 16 10.2 16c2 0 3.5-1.1 3.5-2.7"/></svg></button>
    </div>
  </div>
  <div class="mt-5 rounded-xl bg-amber-50 p-5 ring-1 ring-amber-900/10">
    <p class="text-xs font-medium text-amber-900">Preview</p>
    <p id="ttg-pg-preview" class="mt-2 text-xl text-stone-900 font-bold">Harbour market reopens with forty new stalls</p>
  </div>
</div>

Segmented two-option toggle built from radios

  • has-checked:
  • has-focus-visible:
  • group-has-[#id:checked]:
  • <fieldset>
  • sr-only

Pick Pickup or Imperial.

When both choices need a name, two radios beat one switch: arrow keys move between them and the form always sends a value. Each label wraps an sr-only radio and lights up with has-checked:bg-white has-checked:shadow-sm. The delivery and distance lines below change with no script, through group-has-[#ttg-sg-pickup:checked]:block on the card.

View code
<div class="group w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-orange-900/10">
  <fieldset>
    <legend class="text-base font-semibold text-stone-900">How do you want your order?</legend>
    <div class="mt-3 grid grid-cols-2 gap-1 rounded-xl bg-stone-200 p-1">
      <label class="flex min-h-11 cursor-pointer items-center justify-center gap-2 rounded-lg text-sm font-medium text-stone-700 has-checked:bg-white has-checked:text-stone-950 has-checked:shadow-sm has-focus-visible:outline-2 has-focus-visible:outline-orange-700 motion-safe:transition-colors">
        <input type="radio" name="ttg-sg-mode" value="delivery" class="sr-only" checked>
        <svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" class="size-4"><path d="M2 5h10v8H2zM12 8h3.5L18 11v2h-6z"/><circle cx="5.5" cy="14.5" r="1.5"/><circle cx="14.5" cy="14.5" r="1.5"/></svg>
        Delivery
      </label>
      <label class="flex min-h-11 cursor-pointer items-center justify-center gap-2 rounded-lg text-sm font-medium text-stone-700 has-checked:bg-white has-checked:text-stone-950 has-checked:shadow-sm has-focus-visible:outline-2 has-focus-visible:outline-orange-700 motion-safe:transition-colors">
        <input id="ttg-sg-pickup" type="radio" name="ttg-sg-mode" value="pickup" class="sr-only">
        <svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" class="size-4"><path d="M3 8h14l-1 9H4zM7 8V6a3 3 0 0 1 6 0v2"/></svg>
        Pickup
      </label>
    </div>
  </fieldset>
  <div class="mt-4 rounded-xl bg-orange-50 p-4 text-sm text-orange-950">
    <p class="group-has-[#ttg-sg-pickup:checked]:hidden">Arrives in 35 to 45 minutes. Delivery fee $3.99.</p>
    <p id="ttg-sg-pickup-info" class="hidden group-has-[#ttg-sg-pickup:checked]:block">Ready in 20 minutes at 18 Quay Street. No fee.</p>
  </div>
  <fieldset class="mt-6 flex flex-wrap items-center justify-between gap-3">
    <legend class="sr-only">Units</legend>
    <span aria-hidden="true" class="text-sm text-stone-700">Units</span>
    <div class="inline-flex rounded-full bg-stone-200 p-1 text-xs font-semibold">
      <label class="flex min-h-9 cursor-pointer items-center rounded-full px-4 text-stone-700 has-checked:bg-stone-900 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-orange-700"><input type="radio" name="ttg-sg-units" value="metric" class="sr-only" checked>Metric</label>
      <label class="flex min-h-9 cursor-pointer items-center rounded-full px-4 text-stone-700 has-checked:bg-stone-900 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-orange-700"><input id="ttg-sg-imperial" type="radio" name="ttg-sg-units" value="imperial" class="sr-only">Imperial</label>
    </div>
  </fieldset>
  <p class="mt-3 text-sm text-stone-600">Distance from the kitchen: <span class="font-semibold text-stone-900 group-has-[#ttg-sg-imperial:checked]:hidden">4.2 km</span><span class="hidden font-semibold text-stone-900 group-has-[#ttg-sg-imperial:checked]:inline">2.6 mi</span></p>
</div>

Disabled and loading states

  • aria-busy
  • data-loading:
  • group-data-loading:
  • animate-spin motion-reduce:animate-none
  • peer-disabled:

Press the Daily backups switch and watch it save.

The backup switch saves to a server, so the script sets aria-busy and a data-loading attribute while the request runs: group-data-loading:block shows a spinner in the thumb and data-loading:cursor-wait marks the wait. The two locked rows are native disabled checkboxes styled with peer-disabled: and has-disabled:, and aria-describedby points each one at the reason it is locked.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10">
  <h2 class="text-base font-semibold text-slate-900">Workspace security</h2>
  <ul class="mt-3 divide-y divide-slate-200">
    <li class="flex items-start justify-between gap-4 py-4">
      <div class="min-w-0">
        <p id="ttg-st-l1" class="text-sm font-medium text-slate-900">Daily backups</p>
        <p id="ttg-st-msg" class="mt-0.5 text-sm text-slate-600" aria-live="polite">Off. Last backup 9 days ago.</p>
      </div>
      <button id="ttg-st-backup" type="button" role="switch" aria-checked="false" aria-labelledby="ttg-st-l1" aria-describedby="ttg-st-msg" class="group relative mt-0.5 inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full bg-slate-500 p-1 before:absolute before:-inset-2 aria-checked:bg-sky-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 data-loading:cursor-wait motion-safe:transition-colors">
        <span aria-hidden="true" class="grid size-5 place-items-center rounded-full bg-white shadow-sm group-aria-checked:translate-x-5 motion-safe:transition-transform motion-safe:duration-200">
          <svg viewBox="0 0 16 16" fill="none" class="hidden size-3.5 animate-spin text-sky-700 group-data-loading:block motion-reduce:animate-none"><circle cx="8" cy="8" r="6" stroke="currentColor" stroke-opacity=".25" stroke-width="2.5"/><path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></svg>
        </span>
      </button>
    </li>
    <li class="flex items-start justify-between gap-4 py-4">
      <div class="min-w-0">
        <label for="ttg-st-sso" class="text-sm font-medium text-slate-500">Require single sign-on</label>
        <p id="ttg-st-sso-why" class="mt-0.5 text-sm text-slate-600">Available on the Business plan.</p>
      </div>
      <span class="relative mt-0.5 inline-flex h-7 w-12 shrink-0 rounded-full bg-slate-200 ring-1 ring-slate-300 ring-inset has-disabled:cursor-not-allowed">
        <input id="ttg-st-sso" type="checkbox" role="switch" class="peer sr-only" disabled aria-describedby="ttg-st-sso-why">
        <span aria-hidden="true" class="absolute top-1 left-1 size-5 rounded-full bg-white shadow-sm peer-disabled:bg-slate-100 peer-disabled:shadow-none"></span>
      </span>
    </li>
    <li class="flex items-start justify-between gap-4 py-4">
      <div class="min-w-0">
        <label for="ttg-st-audit" class="text-sm font-medium text-slate-500">Audit log</label>
        <p id="ttg-st-audit-why" class="mt-0.5 text-sm text-slate-600">Locked on by your organization admin.</p>
      </div>
      <span class="relative mt-0.5 inline-flex h-7 w-12 shrink-0 rounded-full bg-sky-200 has-disabled:cursor-not-allowed">
        <input id="ttg-st-audit" type="checkbox" role="switch" class="peer sr-only" checked disabled aria-describedby="ttg-st-audit-why">
        <span aria-hidden="true" class="absolute top-1 left-1 grid size-5 translate-x-5 place-items-center rounded-full bg-white">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" class="size-3 text-sky-800"><path d="M4.5 7.5h7v5.5h-7zM6 7.5V5.5a2 2 0 0 1 4 0v2"/></svg>
        </span>
      </span>
    </li>
  </ul>
</div>

Switches in settings screens

Labels on either side with descriptions wired through aria-describedby, grouped lists under a fieldset and legend, a switch that reveals more fields and one that asks before it turns off.

Label on the left or right, with a description

  • aria-describedby
  • justify-between
  • peer-checked:
  • md:grid-cols-2
  • min-w-0

The label text and the switch can sit in either order, because peer only needs the track to follow the input. Each input is named by its label and points to the helper line with aria-describedby, so screen readers read the name, the switch state and then the description. The left column uses justify-between for settings pages, the right column puts the switch first for consent lists.

View code
<div class="grid w-full max-w-3xl gap-5 md:grid-cols-2">
  <section class="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10">
    <h2 class="text-sm font-semibold text-slate-900">Label left</h2>
    <div class="mt-4 space-y-5">
      <div class="flex items-start justify-between gap-4">
        <div class="min-w-0">
          <label for="ttg-lp-receipts" class="text-sm font-medium text-slate-900">Read receipts</label>
          <p id="ttg-lp-receipts-d" class="mt-0.5 text-sm text-slate-600">Let people see when you have read their message.</p>
        </div>
        <span class="relative mt-0.5 inline-flex shrink-0">
          <input id="ttg-lp-receipts" type="checkbox" role="switch" aria-describedby="ttg-lp-receipts-d" class="peer absolute inset-0 size-full cursor-pointer opacity-0" checked>
          <span aria-hidden="true" class="pointer-events-none h-6 w-11 rounded-full bg-slate-500 peer-checked:bg-cyan-700 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-cyan-700 motion-safe:transition-colors"></span>
          <span aria-hidden="true" class="pointer-events-none absolute top-0.5 left-0.5 size-5 rounded-full bg-white shadow-sm peer-checked:translate-x-5 motion-safe:transition-transform"></span>
        </span>
      </div>
      <div class="flex items-start justify-between gap-4">
        <div class="min-w-0">
          <label for="ttg-lp-mentions" class="text-sm font-medium text-slate-900">Mentions from anyone</label>
          <p id="ttg-lp-mentions-d" class="mt-0.5 text-sm text-slate-600">When off, only people you follow can mention you.</p>
        </div>
        <span class="relative mt-0.5 inline-flex shrink-0">
          <input id="ttg-lp-mentions" type="checkbox" role="switch" aria-describedby="ttg-lp-mentions-d" class="peer absolute inset-0 size-full cursor-pointer opacity-0">
          <span aria-hidden="true" class="pointer-events-none h-6 w-11 rounded-full bg-slate-500 peer-checked:bg-cyan-700 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-cyan-700 motion-safe:transition-colors"></span>
          <span aria-hidden="true" class="pointer-events-none absolute top-0.5 left-0.5 size-5 rounded-full bg-white shadow-sm peer-checked:translate-x-5 motion-safe:transition-transform"></span>
        </span>
      </div>
    </div>
  </section>
  <section class="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10">
    <h2 class="text-sm font-semibold text-slate-900">Label right</h2>
    <div class="mt-4 space-y-5">
      <div class="flex items-start gap-3">
        <span class="relative mt-0.5 inline-flex shrink-0">
          <input id="ttg-lp-news" type="checkbox" role="switch" aria-describedby="ttg-lp-news-d" class="peer absolute inset-0 size-full cursor-pointer opacity-0">
          <span aria-hidden="true" class="pointer-events-none h-6 w-11 rounded-full bg-slate-500 peer-checked:bg-lime-700 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-lime-700 motion-safe:transition-colors"></span>
          <span aria-hidden="true" class="pointer-events-none absolute top-0.5 left-0.5 size-5 rounded-full bg-white shadow-sm peer-checked:translate-x-5 motion-safe:transition-transform"></span>
        </span>
        <div class="min-w-0">
          <label for="ttg-lp-news" class="text-sm font-medium text-slate-900">Monthly newsletter</label>
          <p id="ttg-lp-news-d" class="mt-0.5 text-sm text-slate-600">One email on the first Monday. Unsubscribe any time.</p>
        </div>
      </div>
      <div class="flex items-start gap-3">
        <span class="relative mt-0.5 inline-flex shrink-0">
          <input id="ttg-lp-research" type="checkbox" role="switch" aria-describedby="ttg-lp-research-d" class="peer absolute inset-0 size-full cursor-pointer opacity-0" checked>
          <span aria-hidden="true" class="pointer-events-none h-6 w-11 rounded-full bg-slate-500 peer-checked:bg-lime-700 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-lime-700 motion-safe:transition-colors"></span>
          <span aria-hidden="true" class="pointer-events-none absolute top-0.5 left-0.5 size-5 rounded-full bg-white shadow-sm peer-checked:translate-x-5 motion-safe:transition-transform"></span>
        </span>
        <div class="min-w-0">
          <label for="ttg-lp-research" class="text-sm font-medium text-slate-900">Research invitations</label>
          <p id="ttg-lp-research-d" class="mt-0.5 text-sm text-slate-600">Paid 30-minute interviews about new features.</p>
        </div>
      </div>
    </div>
  </section>
</div>

Notification settings grouped with fieldset and legend

  • <fieldset>
  • <legend>
  • divide-y
  • peer-checked:
  • @container

Each channel is a fieldset whose legend gives the group a name, so a screen reader announces Email notifications before the first switch inside. The rows reuse one peer track, and the card switches to two columns with @container and @2xl:grid-cols-2 based on its own width. float-left w-full on the legend takes it out of the fieldset border slot, so it respects the card padding like a normal heading.

View code
<div class="bg-slate-50 px-4 py-8 sm:px-8">
  <div class="@container mx-auto max-w-4xl">
    <h2 class="text-xl font-semibold text-slate-900">Notifications</h2>
    <p class="mt-1 text-sm text-slate-600">Choose how Tidepool tells you about activity in your projects.</p>
    <div class="mt-6 grid gap-6 @2xl:grid-cols-2">
      <fieldset class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-900/10">
        <legend class="float-left w-full text-base font-semibold text-slate-900">Email notifications</legend>
        <div class="clear-both divide-y divide-slate-200 pt-2">
          <label class="relative flex min-h-11 cursor-pointer items-center justify-between gap-4 py-3"><span class="text-sm text-slate-800">Comments on my tasks</span><input type="checkbox" role="switch" class="peer sr-only" checked><span aria-hidden="true" class="relative h-6 w-11 shrink-0 rounded-full bg-slate-500 peer-checked:bg-blue-700 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-blue-700 after:absolute after:top-0.5 after:left-0.5 after:size-5 after:rounded-full after:bg-white peer-checked:after:translate-x-5 motion-safe:transition-colors motion-safe:after:transition-transform"></span></label>
          <label class="relative flex min-h-11 cursor-pointer items-center justify-between gap-4 py-3"><span class="text-sm text-slate-800">Mentions</span><input type="checkbox" role="switch" class="peer sr-only" checked><span aria-hidden="true" class="relative h-6 w-11 shrink-0 rounded-full bg-slate-500 peer-checked:bg-blue-700 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-blue-700 after:absolute after:top-0.5 after:left-0.5 after:size-5 after:rounded-full after:bg-white peer-checked:after:translate-x-5 motion-safe:transition-colors motion-safe:after:transition-transform"></span></label>
          <label class="relative flex min-h-11 cursor-pointer items-center justify-between gap-4 py-3"><span class="text-sm text-slate-800">Weekly digest</span><input type="checkbox" role="switch" class="peer sr-only"><span aria-hidden="true" class="relative h-6 w-11 shrink-0 rounded-full bg-slate-500 peer-checked:bg-blue-700 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-blue-700 after:absolute after:top-0.5 after:left-0.5 after:size-5 after:rounded-full after:bg-white peer-checked:after:translate-x-5 motion-safe:transition-colors motion-safe:after:transition-transform"></span></label>
          <label class="relative flex min-h-11 cursor-pointer items-center justify-between gap-4 py-3"><span class="text-sm text-slate-800">Product updates</span><input type="checkbox" role="switch" class="peer sr-only"><span aria-hidden="true" class="relative h-6 w-11 shrink-0 rounded-full bg-slate-500 peer-checked:bg-blue-700 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-blue-700 after:absolute after:top-0.5 after:left-0.5 after:size-5 after:rounded-full after:bg-white peer-checked:after:translate-x-5 motion-safe:transition-colors motion-safe:after:transition-transform"></span></label>
        </div>
      </fieldset>
      <fieldset class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-900/10">
        <legend class="float-left w-full text-base font-semibold text-slate-900">Push notifications</legend>
        <div class="clear-both divide-y divide-slate-200 pt-2">
          <label class="relative flex min-h-11 cursor-pointer items-center justify-between gap-4 py-3"><span class="text-sm text-slate-800">Direct messages</span><input type="checkbox" role="switch" class="peer sr-only" checked><span aria-hidden="true" class="relative h-6 w-11 shrink-0 rounded-full bg-slate-500 peer-checked:bg-blue-700 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-blue-700 after:absolute after:top-0.5 after:left-0.5 after:size-5 after:rounded-full after:bg-white peer-checked:after:translate-x-5 motion-safe:transition-colors motion-safe:after:transition-transform"></span></label>
          <label class="relative flex min-h-11 cursor-pointer items-center justify-between gap-4 py-3"><span class="text-sm text-slate-800">Due date reminders</span><input id="ttg-sl-due" type="checkbox" role="switch" class="peer sr-only"><span aria-hidden="true" class="relative h-6 w-11 shrink-0 rounded-full bg-slate-500 peer-checked:bg-blue-700 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-blue-700 after:absolute after:top-0.5 after:left-0.5 after:size-5 after:rounded-full after:bg-white peer-checked:after:translate-x-5 motion-safe:transition-colors motion-safe:after:transition-transform"></span></label>
          <label class="relative flex min-h-11 cursor-pointer items-center justify-between gap-4 py-3"><span class="text-sm text-slate-800">Task assigned to me</span><input type="checkbox" role="switch" class="peer sr-only" checked><span aria-hidden="true" class="relative h-6 w-11 shrink-0 rounded-full bg-slate-500 peer-checked:bg-blue-700 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-blue-700 after:absolute after:top-0.5 after:left-0.5 after:size-5 after:rounded-full after:bg-white peer-checked:after:translate-x-5 motion-safe:transition-colors motion-safe:after:transition-transform"></span></label>
          <label class="relative flex min-h-11 cursor-pointer items-center justify-between gap-4 py-3"><span class="text-sm text-slate-800">Quiet hours, 22:00 to 07:00</span><input type="checkbox" role="switch" class="peer sr-only" checked><span aria-hidden="true" class="relative h-6 w-11 shrink-0 rounded-full bg-slate-500 peer-checked:bg-blue-700 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-blue-700 after:absolute after:top-0.5 after:left-0.5 after:size-5 after:rounded-full after:bg-white peer-checked:after:translate-x-5 motion-safe:transition-colors motion-safe:after:transition-transform"></span></label>
        </div>
      </fieldset>
    </div>
  </div>
</div>

Switch that reveals extra fields

  • group-has-[#id:checked]:
  • hidden
  • sm:grid-cols-2
  • focus-visible:outline-*
  • aria-controls

Turn on the auto-reply.

Turning on the auto-reply reveals its date and message fields with group-has-[#ttg-rv-switch:checked]:grid on the panel. Naming the switch by id matters, because a plain group-has-checked: would also react to checkboxes inside the revealed fields. Off, the fields are display none, so they also leave the tab order and are skipped by screen readers.

View code
<div class="group w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10">
  <div class="flex items-start justify-between gap-4">
    <div class="min-w-0">
      <label for="ttg-rv-switch" class="text-base font-semibold text-slate-900">Out-of-office reply</label>
      <p id="ttg-rv-desc" class="mt-0.5 text-sm text-slate-600">Answer new emails automatically while you are away.</p>
    </div>
    <span class="relative mt-0.5 inline-flex shrink-0">
      <input id="ttg-rv-switch" type="checkbox" role="switch" aria-describedby="ttg-rv-desc" aria-controls="ttg-rv-fields" class="peer absolute inset-0 size-full cursor-pointer opacity-0">
      <span aria-hidden="true" class="pointer-events-none h-7 w-12 rounded-full bg-slate-500 peer-checked:bg-rose-700 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-rose-700 motion-safe:transition-colors"></span>
      <span aria-hidden="true" class="pointer-events-none absolute top-1 left-1 size-5 rounded-full bg-white shadow-sm peer-checked:translate-x-5 motion-safe:transition-transform"></span>
    </span>
  </div>
  <div id="ttg-rv-fields" class="mt-5 hidden gap-4 border-t border-slate-200 pt-5 group-has-[#ttg-rv-switch:checked]:grid">
    <div class="grid gap-3 sm:grid-cols-2">
      <div class="min-w-0">
        <label for="ttg-rv-from" class="block text-sm font-medium text-slate-800">First day away</label>
        <input id="ttg-rv-from" type="date" value="2026-12-21" class="mt-1.5 w-full min-w-0 rounded-lg border border-slate-400 px-3 py-2 text-sm text-slate-900 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-rose-700">
      </div>
      <div class="min-w-0">
        <label for="ttg-rv-to" class="block text-sm font-medium text-slate-800">Back on</label>
        <input id="ttg-rv-to" type="date" value="2027-01-04" class="mt-1.5 w-full min-w-0 rounded-lg border border-slate-400 px-3 py-2 text-sm text-slate-900 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-rose-700">
      </div>
    </div>
    <div>
      <label for="ttg-rv-msg" class="block text-sm font-medium text-slate-800">Message</label>
      <textarea id="ttg-rv-msg" rows="3" class="mt-1.5 w-full rounded-lg border border-slate-400 px-3 py-2 text-sm text-slate-900 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-rose-700">Thanks for your email. I am away until January 4 and will reply when I am back. For urgent questions, write to studio@example.com.</textarea>
    </div>
    <label class="flex items-center gap-2 text-sm text-slate-700"><input type="checkbox" class="size-4 accent-rose-700" checked> Only reply to people in my contacts</label>
  </div>
</div>

Ask before turning a switch off

  • aria-checked:
  • group-aria-checked:
  • data-pending:
  • hidden attribute
  • aria-live

Press the switch, then answer the question.

Turning two-step verification off is risky, so the switch stays on and an inline confirm opens right under it, with focus moved to the safe Keep it on button. Only Turn off flips aria-checked, Escape cancels and focus returns to the switch either way. While the question is open, data-pending:ring-4 rings the switch so it is clear which setting is being asked about.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10">
  <div class="flex items-start justify-between gap-4">
    <div class="min-w-0">
      <p id="ttg-cf-label" class="text-sm font-semibold text-slate-900">Two-step verification</p>
      <p id="ttg-cf-status" class="mt-0.5 text-sm text-slate-600" aria-live="polite">On. Codes come from your authenticator app.</p>
    </div>
    <button id="ttg-cf-switch" type="button" role="switch" aria-checked="true" aria-labelledby="ttg-cf-label" aria-describedby="ttg-cf-status" class="group relative mt-0.5 inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full bg-slate-500 p-1 before:absolute before:-inset-2 aria-checked:bg-green-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-700 data-pending:ring-4 data-pending:ring-amber-300 motion-safe:transition-colors"><span aria-hidden="true" class="size-5 rounded-full bg-white shadow-sm group-aria-checked:translate-x-5 motion-safe:transition-transform motion-safe:duration-200"></span></button>
  </div>
  <div id="ttg-cf-panel" role="group" aria-labelledby="ttg-cf-q" class="mt-4 rounded-xl bg-amber-50 p-4 ring-1 ring-amber-700/30" hidden>
    <p id="ttg-cf-q" class="text-sm font-semibold text-amber-950">Turn off two-step verification?</p>
    <p class="mt-1 text-sm text-amber-900">Anyone with your password could then sign in to your account.</p>
    <div class="mt-3 flex flex-wrap gap-2">
      <button id="ttg-cf-keep" type="button" class="min-h-11 rounded-lg bg-slate-900 px-4 text-sm font-semibold text-white hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900">Keep it on</button>
      <button id="ttg-cf-off" type="button" class="min-h-11 rounded-lg px-4 text-sm font-semibold text-red-800 ring-1 ring-red-800/40 hover:bg-red-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-800">Turn off</button>
    </div>
  </div>
</div>

Toggles in real interfaces

A dark mode toggle that flips the dark class on its own wrapper, a monthly and yearly pricing switch that rewrites the prices, and a smart home panel on a dark stage where the whole tile is the switch.

Dark mode toggle that flips the dark class on its own wrapper

  • dark:
  • @custom-variant dark
  • aria-checked:
  • group-aria-checked:
  • scheme-light dark:scheme-dark

Press the Dark mode switch.

With the dark variant set to &:where(.dark, .dark *), adding the dark class to any ancestor flips every dark: utility inside it, so the switch toggles dark on the app wrapper instead of the html element. The thumb swaps a sun for a moon with group-aria-checked:hidden and group-aria-checked:block. On a real site you would put the class on html and remember the choice, as the guide below explains.

View code
<div id="ttg-th-app" class="scheme-light bg-slate-100 px-4 py-8 text-slate-900 sm:px-8 dark:scheme-dark dark:bg-slate-950 dark:text-slate-100 motion-safe:transition-colors">
  <div class="mx-auto max-w-3xl">
    <header class="flex flex-wrap items-center justify-between gap-4">
      <div>
        <p class="text-lg font-semibold">Ledgerly</p>
        <p class="text-sm text-slate-600 dark:text-slate-400">October overview</p>
      </div>
      <div class="flex items-center gap-3">
        <span id="ttg-th-label" class="text-sm font-medium text-slate-700 dark:text-slate-300">Dark mode</span>
        <button id="ttg-th-switch" type="button" role="switch" aria-checked="false" aria-labelledby="ttg-th-label" class="group relative inline-flex h-8 w-14 shrink-0 cursor-pointer items-center rounded-full bg-sky-200 p-0.5 ring-1 ring-sky-700 ring-inset before:absolute before:-inset-1.5 aria-checked:bg-indigo-900 aria-checked:ring-indigo-300/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 dark:focus-visible:outline-indigo-300 motion-safe:transition-colors">
          <span aria-hidden="true" class="grid size-7 place-items-center rounded-full bg-white shadow-sm group-aria-checked:translate-x-6 group-aria-checked:bg-indigo-200 motion-safe:transition-transform motion-safe:duration-300">
            <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" class="size-4 text-amber-600 group-aria-checked:hidden"><circle cx="10" cy="10" r="3.2"/><path d="M10 2.5v1.8M10 15.7v1.8M2.5 10h1.8M15.7 10h1.8M4.7 4.7l1.3 1.3M14 14l1.3 1.3M4.7 15.3 6 14M14 6l1.3-1.3"/></svg>
            <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" class="hidden size-4 text-indigo-900 group-aria-checked:block"><path d="M15.5 12.5A6 6 0 0 1 7.5 4.5a6 6 0 1 0 8 8z"/></svg>
          </span>
        </button>
      </div>
    </header>
    <div class="mt-6 grid gap-4 sm:grid-cols-3">
      <div class="rounded-xl bg-white p-4 ring-1 ring-slate-900/10 dark:bg-slate-900 dark:ring-white/10">
        <p class="text-sm text-slate-600 dark:text-slate-400">Income</p>
        <p class="mt-1 text-2xl font-semibold">$18,240</p>
        <p class="mt-1 text-xs font-medium text-emerald-700 dark:text-emerald-400">+12% vs September</p>
      </div>
      <div class="rounded-xl bg-white p-4 ring-1 ring-slate-900/10 dark:bg-slate-900 dark:ring-white/10">
        <p class="text-sm text-slate-600 dark:text-slate-400">Expenses</p>
        <p class="mt-1 text-2xl font-semibold">$9,615</p>
        <p class="mt-1 text-xs font-medium text-rose-700 dark:text-rose-400">+4% vs September</p>
      </div>
      <div class="rounded-xl bg-white p-4 ring-1 ring-slate-900/10 dark:bg-slate-900 dark:ring-white/10">
        <p class="text-sm text-slate-600 dark:text-slate-400">Unpaid invoices</p>
        <p class="mt-1 text-2xl font-semibold">3</p>
        <p class="mt-1 text-xs font-medium text-slate-600 dark:text-slate-400">$2,980 outstanding</p>
      </div>
    </div>
    <ul class="mt-4 divide-y divide-slate-200 rounded-xl bg-white ring-1 ring-slate-900/10 dark:divide-white/10 dark:bg-slate-900 dark:ring-white/10">
      <li class="flex items-center justify-between gap-3 px-4 py-3 text-sm"><span class="min-w-0 truncate">Harbor Coffee Roasters</span><span class="shrink-0 font-medium tabular-nums">$1,200.00</span></li>
      <li class="flex items-center justify-between gap-3 px-4 py-3 text-sm"><span class="min-w-0 truncate">Northfield Print Shop</span><span class="shrink-0 font-medium tabular-nums">$640.00</span></li>
      <li class="flex items-center justify-between gap-3 px-4 py-3 text-sm"><span class="min-w-0 truncate">Juniper Yoga Studio</span><span class="shrink-0 font-medium tabular-nums">$1,140.00</span></li>
    </ul>
  </div>
</div>

Monthly and yearly pricing toggle

  • peer-checked:
  • has-checked:
  • aria-live
  • md:grid-cols-3
  • tabular-nums

Switch to yearly billing.

The billing switch is a checkbox, so the label text and the Save 20% badge stay readable on both sides of it, and has-checked:bg-emerald-50 on the pill confirms the yearly choice. A change listener rewrites each price from data attributes and updates one aria-live summary, so screen readers hear one sentence instead of three numbers. tabular-nums keeps the digits from jumping as they change.

View code
<div class="bg-white px-4 py-10 sm:px-8">
  <div class="mx-auto max-w-4xl">
    <div class="text-center">
      <h2 class="text-2xl font-semibold text-slate-900">Plans for teams of every size</h2>
      <p class="mt-2 text-sm text-slate-600">Fieldnote pricing. Cancel any time.</p>
      <label class="mt-5 inline-flex min-h-11 cursor-pointer items-center gap-3 rounded-full px-4 py-1.5 ring-1 ring-slate-300 has-checked:bg-emerald-50 has-checked:ring-emerald-700/40">
        <span class="text-sm font-medium text-slate-800">Monthly</span>
        <input id="ttg-pr-yearly" type="checkbox" role="switch" class="peer sr-only" aria-label="Bill yearly">
        <span aria-hidden="true" class="relative h-6 w-11 shrink-0 rounded-full bg-slate-500 peer-checked:bg-emerald-700 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-emerald-700 after:absolute after:top-0.5 after:left-0.5 after:size-5 after:rounded-full after:bg-white peer-checked:after:translate-x-5 motion-safe:transition-colors motion-safe:after:transition-transform"></span>
        <span class="text-sm font-medium text-slate-800">Yearly</span>
        <span class="rounded-full bg-emerald-700 px-2 py-0.5 text-xs font-semibold text-white">Save 20%</span>
      </label>
      <p id="ttg-pr-summary" class="sr-only" aria-live="polite">Showing monthly prices.</p>
    </div>
    <div class="mt-8 grid gap-4 md:grid-cols-3">
      <div class="rounded-2xl p-6 ring-1 ring-slate-200">
        <h3 class="text-base font-semibold text-slate-900">Solo</h3>
        <p class="mt-1 text-sm text-slate-600">For one person and a few projects.</p>
        <p class="mt-5 flex items-baseline gap-1"><span class="ttg-pr-price text-4xl font-semibold text-slate-900 tabular-nums" data-monthly="$12" data-yearly="$10">$12</span><span class="ttg-pr-per text-sm text-slate-600">/ month</span></p>
        <a href="#" class="mt-6 flex min-h-11 items-center justify-center rounded-lg text-sm font-semibold text-slate-900 ring-1 ring-slate-300 hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700">Start with Solo</a>
      </div>
      <div class="rounded-2xl bg-slate-900 p-6 text-white ring-1 ring-slate-900">
        <h3 class="text-base font-semibold">Studio</h3>
        <p class="mt-1 text-sm text-slate-300">Shared boards and client sharing.</p>
        <p class="mt-5 flex items-baseline gap-1"><span class="ttg-pr-price text-4xl font-semibold tabular-nums" data-monthly="$29" data-yearly="$23">$29</span><span class="ttg-pr-per text-sm text-slate-300">/ month</span></p>
        <a href="#" class="mt-6 flex min-h-11 items-center justify-center rounded-lg bg-emerald-500 text-sm font-semibold text-slate-950 hover:bg-emerald-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-400">Start with Studio</a>
      </div>
      <div class="rounded-2xl p-6 ring-1 ring-slate-200">
        <h3 class="text-base font-semibold text-slate-900">Agency</h3>
        <p class="mt-1 text-sm text-slate-600">Unlimited clients, roles and audit log.</p>
        <p class="mt-5 flex items-baseline gap-1"><span class="ttg-pr-price text-4xl font-semibold text-slate-900 tabular-nums" data-monthly="$59" data-yearly="$47">$59</span><span class="ttg-pr-per text-sm text-slate-600">/ month</span></p>
        <a href="#" class="mt-6 flex min-h-11 items-center justify-center rounded-lg text-sm font-semibold text-slate-900 ring-1 ring-slate-300 hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700">Start with Agency</a>
      </div>
    </div>
  </div>
</div>

Smart home panel where the whole tile is the switch

  • has-checked:
  • group-has-checked:
  • has-focus-visible:
  • grid-cols-2
  • dark stage

Every tile is a label around an sr-only checkbox with role="switch", so the full card is the click target and has-checked:bg-amber-300 has-checked:text-stone-950 lights it up. The On and Off words and the small track swap through group-has-checked: and stay aria-hidden, which keeps each accessible name steady at Kitchen lights or Porch light. has-focus-visible: draws the ring on the tile, where the keyboard user is looking.

View code
<div class="w-full max-w-2xl">
  <div class="flex items-baseline justify-between gap-3">
    <h2 class="text-lg font-semibold text-white">Home</h2>
    <p class="text-sm text-slate-400">Indoor 21.5 &deg;C</p>
  </div>
  <div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-3">
    <label class="group relative flex min-h-32 cursor-pointer flex-col justify-between rounded-2xl bg-slate-800 p-4 text-slate-100 ring-1 ring-white/10 has-checked:bg-amber-300 has-checked:text-stone-950 has-checked:ring-amber-200 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-amber-300 motion-safe:transition-colors">
      <input id="ttg-hp-living" type="checkbox" role="switch" class="sr-only" checked>
      <span class="flex items-start justify-between">
        <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" class="size-6"><path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.5 10.9c.6.5 1 1.2 1 2.1h5c0-.9.4-1.6 1-2.1A6 6 0 0 0 12 3z"/></svg>
        <span aria-hidden="true" class="flex h-5 w-9 items-center rounded-full bg-slate-600 p-0.5 group-has-checked:bg-stone-900"><span class="size-4 rounded-full bg-slate-300 group-has-checked:translate-x-4 group-has-checked:bg-amber-300 motion-safe:transition-transform"></span></span>
      </span>
      <span>
        <span class="block text-sm font-semibold">Living room lights</span>
        <span aria-hidden="true" class="block text-xs text-slate-400 group-has-checked:hidden">Off</span>
        <span aria-hidden="true" class="hidden text-xs text-stone-800 group-has-checked:block">On, 80%</span>
      </span>
    </label>
    <label class="group relative flex min-h-32 cursor-pointer flex-col justify-between rounded-2xl bg-slate-800 p-4 text-slate-100 ring-1 ring-white/10 has-checked:bg-amber-300 has-checked:text-stone-950 has-checked:ring-amber-200 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-amber-300 motion-safe:transition-colors">
      <input id="ttg-hp-kitchen" type="checkbox" role="switch" class="sr-only">
      <span class="flex items-start justify-between">
        <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" class="size-6"><path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.5 10.9c.6.5 1 1.2 1 2.1h5c0-.9.4-1.6 1-2.1A6 6 0 0 0 12 3z"/></svg>
        <span aria-hidden="true" class="flex h-5 w-9 items-center rounded-full bg-slate-600 p-0.5 group-has-checked:bg-stone-900"><span class="size-4 rounded-full bg-slate-300 group-has-checked:translate-x-4 group-has-checked:bg-amber-300 motion-safe:transition-transform"></span></span>
      </span>
      <span>
        <span class="block text-sm font-semibold">Kitchen lights</span>
        <span aria-hidden="true" class="block text-xs text-slate-400 group-has-checked:hidden">Off</span>
        <span aria-hidden="true" class="hidden text-xs text-stone-800 group-has-checked:block">On, 100%</span>
      </span>
    </label>
    <label class="group relative flex min-h-32 cursor-pointer flex-col justify-between rounded-2xl bg-slate-800 p-4 text-slate-100 ring-1 ring-white/10 has-checked:bg-amber-300 has-checked:text-stone-950 has-checked:ring-amber-200 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-amber-300 motion-safe:transition-colors">
      <input id="ttg-hp-porch" type="checkbox" role="switch" class="sr-only">
      <span class="flex items-start justify-between">
        <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" class="size-6"><path d="M4 11 12 4l8 7M6 9.5V20h12V9.5M10 20v-5h4v5"/></svg>
        <span aria-hidden="true" class="flex h-5 w-9 items-center rounded-full bg-slate-600 p-0.5 group-has-checked:bg-stone-900"><span class="size-4 rounded-full bg-slate-300 group-has-checked:translate-x-4 group-has-checked:bg-amber-300 motion-safe:transition-transform"></span></span>
      </span>
      <span>
        <span class="block text-sm font-semibold">Porch light</span>
        <span aria-hidden="true" class="block text-xs text-slate-400 group-has-checked:hidden">Off, sunset timer</span>
        <span aria-hidden="true" class="hidden text-xs text-stone-800 group-has-checked:block">On until 23:00</span>
      </span>
    </label>
    <label class="group relative flex min-h-32 cursor-pointer flex-col justify-between rounded-2xl bg-slate-800 p-4 text-slate-100 ring-1 ring-white/10 has-checked:bg-amber-300 has-checked:text-stone-950 has-checked:ring-amber-200 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-amber-300 motion-safe:transition-colors">
      <input id="ttg-hp-heat" type="checkbox" role="switch" class="sr-only" checked>
      <span class="flex items-start justify-between">
        <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" class="size-6"><path d="M10 14.5V5a2 2 0 0 1 4 0v9.5a4 4 0 1 1-4 0zM12 11v6"/></svg>
        <span aria-hidden="true" class="flex h-5 w-9 items-center rounded-full bg-slate-600 p-0.5 group-has-checked:bg-stone-900"><span class="size-4 rounded-full bg-slate-300 group-has-checked:translate-x-4 group-has-checked:bg-amber-300 motion-safe:transition-transform"></span></span>
      </span>
      <span>
        <span class="block text-sm font-semibold">Heating</span>
        <span aria-hidden="true" class="block text-xs text-slate-400 group-has-checked:hidden">Off</span>
        <span aria-hidden="true" class="hidden text-xs text-stone-800 group-has-checked:block">On, target 22 &deg;C</span>
      </span>
    </label>
    <label class="group relative flex min-h-32 cursor-pointer flex-col justify-between rounded-2xl bg-slate-800 p-4 text-slate-100 ring-1 ring-white/10 has-checked:bg-amber-300 has-checked:text-stone-950 has-checked:ring-amber-200 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-amber-300 motion-safe:transition-colors">
      <input id="ttg-hp-fan" type="checkbox" role="switch" class="sr-only">
      <span class="flex items-start justify-between">
        <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" class="size-6"><circle cx="12" cy="12" r="1.5"/><path d="M12 10.5C12 6 13 3.5 15.5 4S16 9 12 10.5zM13.5 12c4.5 0 7 1 6.5 3.5S15 16 13.5 12zM12 13.5c0 4.5-1 7-3.5 6.5S8 15 12 13.5zM10.5 12C6 12 3.5 11 4 8.5S9 8 10.5 12z"/></svg>
        <span aria-hidden="true" class="flex h-5 w-9 items-center rounded-full bg-slate-600 p-0.5 group-has-checked:bg-stone-900"><span class="size-4 rounded-full bg-slate-300 group-has-checked:translate-x-4 group-has-checked:bg-amber-300 motion-safe:transition-transform"></span></span>
      </span>
      <span>
        <span class="block text-sm font-semibold">Bedroom fan</span>
        <span aria-hidden="true" class="block text-xs text-slate-400 group-has-checked:hidden">Off</span>
        <span aria-hidden="true" class="hidden text-xs text-stone-800 group-has-checked:block">On, speed 2</span>
      </span>
    </label>
    <label class="group relative flex min-h-32 cursor-pointer flex-col justify-between rounded-2xl bg-slate-800 p-4 text-slate-100 ring-1 ring-white/10 has-checked:bg-amber-300 has-checked:text-stone-950 has-checked:ring-amber-200 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-amber-300 motion-safe:transition-colors">
      <input id="ttg-hp-garden" type="checkbox" role="switch" class="sr-only">
      <span class="flex items-start justify-between">
        <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" class="size-6"><path d="M12 3c3 4 5 6.8 5 9.5a5 5 0 0 1-10 0C7 9.8 9 7 12 3z"/></svg>
        <span aria-hidden="true" class="flex h-5 w-9 items-center rounded-full bg-slate-600 p-0.5 group-has-checked:bg-stone-900"><span class="size-4 rounded-full bg-slate-300 group-has-checked:translate-x-4 group-has-checked:bg-amber-300 motion-safe:transition-transform"></span></span>
      </span>
      <span>
        <span class="block text-sm font-semibold">Garden sprinklers</span>
        <span aria-hidden="true" class="block text-xs text-slate-400 group-has-checked:hidden">Off, next run 06:00</span>
        <span aria-hidden="true" class="hidden text-xs text-stone-800 group-has-checked:block">Running, 15 min</span>
      </span>
    </label>
  </div>
</div>

What Is a Tailwind Toggle and Does Tailwind CSS Include One?

Tailwind CSS includes nothing like that. Every toggle on the web is assembled from utilities such as peer-checked, rounded-full and translate-x-5, and the finished control switches a single setting between on and off.

Because Tailwind is a utility-first framework without prebuilt widgets, the toggle is a pattern you copy, not a class you call.

Tailwind Plus (formerly Tailwind UI) has toggles made by the Tailwind CSS team, but it closed to new customers on September 9, 2026, when Tailwind Labs joined Shopify.

You’ll run into two builds. One is the checkbox build, a hidden input plus a styled sibling, with no JavaScript at all. The other is a button with role="switch", styled from aria-checked, where script flips the state.

On screen they look identical. What differs is where the on/off state lives.

How to Build a Toggle Switch Without JavaScript

Two elements do the work, a hidden checkbox and a styled sibling. The sibling picks up the checkbox state through the peer-checked variant, so no script runs.

  1. Wrap the control in a label so a click anywhere on it reaches the input.
  2. Add the checkbox with sr-only peer.
  3. Put a div after the input as the track, with width, height, rounded-full and the off color.
  4. Draw the thumb with after: classes on the track: absolute position, size, rounded-full and a white fill.
  5. Add peer-checked:bg-* to the track and peer-checked:after:translate-x-* to move the thumb.
  6. Finish with a text span for the visible label.

Here’s a version that works.

<label class="inline-flex items-center cursor-pointer">
  <input type="checkbox" class="peer sr-only">
  <div class="relative h-6 w-11 rounded-full bg-gray-200 transition-colors duration-300
      peer-checked:bg-blue-600 peer-focus-visible:ring-2 peer-focus-visible:ring-blue-300
      after:absolute after:left-0.5 after:top-0.5 after:h-5 after:w-5 after:rounded-full
      after:bg-white after:transition-transform after:duration-300
      peer-checked:after:translate-x-5"></div>
    <span class="ms-3 text-sm text-gray-900">Notifications</span>
  </label>

Flowbite’s toggle docs use the same structure, down to the thumb drawn with an after: pseudo-element.

According to the Tailwind CSS documentation, the sr-only utility sets position: absolute and overflow: hidden, along with a 1px width and height and a negative 1px margin. The input leaves the layout but stays available to screen readers, as the Tailgrids toggle docs point out.

Some tutorials hide the input with w-0 h-0 opacity-0 instead (KindaCode does). It works. I’d still go with sr-only, since it’s the documented utility for the job.

The input has to come before the track in the DOM. That’s the one rule you can’t bend, because the Tailwind CSS documentation limits the peer marker to previous siblings.

The same checkbox technique carries over to a toggle switch written in plain CSS, where the :checked selector replaces peer-checked.

Paste the markup into Tailwind Play to check the thumb travel before it goes into a project.

How to Style Toggle States, Sizes and Animation

Variants handle state. Width and height set the size, and the thumb’s slide comes from translate-x-* plus a duration.

Checked, disabled and focus classes

The Tailwind CSS documentation says the peer pattern works with every pseudo-class variant, including peer-focus and peer-disabled.

  • Checked styling goes on the track with peer-checked:bg-blue-600.
  • For focus, peer-focus-visible:ring-2 lets keyboard users see where they are.
  • Disabled means the disabled attribute on the input and a muted label color (Flowbite uses its text-fg-disabled token).
  • Flowbite also documents disabled and checked together as a separate look of its own.

Material Tailwind takes another route for disabled: pointer-events-none on the parent div, with opacity-50 on the input and the thumb. The whole control dims and stops responding at once.

Size classes and thumb travel distance

Thumb travel equals the track width, minus the thumb width, minus the inset on both sides. Get that sum wrong and the thumb stops short or overshoots the track.

SourceTrackThumbThumb travel
Material Tailwind (HTML switch)w-11 h-5w-5 h-5translate-x-6
Bordered track patternw-11 h-6, border-2w-5 h-5translate-x-5
DockYard (ARIA button)w-14 h-8, p-1w-6 h-6translate-x-full

Every row obeys the rule, which is derived here from the published class values. Material Tailwind has no inset, so 11 minus 5 gives 6.

The bordered track loses one unit to its 2px borders (11 – 5 – 1 = 5), since the default spacing unit is 4px. DockYard loses two units to p-1 padding (14 – 6 – 2 = 6), which equals the thumb width and explains translate-x-full.

If you just want ready-made sizes, Flowbite shows a default toggle (w-9 h-5) and a large one (w-11 h-6). Tailgrids ships sm and md, with sm as the default.

Transition utilities and duration

duration-300 sets transition-duration: 300ms, per the Tailwind CSS documentation.

The Headless UI switch example uses plain transition, which defaults to 150ms in Tailwind, and Material Tailwind uses duration-300. Some tutorials push it to duration-700, which feels slow on a control people expect to flip at once.

Put transition-colors on the track and transition-transform on the thumb. transition-all (Flowbite’s choice) works too, but it animates every property that changes.

The sliding thumb is a small micro-interaction, so motion-sensitive users should be able to switch it off. Tailwind’s variants cover prefers-reduced-motion, which means a motion-reduce:transition-none class removes the slide.

How Do Dark Mode and Color Variants Work on a Toggle?

Add a dark: variant to every class that sets a color. By default dark: follows the prefers-color-scheme media query, so the toggle adapts to the operating system with no configuration.

That covers the off track, the on track, the thumb, the border, the label text and the focus ring.

Flowbite’s color examples show the pattern with dark:bg-gray-700 on the track and a dark:peer-checked: twin for each checked color.

Color variants come from swapping the color inside the peer-checked and peer-focus classes. That is how Flowbite builds its orange and other color examples.

Using a toggle to switch the site theme

Manual theme switching needs the dark variant keyed to a class or attribute instead of the media query. In Tailwind v3 that is darkMode: 'selector', which replaced the class strategy in v3.4.1, according to the Tailwind CSS documentation.

In Tailwind v4 the same change lives in the stylesheet: @custom-variant dark (&:where(.dark, .dark *));.

The toggle then adds or removes the dark class on the html element and saves the choice to localStorage. Initialise the checkbox from that stored value, or it shows the wrong position after a reload.

An inline script in the document head applies the stored theme on load, which fixes the flash of the wrong theme (Tailwind CSS documentation).

A two-position toggle can’t express “follow the system”, though. Tailwind’s documentation describes three-way theme toggles for that case, built on window.matchMedia().

How Do You Make a Tailwind Toggle Accessible?

Give the control a switch role, a state, an accessible name, Space-key support and a visible focus style. A native checkbox covers most of that. A button needs role="switch" and an aria-checked value set by script.

The W3C ARIA Authoring Practices Guide switch pattern asks for the following.

  • Role switch, or a native checkbox, which uses its checked attribute instead of aria-checked
  • A name from visible text, aria-labelledby or aria-label
  • Space changes the state. Enter is optional
  • aria-checked is true when on and false when off

ARIA attributes matter most on the button build, where nothing native reports the state. On the checkbox build the browser does that work, which is the main argument for it on static pages.

The label shouldn’t change when the state does, and the W3C pattern says so too.

“Notifications” stays “Notifications”. The switch announces on or off, so a screen reader says “Notifications switch on” instead of “Notifications checkbox checked”.

Sources disagree on the role. Some older toggle markup puts role="checkbox" with aria-checked on a span, while the W3C documents three switch implementations: a div, a button and a checkbox input.

The W3C also calls switch, checkbox and toggle button functionally interchangeable in many cases. Pick the role whose announcement matches the setting.

Hiding the input with sr-only removes its visible outline, so draw the ring on the track with peer-focus-visible.

WCAG 2.1 success criterion 1.4.11 requires user interface components and their states to reach a 3:1 contrast ratio against adjacent colors, with disabled controls exempt. The Headless UI documentation example uses bg-gray-200 for the off track, a pale gray close to a white page, so check the color contrast of the off state against that 3:1 threshold.

Checkbox, ARIA Button or Headless UI: Which Approach Fits?

Mostly this comes down to who owns the state. With the checkbox build the DOM does. The button build keeps it in the aria-checked attribute, and with a Headless UI Switch your app holds it.

ApproachJavaScriptState lives inForm value
Checkbox buildNoneThe input’s checked stateNative, sent only when checked
Button buildYes, flips aria-checkedThe aria-checked attributeNone by default
Headless UI SwitchReact or VueComponent stateVia the name prop

The checkbox build is a custom-styled checkbox, which makes it the cheapest option and the right default when nothing outside the DOM needs the state. Nothing to install, nothing to hydrate.

Headless UI is the heavier choice, and not always the better one. Its documentation says the Switch offers the same semantics and keyboard navigation as a native checkbox, so on a plain HTML page it adds a dependency without adding behavior.

What it does add is control: a controlled value, state exposed to your classes, and a renderless structure that composes with animation libraries such as Framer Motion and React Spring.

Reach for the button build when script is already on the page and every visual state should key off aria-checked. It’s the only build where the markup does nothing until you write the state flip.

How to Use a Tailwind Toggle in React, Vue and Alpine.js

In each framework you bind the toggle to a boolean and let that value drive the same peer classes. The markup stays as it is. Only the state wiring changes.

React

A controlled checkbox needs two props, checked and onChange, according to the React documentation.

const [on, setOn] = useState(false)

<input type="checkbox" className="peer sr-only" checked={on} onChange={(e) => setOn(e.target.checked)} />

If Tailwind is not set up in the project yet, the steps for using Tailwind CSS in React come first.

For the button build, the Tailwind UI JavaScript integration guide pairs an onClick that flips state with an aria-checked value read from that same state.

One gotcha. An input cannot switch between controlled and uncontrolled during its lifetime, so choose checked or defaultChecked up front (React documentation).

Vue

v-model on a single checkbox gives you a boolean. Under the hood, checkboxes use the checked property and the change event. And v-model ignores a static checked attribute, so the starting value belongs in data.

All of that comes from the Vue form input bindings guide. The peer classes need no changes.

Alpine.js

Alpine needs a script tag and nothing else.

Put x-data="{ on: false }" on the label and x-model="on" on the checkbox. The Alpine documentation describes x-model as two-way bound: it both sets and gets, so the input and its data stay in sync.

On the checkbox build, x-model goes on the hidden input. The button build pairs @click="on = !on" with :aria-checked="on".

Headless UI Switch

Headless UI handles ARIA attributes, keyboard interaction and focus, the work that makes accessible UI components costly to build by hand, according to Tailwind Labs’ announcement of its React and Vue support.

The Switch renders a button and exposes its state through data attributes. Controlled use takes checked and onChange, while uncontrolled use takes defaultChecked (Headless UI documentation).

Version 1 gives you Switch.Group, Switch.Label and Switch.Description. State is styled with ui-checked: modifiers from the @headlessui/tailwindcss plugin, and it’s exposed as data-headlessui-state="checked".

Version 2 for React swaps those parts out for Field, Label and Description. The ui-checked: modifiers become data-[checked]: (Tailwind v4 also accepts the shorter data-checked:), and the Switch exposes a data-checked attribute.

The v2.0.0 changelog adds Field, Label and Description, and v2.0.4 marks SwitchGroup as deprecated, so any tutorial built on Switch.Group is written for v1. The Vue docs still sit under the v1 path.

import { Field, Label, Switch } from '@headlessui/react'
import { useState } from 'react'

function Example() {
  const [enabled, setEnabled] = useState(false)

  return (
  <Field>
  <Label>Enable notifications</Label>
  <Switch
  checked={enabled}
  onChange={setEnabled}
  className="group inline-flex h-6 w-11 items-center rounded-full bg-gray-200 transition data-[checked]:bg-blue-600"
  >
  <span className="size-4 translate-x-1 rounded-full bg-white transition group-data-[checked]:translate-x-6" />
  </Switch>
  </Field>
  )
}

The thumb starts one unit in and stops one unit from the edge: translate-x-1 to translate-x-6 on a w-11 track.

Prebuilt Toggle Components Worth Using

Tailwind Plus, Flowbite, daisyUI, Preline UI, Material Tailwind and shadcn/ui all ship a Tailwind toggle, and what you copy from each one differs. Some hand you markup, daisyUI hands you a class, and shadcn/ui puts a component file in your repo.

Tailwind Plus

Tailwind Plus was sold as a one-time license for individuals and teams. On September 9, 2026, Tailwind Labs announced it was joining Shopify and closed sign-ups for new customers; existing customers keep their access (Tailwind CSS blog).

Its toggle examples came in React, Vue and plain HTML, and the React and Vue versions run on Headless UI. The Catalyst UI Kit adds a styled Switch that extends the Headless UI Switch, with a color prop that defaults to dark/zinc.

If you don’t already own it, it’s no longer an option, and for a single toggle the checkbox build costs nothing anyway.

Flowbite

Flowbite gives you copy-paste HTML built on a peer checkbox, with no component runtime. It suits prototypes and server-rendered pages well. The catch is that framework state wiring is yours to add.

If the checkbox build is what you want and you’d rather not type it, Flowbite is the shortcut.

daisyUI

One class replaces the whole build.

Put toggle on an input type="checkbox" and you’re done. Colors run from toggle-primary and toggle-success to toggle-error and five more, and sizes go from toggle-xs to toggle-xl.

The daisyUI docs describe the toggle as a checkbox styled to look like a switch, and they override its colors with ordinary Tailwind classes such as checked:bg-orange-400. The trade-off is that the look follows the daisyUI theme, so matching an unrelated brand means overriding it class by class.

Preline UI

Preline UI is an open-source set of prebuilt components based on Tailwind CSS, and its toggle sits on the switch docs page. By default it depends on the Tailwind CSS Forms plugin, which the docs tell you to install.

Watch the naming, though. Preline’s collapse docs use “toggle” for showing and hiding content, so the on/off control is the one filed under switch.

Material Tailwind

Here the checkbox itself is the track (appearance-none) and a label acts as the thumb. Examples cover colors, labels, a disabled state and a ripple effect.

Pick it when you want the appearance-none approach, which drops the separate track div.

shadcn/ui

shadcn/ui adds a Switch with pnpm dlx shadcn@latest add switch, and it comes with disabled, aria-invalid and size options. The docs now offer Base UI, React Aria and Radix UI versions of the component (shadcn/ui documentation).

The component file lands in your codebase, so the track and thumb classes are yours to edit.

Radix and Base UI name the handler onCheckedChange, where Headless UI uses onChange. Supabase’s design system documents its Switch, built on Radix UI, wired to a form field that way.

It’s the natural choice when the project already runs shadcn/ui. Otherwise it brings in a primitive package (Radix UI or Base UI) for a single control.

When a CSS-Only Toggle Stops Working

A CSS-only toggle breaks wherever state has to live outside the DOM or a layout rule gets in the way.

SituationWhat breaksFix
Track cannot follow the inputpeer-checked never fireshas-[:checked] on the wrapping label
React controlled inputchecked without onChangeAdd the handler, or use defaultChecked
Vue v-modelStatic checked attribute ignoredSet the starting value in data
Plain form postUnchecked box sends nothingRead a missing field as off
Screen readerAnnounced as a checkboxrole=”switch” on the input

For the layout case, Tailwind’s has-* variant styles an element from the state of its descendants, so has-[:checked]:bg-blue-600 on a wrapping label works when the track cannot follow the input. Check :has() support for your target browsers first.

The Vue row catches people who copy a snippet with a static checked attribute. The Vue guide says v-model ignores initial checked values and treats the bound data as the source of truth.

The form row follows from browser behavior, since an unchecked box sends no value at all. The Vue guide makes the same point when it recommends radio inputs for a guaranteed yes or no.

The announcement row costs nothing to fix. PrimeReact’s InputSwitch puts the switch role on a hidden native checkbox, and the same attribute works on your own input.

One mistake to avoid. Hiding the input with hidden (display: none) removes it from the tab order, and the toggle stops responding to the keyboard.

The button build has its own limit. Until July 2025 the plain HTML examples in Tailwind Plus left aria-checked and the rest of the interactive behavior to your script. Its Elements library has made every HTML block interactive since, but in your own markup the state flip is still your job.

Inside a form, the toggle needs the same label and error handling as any other field, which is the ground accessible forms cover.

On a static page or prototype where script reads the checkbox directly, CSS-only still wins. None of the failures above apply there.

Tailwind Toggle FAQ

Is a toggle the same as a switch?

In everyday use, yes, but the W3C pattern separates them. A switch holds only on or off, while a toggle button can also be partially pressed, according to the ARIA Authoring Practices Guide.

Can a toggle show and hide content instead of switching a setting?

In some libraries, yes. Metronic’s KTUI Toggle component shows and hides elements, which is a different control from an on/off switch.

For a setting, build the switch. For content, flip the hidden class from script.

How do I add icons inside the thumb?

Use a real span as the thumb instead of an after: pseudo-element, and put two icons inside it. Mark the label with group, then show each icon with group-has-[:checked]:block or hide it with hidden.

How do I move the label to the left of the toggle?

Put the text span before the input inside the label, and swap ms-3 for me-3.

The peer rule only needs the track to follow the input, so the text can sit anywhere else.

Do toggle classes change between Tailwind v3 and v4?

The toggle classes stay the same. peer, peer-checked and sr-only read alike in the v3 and current docs. Only the dark mode setup changes, with a config option in v3 and a CSS @custom-variant in v4.

Is Tailwind Plus free?

No. Tailwind Plus was a paid, one-time license, and it has been closed to new customers since September 9, 2026.

The checkbox build needs only Tailwind CSS, so a single toggle never required the purchase.

Testing a Tailwind Toggle Before It Ships

The order I’d test in is keyboard first, then screen reader, then form payload.

  1. Tab to the control and press Space.
  2. Listen for the label and an on or off state.
  3. Submit with the toggle off and read the request.

The keyboard pass comes first because it needs no extra tool. The payload check comes last because it only matters when a server reads the value.

The W3C’s own switch example carries a notice that support gaps remain in some browser and assistive technology combinations, especially on mobile and touch devices. A desktop pass alone leaves touch untested, and the manual screen reader session is the cost no linter replaces.

Repeat the sequence after each library upgrade, and keep a web accessibility checklist beside it for the rest of the page.