Skip to main content
ASoc
Tutorial

React Switch: The Accessible Toggle, and When Not to Use One

role=switch in 30 lines, no dependency. Then the harder part: seven two-state toggles ship here, and not one of them is a switch.

The ASoc Team9 min read

A React switch is a two-state control for a setting that applies immediately. You do not need a library: a <button> with role="switch" and aria-checked is the whole accessible primitive, and Tailwind styles the track and thumb. The harder question is whether you want a switch at all — this storefront ships seven two-state toggles and none of them is one.

"React switch" also returns two unrelated things in search: the toggle component, and the JavaScript switch statement used to pick what to render. Both are below, toggle first.

Three different things called "switch"

WhatLooks likeUse when
Switch / togglerole="switch", aria-checkedA binary setting that takes effect on click — notifications on/off
Toggle button<button aria-pressed>A two-state control or action — bookmark, mute, bold
Checkbox<input type="checkbox">A binary choice submitted with a form — terms consent
switch statementswitch (x) { case … }Picking one of several branches in render logic

The first three are not interchangeable, and screen readers announce them differently: "switch, on", "button, pressed", "checkbox, checked". Picking by appearance is how you end up with a control that looks like a switch and reads like a checkbox.

An accessible switch, no dependencies

A native <button> already gives you focus, Enter and Space. Everything else is two ARIA attributes:

"use client";
import { useState } from "react";

export default function Switch({
  checked,
  onChange,
  label,
}: {
  checked: boolean;
  onChange: (next: boolean) => void;
  label: string;
}) {
  return (
    <button
      type="button"
      role="switch"
      aria-checked={checked}
      aria-label={label}
      onClick={() => onChange(!checked)}
      className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full duration-200 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:outline-none ${
        checked ? "bg-primary" : "bg-gray-200 dark:bg-gray-700"
      }`}
    >
      <span
        aria-hidden="true"
        className={`inline-block h-5 w-5 transform rounded-full bg-white shadow-sm duration-200 ${
          checked ? "translate-x-5.5" : "translate-x-0.5"
        }`}
      />
    </button>
  );
}

Four things in there are load-bearing:

  • type="button". Without it, a button inside a form submits it. This is the single most common bug in hand-rolled switches.
  • aria-checked, not aria-pressed. role="switch" expects aria-checked; pairing the role with aria-pressed leaves the state unannounced.
  • The thumb is aria-hidden. It is decoration; the button already carries the state.
  • focus-visible: rather than focus:. Keyboard users get a ring, mouse users do not. The repo-wide reasoning is in Tailwind focus-visible.

Call it with state you own:

const [emails, setEmails] = useState(false);

<Switch checked={emails} onChange={setEmails} label="Product email updates" />;

If you want a visible text label instead of aria-label, wrap both in a <label> and drop the attribute — but note that clicking a <label> does not activate a <button> the way it activates an <input>, so put the click handler on a wrapper or keep the label purely visual with aria-labelledby.

When a switch is the wrong control

This is where the repo is more instructive than the component. Across src/components there are seven two-state toggles in six files, and role="switch" appears in exactly zero of them:

FileControlRole usedWhy not a switch
src/components/molecules/WishlistButton.tsxSave-for-later heartaria-pressedSaving is an action, not a setting
src/components/molecules/EditionPicker.tsxFramework edition chipsaria-pressedOne-of-many, not on/off
src/components/molecules/PreviewModal.tsxDevice-width pillsaria-pressed + role="group"Three options, not two
src/components/molecules/ProductDownloadGroup.tsxEdition selectoraria-pressedOne-of-many
src/components/organisms/TemplatesExplorer.tsxCatalog filteraria-pressedOne-of-many
src/components/organisms/YourProductsGrid.tsxCategory filter (×2)aria-pressedOne-of-many

Only one of the seven — the wishlist heart — is genuinely binary, and it is still not a switch, because it performs an action rather than flipping a preference. The other five are single-select pickers built as pressed-button groups. If you are building the same thing, role="radiogroup" with aria-checked describes one-of-many more precisely than a row of pressed buttons does; a pressed-button group is the pragmatic version and is what ships here.

The one native checkbox on the site is the signup consent box in src/app/signup/page.tsx, and it is a checkbox for a specific reason: it is required and submitted with the form. A switch cannot do that — it has no form value.

// src/app/signup/page.tsx
<input
  id="consent"
  name="consent"
  type="checkbox"
  value="true"
  required
  className="mt-1 h-4 w-4 shrink-0 accent-primary"
/>

The decision rule that falls out: if the state is submitted, use a checkbox; if it applies on click, use a switch; if it does something, use a toggle button.

The defect: a toggle that renders before its state exists

Any toggle backed by localStorage has a window where the server-rendered HTML and the real state disagree. This repo hit it on the wishlist heart, and the fix is a third state rather than a cleverer read:

// src/components/molecules/WishlistButton.tsx
const { has, toggle, hydrated } = useWishlist();
const saved = hydrated && has(entry.slug);

return (
  <button
    type="button"
    aria-pressed={saved}
    aria-label={saved ? `Remove ${entry.name} from saved` : `Save ${entry.name}`}
    disabled={!hydrated}
    onClick={(e) => {
      e.preventDefault();
      e.stopPropagation();
      if (!saved) track("wishlist_added", { slug: entry.slug });
      toggle(entry);
    }}
  >

Two guards, both earning their place. disabled={!hydrated} makes the button inert until storage has been read — a click in that window would toggle against a list that is not loaded yet and wipe an existing save. And preventDefault + stopPropagation exist because the card around it is a stretched link; without them the heart navigates instead of toggling. Every toggle that sits inside a clickable card needs that pair.

Rendering the unsaved state before hydration is deliberate: it matches the statically generated HTML, so there is no mismatch warning. The general pattern is in persisting UI state without a hydration mismatch, and the dark-mode toggle makes the same aria-pressed-over-role="switch" call for the same reason in Tailwind dark mode.

Do you need react-switch?

The popular packages — react-switch, rc-switch, react-toggle — are all small, and they exist mostly to ship the drag interaction and the styling. Against the 30-odd lines above, the trade is:

Hand-rolledA switch package
Bundle cost0A dependency, plus its CSS in some cases
Drag-to-toggleNoUsually yes
StylingYour Tailwind tokensProps, or override its classes
Dark modeA dark: variantWhatever it exposes
ARIA correctnessYours to get rightVaries — verify, do not assume

For a design system already running Tailwind tokens, hand-rolled wins on consistency: the switch picks up bg-primary and the dark variants that everything else uses. For a dashboard with forty settings rows and a drag affordance in the spec, a package is reasonable. Our own bundle reasoning is in React bundle size.

The other meaning: switch statements in render

If you arrived here for the statement, the short version is that switch cannot be used inside JSX — JSX interpolation takes expressions, and switch is a statement. Three options, in ascending order of how much I like them:

// 1. switch before the return
function Status({ state }: { state: "ok" | "warn" | "down" }) {
  switch (state) {
    case "ok":
      return <Badge tone="success">Operational</Badge>;
    case "warn":
      return <Badge tone="warning">Degraded</Badge>;
    default:
      return <Badge tone="error">Down</Badge>;
  }
}

// 2. a lookup object — no branching at all
const BADGES = {
  ok: <Badge tone="success">Operational</Badge>,
  warn: <Badge tone="warning">Degraded</Badge>,
  down: <Badge tone="error">Down</Badge>,
} as const;

// 3. inside JSX, you are limited to ternaries
<span>{state === "ok" ? "Operational" : "Down"}</span>;

The lookup object is what this codebase reaches for — CLUSTER_LABELS in src/data/blog.ts maps the four blog clusters to their display names that way, and a Record<Union, T> makes TypeScript fail the build when a union member has no entry. A switch only wins when the cases need logic rather than a value.

Troubleshooting

SymptomCauseFix
Clicking the switch submits the formMissing type="button"Add it
Screen reader says "button" with no staterole="switch" without aria-checkedPair the role with aria-checked
State announced as "pressed" not "on"aria-pressed on a role="switch"Use one or the other, not both
Switch inside a card navigates instead of togglingThe card is a stretched linkpreventDefault() + stopPropagation()
Toggle flips back on reload, or a save disappearsClicked before localStorage was readGate on a hydrated flag; disable until then
Hydration mismatch warning on first paintReading storage during renderRead in an effect; render the server state first
Focus ring shows on mouse clickfocus: instead of focus-visible:Switch the variant
switch inside JSX is a syntax errorJSX takes expressions, not statementsMove it above the return, or use a lookup

Frequently asked questions

Should a switch have a label beside it? Yes, almost always a visible one. A bare switch gives no clue what it governs, and aria-label serves assistive tech while leaving sighted users guessing. Keep the label text constant and let the switch carry the state — a label that changes between "On" and "Off" is ambiguous about whether it describes the current state or the action.

Is aria-pressed ever acceptable for an on/off control? Yes, when the control performs an action rather than flipping a setting. Bookmark, mute and the wishlist heart here are all toggle buttons; a notification preference is a switch. If a user would describe it with a verb, it is a button.

Do I need to handle Space and Enter myself? Not on a <button> — it handles both natively. You only need a key handler if you built the switch on a <div>, which is the main reason not to.

Can a switch be used in an uncontrolled form? Not directly; it has no form value. Either keep the state in React and submit it yourself, or use a visually-styled <input type="checkbox">, which is what the signup consent box does.

Templates in this post

ASoc Amplify is a social-media-management SaaS site with a core-toolkit services grid and results stats, ASoc Atelier is a designer's portfolio and studio site with a selected-work grid and a booking funnel, and ASoc Axiom is a dark-theme AI-consultancy landing page with a four-service grid and an FAQ. Each ships the focus-visible and dark: conventions the switch above assumes, so a settings toggle dropped into one inherits the tokens rather than fighting them.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial12 min read

React Tabs: Six Requirements, and the Three Ours Was Missing

Our dashboard tabs had the roles and none of the wiring — no aria-controls, no tabpanel, no roving tabindex. The audit, why no scanner caught it, and the fix that shipped with this post.

Read more
Tutorial10 min read

React Toast Notifications: Five Live Regions That Announced Nothing

Zero toast libraries and five aria-live regions here — all five mounted with their first message, so none of them ever announced. The audit, and the atom that fixed it.

Read more
Tutorial9 min read

React Tooltip: When 38 aria-label Attributes Beat a Library

38 aria-label attributes, zero tooltip libraries. What this codebase actually uses to name icon-only buttons, and the one place a real tooltip earns its keep.

Read more