Animated Toggle Switch in React and Tailwind (Accessible, No Library)

4 min read

TL;DR Use a button with role=switch and aria-checked, style both states with Tailwind's aria-checked variants, and move the thumb with a transform and a slightly overshooting cubic-bezier. Add a hidden input so the value posts with a form, give it a visible label, and turn the motion off under prefers-reduced-motion.

On this page
  1. What a toggle switch needs
  2. The component
  3. Using it in a Next.js form
  4. Controlled, for instant settings
  5. Adding an icon inside the thumb
  6. Common mistakes in generated toggles
  7. When you want more than a switch
  8. FAQ
  9. How do I make an accessible toggle switch in React?
  10. How do I animate a toggle switch with Tailwind only?
  11. How do I send a toggle value with a Next.js server action?
  12. Should I use a checkbox or a switch?

Ask Cursor or v0 for a settings page and you usually get toggles that work with a mouse and little else. The thumb snaps, screen readers announce a checkbox or nothing at all, and the value never reaches the server action because there is no input behind it. I make animated React components at qodesign, and the toggle is the piece I rewrite most often in generated code.

This guide builds one switch component with plain React and Tailwind. No animation library, about 50 lines.

Day/Night Toggle from qodesign: the sun sets into a starry night as the switch flips

That is the Day/Night Toggle, the free piece on qodesign. You can see it live and grab the code. The switch below is the plain version you can drop into any settings screen.

What a toggle switch needs

  • It is announced as a switch with an on or off state. A button with role="switch" and aria-checked does this in every major screen reader.
  • Space and Enter flip it. A native button gives you both for free.
  • It has a visible label, and clicking the label flips it too.
  • The value posts with a form, so a server action can read it.
  • The motion is short, and it stops for people who asked for less motion.
Day / Night Toggle running live. A theme switch with a sun that sets into a starry night.

The component

tsx
"use client";

import { useId, useState } from "react";

type SwitchProps = {
  label: string;
  name?: string;
  defaultChecked?: boolean;
  checked?: boolean;
  onCheckedChange?: (checked: boolean) => void;
  disabled?: boolean;
};

export function Switch({
  label,
  name,
  defaultChecked = false,
  checked,
  onCheckedChange,
  disabled,
}: SwitchProps) {
  const id = useId();
  const [inner, setInner] = useState(defaultChecked);
  const isOn = checked ?? inner;

  function toggle() {
    const next = !isOn;
    if (checked === undefined) setInner(next);
    onCheckedChange?.(next);
  }

  return (
    <div className="flex items-center justify-between gap-4">
      <label htmlFor={id} className="text-sm font-medium text-zinc-200">
        {label}
      </label>
      <button
        id={id}
        type="button"
        role="switch"
        aria-checked={isOn}
        disabled={disabled}
        onClick={toggle}
        className="group relative inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full bg-zinc-700 p-0.5 transition-colors duration-200 aria-checked:bg-emerald-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-400 disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none"
      >
        <span
          aria-hidden="true"
          className="block size-6 rounded-full bg-white shadow-md transition-transform duration-300 ease-[cubic-bezier(.34,1.56,.64,1)] group-aria-checked:translate-x-5 group-active:scale-90 motion-reduce:transition-none"
        />
      </button>
      {name ? <input type="hidden" name={name} value={isOn ? "on" : "off"} /> : null}
    </div>
  );
}

A few details worth knowing:

  • aria-checked: and group-aria-checked: are built into Tailwind. The track and the thumb read the same attribute that screen readers read, so the visual state can never drift from the announced one.
  • The easing cubic-bezier(.34,1.56,.64,1) goes past 1, so the thumb overshoots a few pixels and settles. That small bounce is most of what makes a switch feel physical. Keep it near 300ms; longer starts to feel slow when someone flips five settings in a row.
  • group-active:scale-90 squeezes the thumb while the pointer is down. It is a tiny press state, and it costs nothing.
  • label htmlFor points at the button, so clicking the text flips the switch and the accessible name comes from the label.

Using it in a Next.js form

The hidden input is the part generated code usually forgets. With it, the switch works inside a plain form and a server action:

tsx
import { Switch } from "@/components/switch";

async function saveSettings(formData: FormData) {
  "use server";
  const weeklyDigest = formData.get("weeklyDigest") === "on";
  // save weeklyDigest for the signed-in user
}

export default function SettingsPage() {
  return (
    <form action={saveSettings} className="max-w-sm space-y-4">
      <Switch label="Weekly digest email" name="weeklyDigest" defaultChecked />
      <Switch label="Product updates" name="productUpdates" />
      <button className="rounded-lg bg-white px-4 py-2 text-sm font-medium text-zinc-900">
        Save
      </button>
    </form>
  );
}

I post "on" and "off" instead of skipping the field, so the server can tell "turned off" apart from "field missing".

Controlled, for instant settings

Some settings save the moment they flip. Pass checked and onCheckedChange, update your state at once, and roll back if the request fails:

tsx
const [enabled, setEnabled] = useState(true);

<Switch
  label="Email alerts"
  checked={enabled}
  onCheckedChange={async (next) => {
    setEnabled(next);
    const res = await fetch("/api/alerts", {
      method: "POST",
      body: JSON.stringify({ enabled: next }),
    });
    if (!res.ok) setEnabled(!next);
  }}
/>;

The thumb moves right away, so the switch feels instant even on a slow network.

Adding an icon inside the thumb

A sun and moon, or a check and a cross, helps when color alone carries the state. Put two icons in the thumb and cross-fade them with the same attribute:

tsx
<span className="relative grid size-6 place-items-center rounded-full bg-white shadow-md transition-transform duration-300 group-aria-checked:translate-x-5">
  <svg viewBox="0 0 16 16" className="absolute size-3.5 text-zinc-500 transition-opacity group-aria-checked:opacity-0">
    <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
  </svg>
  <svg viewBox="0 0 16 16" className="absolute size-3.5 text-emerald-600 opacity-0 transition-opacity group-aria-checked:opacity-100">
    <path d="M3 8.5l3 3 7-7" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" />
  </svg>
</span>

That also covers people who can't tell green from grey.

Common mistakes in generated toggles

  1. A div with an onClick. It can't be focused with Tab and never fires on Space. Use a button.
  2. role="checkbox" or no role at all. A switch is on or off and takes effect right away, which is what role="switch" tells people.
  3. Animating left or margin-left. Those trigger layout on every frame. transform: translateX() stays on the compositor.
  4. A toggle with no label, or a label that isn't tied to it. Clicking the text should flip it.
  5. Hard-coding transition with no motion-reduce fallback. The reduced motion guide covers the rest.

When you want more than a switch

A theme switch is the one toggle people notice, so it can carry more personality. The Day/Night Toggle rolls the sun down and brings in the stars, and it ships with a PROMPT.md so Cursor or v0 can wire it into your theme provider. It is free in the free kit along with the open-source pieces on GitHub. If you want the whole page to change with the theme, the circular reveal theme toggle post shows the View Transitions part. For a toggle people drag instead of click, see the draggable pull cord and the Pull-Cord Login that grew out of it.

FAQ

How do I make an accessible toggle switch in React?

Use a native button with role="switch" and aria-checked set to the current state, and tie a visible label to it with htmlFor. The button gives you focus, Space and Enter for free, and screen readers announce it as a switch that is on or off.

How do I animate a toggle switch with Tailwind only?

Put the state in aria-checked and style it with the aria-checked and group-aria-checked variants. Move the thumb with translate-x and a transition-transform, and use an easing curve that overshoots a little, such as cubic-bezier(.34,1.56,.64,1), for a springy feel.

How do I send a toggle value with a Next.js server action?

Render a hidden input next to the switch with the field name and a value of on or off. The form posts it like any other field, and the server action reads it with formData.get.

Should I use a checkbox or a switch?

Use a switch for settings that take effect right away, such as notifications or dark mode. Use a checkbox when the choice only applies after the person submits a form, or when they pick several items from a list.