FewTone UI
ComponentsDocs

Getting started

  • Introduction
  • Installation
  • Theming

Components4

  • ButtonNew
  • InputNew
  • SelectNew
  • SwitchNew
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Switch

SwitchNew

pnpm dlx shadcn@latest add …/r/switch.json

An accessible React switch for on/off settings, with a spring-animated thumb, built on Radix Switch.

import { Switch } from "@/components/ui/switch"

export function AirplaneMode() {
  return (
    <>
      <Switch id="airplane-mode" />
      <label htmlFor="airplane-mode">Airplane mode</label>
    </>
  )
}
default.tsx
import { Switch } from "@/components/ui/switch";
import { useId } from "react";

export default function SwitchDefault() {
  const id = useId();
  return (
    <div className="flex items-center gap-3">
      <Switch id={id} />
      <label htmlFor={id} className="text-sm font-medium">
        Airplane mode
      </label>
    </div>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add class-variance-authority motion radix-ui
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-popover: 1400;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --scale-press: 0.97;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      @keyframes popover-in {
        from {
          opacity: 0;
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          transform: scale(1);
        }
        to {
          opacity: 0;
          transform: scale(0.96);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
  4. 4

    Copy the source into your project.

    components/ui/switch.tsx
    "use client";
    
    import { spring } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { cva, type VariantProps } from "class-variance-authority";
    import { motion, useReducedMotion } from "motion/react";
    import { Switch as SwitchPrimitive } from "radix-ui";
    import { type ComponentProps, useState } from "react";
    
    const switchVariants = cva(
      [
        "inline-flex shrink-0 cursor-pointer items-center rounded-full p-0.5 shadow-xs",
        // The thumb sits at the start or the end; motion's layout animation glides it between the two
        "justify-start bg-input data-[state=checked]:justify-end data-[state=checked]:bg-primary",
        "transition-colors duration-(--duration-fast) ease-out",
        "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)",
        "disabled:cursor-not-allowed disabled:opacity-(--opacity-disabled)",
      ],
      {
        variants: {
          size: {
            sm: "h-4 w-7",
            md: "h-5 w-9",
          },
        },
        defaultVariants: { size: "md" },
      },
    );
    
    const thumbSizes = {
      sm: { rest: "size-3", pressed: "h-3 w-4" },
      md: { rest: "size-4", pressed: "h-4 w-5" },
    } as const;
    
    export type SwitchProps = ComponentProps<typeof SwitchPrimitive.Root> & VariantProps<typeof switchVariants>;
    
    /**
     * An on/off switch for settings that apply right away, built on Radix Switch (role="switch").
     * Give it a visible label (`<label htmlFor>`).
     */
    function Switch({
      className,
      size,
      checked,
      defaultChecked,
      onCheckedChange,
      onPointerDown,
      onPointerUp,
      onPointerLeave,
      ...props
    }: SwitchProps) {
      // Mirror the state so the thumb animation knows when it changed, controlled or not
      const [uncontrolled, setUncontrolled] = useState(defaultChecked ?? false);
      const on = checked ?? uncontrolled;
      const [pressed, setPressed] = useState(false);
      const reduceMotion = useReducedMotion();
      const thumb = thumbSizes[size ?? "md"];
    
      return (
        <SwitchPrimitive.Root
          data-slot="switch"
          checked={checked}
          defaultChecked={defaultChecked}
          onCheckedChange={(value) => {
            setUncontrolled(value);
            onCheckedChange?.(value);
          }}
          onPointerDown={(event) => {
            setPressed(true);
            onPointerDown?.(event);
          }}
          onPointerUp={(event) => {
            setPressed(false);
            onPointerUp?.(event);
          }}
          onPointerLeave={(event) => {
            setPressed(false);
            onPointerLeave?.(event);
          }}
          className={cn(switchVariants({ size }), className)}
          {...props}
        >
          <SwitchPrimitive.Thumb asChild>
            <motion.span
              data-slot="switch-thumb"
              layout
              layoutDependency={`${on}-${pressed}`}
              transition={reduceMotion ? { duration: 0 } : spring.snappy}
              // A numeric radius lets motion keep the thumb round while it stretches
              style={{ borderRadius: 9999 }}
              className={cn(
                "pointer-events-none block bg-background shadow-sm data-[state=checked]:bg-primary-foreground",
                pressed ? thumb.pressed : thumb.rest,
              )}
            />
          </SwitchPrimitive.Thumb>
        </SwitchPrimitive.Root>
      );
    }
    
    export { Switch, switchVariants };
  5. 5

    Update the import paths to match your project.

Examples

Settings list

Each switch has a label and a hint linked with aria-describedby, grouped in a fieldset.

Notifications

News and product changes, once a month.

New sign-ins and password changes.

Short guides for features you haven't used.

settings.tsx
import { Switch } from "@/components/ui/switch";
import { useId } from "react";

const settings = [
  { key: "email", label: "Email updates", hint: "News and product changes, once a month.", on: true },
  { key: "security", label: "Security alerts", hint: "New sign-ins and password changes.", on: true },
  { key: "tips", label: "Tips", hint: "Short guides for features you haven't used.", on: false },
];

export default function SwitchSettings() {
  const id = useId();
  return (
    <fieldset className="grid w-full max-w-sm gap-4">
      <legend className="mb-2 text-sm font-medium">Notifications</legend>
      {settings.map((setting) => (
        <div key={setting.key} className="flex items-start justify-between gap-4">
          <div className="grid gap-0.5">
            <label htmlFor={`${id}-${setting.key}`} className="text-sm font-medium">
              {setting.label}
            </label>
            <p id={`${id}-${setting.key}-hint`} className="text-sm text-muted-foreground">
              {setting.hint}
            </p>
          </div>
          <Switch
            id={`${id}-${setting.key}`}
            defaultChecked={setting.on}
            aria-describedby={`${id}-${setting.key}-hint`}
          />
        </div>
      ))}
    </fieldset>
  );
}

Sizes

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

export default function SwitchSizes() {
  return (
    <div className="flex items-center gap-6">
      <Switch size="sm" aria-label="Small switch" defaultChecked />
      <Switch aria-label="Medium switch" defaultChecked />
    </div>
  );
}

Disabled

disabled.tsx
import { Switch } from "@/components/ui/switch";
import { useId } from "react";

export default function SwitchDisabled() {
  const id = useId();
  return (
    <div className="flex items-center gap-3">
      <Switch id={id} disabled defaultChecked />
      <label htmlFor={id} className="text-sm font-medium text-muted-foreground">
        Sync is managed by your organisation
      </label>
    </div>
  );
}

API reference

Also accepts every Radix Switch prop. The thumb glides on a spring and stretches while pressed.

PropTypeDefaultDescription
checked?boolean–The on state, when you control it.
defaultChecked?booleanfalseThe initial state.
onCheckedChange?(checked: boolean) => void–Called when the user turns it on or off.
size?"sm" | "md""md"Track and thumb size.
disabled?booleanfalsePrevents interaction.
name?string–Submits the value with a form, through a hidden checkbox.

Related components

  • InputNew

    An accessible React text input with three sizes and clear invalid, disabled and file states, built on a native input.

  • SelectNew

    An accessible React select with a gliding highlight, groups, labels and a scrollable list, built on Radix Select.

  • ButtonNew

    An accessible React button with six variants, three sizes, icon support and a spring press, built on a native button.

Last updated 13 Oct 2026Edit on GitHub