Switch

A control that allows the user to toggle between checked and not checked.

"use client"

import { cn } from "cn"
import {
  composeRenderProps,
  Switch as SwitchPrimitive,
  type SwitchProps as SwitchPrimitiveProps,
} from "@animate-ui/react/primitives/react-aria"
import { Label } from "@/components/ui/label"

function Switch({
  className,
  size = "default",
  children,
  ...props
}: SwitchPrimitiveProps & {
  size?: "sm" | "default"
}) {
  return (
    <SwitchPrimitive
      data-slot="switch"
      data-size={size}
      className={cn(
        "data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent focus-visible:ring-3 aria-invalid:ring-3 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:border-transparent data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] data-selected:bg-primary not-data-selected:bg-input data-focus-visible:border-ring data-focus-visible:ring-ring/50 dark:not-data-selected:bg-input/80 data-focus-visible:ring-3 data-invalid:ring-destructive/20 dark:data-invalid:ring-destructive/40 data-invalid:border-destructive dark:data-invalid:border-destructive/50 data-invalid:ring-3 peer group/switch relative inline-flex items-center outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50",
        className
      )}
      {...props}
    >
      {composeRenderProps(children, (children, { isSelected }) => (
        <>
          <span
            data-slot="switch-thumb"
            data-selected={isSelected || undefined}
            className="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:not-data-selected:bg-foreground dark:data-selected:bg-primary-foreground group-data-[size=default]/switch:data-selected:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-selected:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:not-data-selected:translate-x-0 group-data-[size=sm]/switch:not-data-selected:translate-x-0 pointer-events-none block ring-0"
          />
          {children}
        </>
      ))}
    </SwitchPrimitive>
  )
}

export { Switch }

export default function SwitchDemo() {
  return (
    <div className="flex items-center space-x-2">
      <Switch id="airplane-mode" />
      <Label htmlFor="airplane-mode">Airplane Mode</Label>
    </div>
  )
}

Installation

Install the packages

npm install @animate-ui/react motion
pnpm add @animate-ui/react motion
yarn add @animate-ui/react motion
bun add @animate-ui/react motion

Add the shadcn/ui component

npx shadcn@latest add switch
pnpm dlx shadcn@latest add switch
yarn dlx shadcn@latest add switch
bun x shadcn@latest add switch

Animate it

The CLI finds your shadcn/ui components through components.json, swaps their primitive imports for Animate UI's and removes the CSS animation classes Motion replaces.

npx @animate-ui/cli@latest animate switch
pnpm dlx @animate-ui/cli@latest animate switch
yarn dlx @animate-ui/cli@latest animate switch
bun x @animate-ui/cli@latest animate switch

Usage

Import from Animate UI instead of react-aria-components: it re-exports all of React Aria, with the animated controls below. Everything else stays the same.

import { Switch } from '@animate-ui/react/primitives/react-aria';

export function AirplaneMode() {
  return (
    <Switch>
      {({ isSelected }) => (
        <>
          <span className="track">
            <span
              data-slot="switch-thumb"
              data-selected={isSelected || undefined}
            />
          </span>
          Airplane mode
        </>
      )}
    </Switch>
  );
}

React Aria lets you render the thumb yourself. Animate UI glides the element marked data-slot="switch-thumb", which is what shadcn/ui renders: add the attribute to your own thumb.

Animation

The motion of Animate UI v1's Radix UI and Headless UI switches (v1 had no React Aria switch):

  • Thumb, pressed: stretches toward the middle of the track, its other edge in place. Its width grows by 3/16 (a 16px thumb becomes 19px, v1's pressedWidth) on a spring (stiffness 300, damping 25). It's the thumb's real width, so its round ends stay round. Pressed means React Aria's isPressed: the pointer, or Space and Enter on the keyboard.
  • Thumb, on a change: springs to its new side while its width springs back, on the same spring (v1's layout animation). The thumb's translate classes still decide where it rests (data-selected:translate-x-…): Animate UI puts it back where it was with a transform and springs it to its new place.
  • Switch doesn't move. Its colours are the component's CSS, and v1's track changed colour at once: shadcn/ui's transition-all on the track would fade it, so the CLI removes it.
  • Toggling again mid-glide turns back from where the thumb is, without a jump.
  • Under reduced motion, the thumb jumps to its side, without the stretch.
  • shadcn/ui's transition-transform on the thumb would tween its translate; the CLI removes it too (Animate UI also skips it while measuring, so a leftover class doesn't break the glide).
  • className, style and render props keep working.

API Reference

Switch takes exactly the props of React Aria's Switch, plus the props below.

PropTypeDefault
transition?
Transition{ type: 'spring', stiffness: 300, damping: 25 }

AnimateUIProvider's tempo scales the spring with everything else.

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/18/2026

On this page