Home / Snippets / UI Components /

CSS-only toggle

Checkbox-driven switch — keyboard accessible, no JavaScript.

Widely supported
uino-js

Quick implementation

/* HTML: <label><input type="checkbox" class="toggle-input"><span class="toggle-track"></span> Label</label> */

.toggle-input {
  position: absolute; opacity: 0; width: 0; height: 0;
}
.toggle-track {
  position: relative;
  width: 3rem; height: 1.6rem;
  background: oklch(0.82 0.02 260);
  border-radius: 999px; cursor: pointer;
  transition: background 0.25s;
  box-shadow: inset 0 1px 3px oklch(0 0 0 / 0.12);
}
.toggle-track::after {
  content: "";
  position: absolute; top: 0.2rem; left: 0.2rem;
  width: 1.2rem; height: 1.2rem;
  background: white; border-radius: 50%;
  box-shadow: 0 2px 5px oklch(0 0 0 / 0.22);
  transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.toggle-input:checked + .toggle-track {
  background: oklch(0.52 0.22 265); /* your accent */
}
.toggle-input:checked + .toggle-track::after {
  transform: translateX(1.4rem);
}
.toggle-input:focus-visible + .toggle-track {
  outline: 2px solid oklch(0.52 0.22 265);
  outline-offset: 3px;
}

Prompt this to your LLM

Includes role, constraints, two framework variants, and edge cases to handle.

You are a senior frontend engineer building accessible UI components.

Goal: A toggle switch (on/off) using only HTML and CSS — no JavaScript for open/close.

Technical constraints:
- Hide the real <input type="checkbox"> with opacity:0 / width:0 / height:0 (keep it in DOM for a11y).
- The visible switch is a <span class="toggle-track"> inside a <label>.
- Use the adjacent sibling selector: .toggle-input:checked + .toggle-track to change track color.
- Move the thumb (::after pseudo-element) with translateX — prefer cubic-bezier(0.34, 1.56, 0.64, 1) for a spring feel.
- Use oklch() for track background colors (off state: oklch(0.82 0.02 260), on: your accent).
- Focus ring: .toggle-input:focus-visible + .toggle-track { outline: 2px solid accent; outline-offset: 3px }

Framework variant (pick one):
A) Vanilla HTML + CSS only.
B) React component with a checked prop, onChange handler, label prop, and optional size ("sm" | "md") variant.

Edge cases to handle:
- Disabled state: .toggle-input:disabled + .toggle-track { opacity: 0.45; cursor: not-allowed }.
- Label click area: the entire <label> should be the click target.
- ARIA: the real checkbox already provides role="checkbox" and aria-checked to screen readers — do not add duplicate ARIA to the <span>.

Return HTML + CSS (or TSX + CSS module for React variant).

Why this matters in 2026

Custom toggle switches are everywhere — settings panels, feature flags, dark mode. The pattern is to visually replace a checkbox but keep the semantic element in the DOM so screen readers understand it. No JavaScript means no hydration cost, no event-listener leak, and the toggle works even before scripts load.

The logic

The <input type="checkbox"> is visually hidden but focusable. The visible track and thumb are a <span> after the input inside a <label>. Clicking anywhere on the label toggles the checkbox. :checked + .toggle-track changes the background; the thumb moves with transform: translateX(). The cubic-bezier(0.34, 1.56, 0.64, 1) spring gives it energy without being flashy.

Accessibility & performance

Leave the checkbox in the DOM — screen readers announce it as a checkbox with checked/unchecked state. Don't replace it with a <div> or add redundant role="switch" on the <span>. Use focus-visible for the focus ring so mouse users don't see a ring on click. Zero JavaScript means zero interaction cost.