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

> 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.

- URL: https://qodesign.dev/blog/animated-toggle-switch-react-tailwind
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-22
- Updated: 2026-10-22
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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](/previews/day-night-toggle.webp)

That is the [Day/Night Toggle](/components/day-night-toggle), the free piece on qodesign. You can [see it live and grab the code](/components/day-night-toggle). 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.

## 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](/blog/prefers-reduced-motion-react) 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](/components/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](/free) 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](/blog/theme-toggle-circular-reveal-view-transitions-nextjs) shows the View Transitions part. For a toggle people drag instead of click, see the [draggable pull cord](/blog/draggable-pull-cord-toggle-react-tailwind) and the [Pull-Cord Login](/components/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.
