Switch

An immediate on/off toggle for settings that apply straight away.

Installation

Terminal
pnpm dlx @dowel-ui/cli add switch

npm packages installed: radix-ui.

Accessibility

Exposed as role="switch" and toggled with Space or Enter. Needs a visible label tied with htmlFor/id. Do not rely on colour alone to show state — position and the label carry it too. Icons passed to `icons` are aria-hidden decoration; the press squash only runs without a reduced-motion preference.

Props

Switch

PropTypeDefault
icons

Icons drawn inside the thumb, one per state, that turn and crossfade as it moves. Decorative: the state is announced by the switch role.

{ checked: ReactNode; unchecked: ReactNode }
variant

How the thumb moves. Opt-in, because both change the thumb's shape: - default — slides, as it always has. - squash — also stretches along its travel while held, like a finger pushing it. - morph — squashes, and rests as a rounded square when off and a circle when on. The press stretch only exists when the reader has not asked for reduced motion; the shape change still happens, instantly.

"default" | "squash" | "morph""default"

Every prop of Radix UI’s Switch.Root, plus className.

Quality

8/8 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testeddoes not apply
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focusdoes not apply
  • No fixed widthspasses

Used in

Whole screens assembled from this component. Installing one brings this and everything else it needs with it.

Source

This is exactly what dowel add switch writes into your project, with imports rewritten to your own path alias.

ui/switch.tsx
"use client";

// Motion from SmoothUI AnimatedToggle (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import { Switch as SwitchPrimitive } from "radix-ui";
import type { ComponentPropsWithRef, ReactNode } from "react";

import { focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";

/**
 * An immediate on/off toggle.
 *
 * Use a Switch when the change takes effect at once, and a Checkbox when it is
 * staged until the form is submitted. The difference is real to users: a switch
 * that needs a Save button is a broken promise.
 */
export interface SwitchProps extends ComponentPropsWithRef<typeof SwitchPrimitive.Root> {
  /**
   * How the thumb moves. Opt-in, because both change the thumb's shape:
   *
   * - `default` — slides, as it always has.
   * - `squash` — also stretches along its travel while held, like a finger
   *   pushing it.
   * - `morph` — squashes, and rests as a rounded square when off and a circle
   *   when on.
   *
   * The press stretch only exists when the reader has not asked for reduced
   * motion; the shape change still happens, instantly.
   */
  variant?: "default" | "squash" | "morph";
  /**
   * Icons drawn inside the thumb, one per state, that turn and crossfade as
   * it moves. Decorative: the state is announced by the switch role.
   */
  icons?: { checked: ReactNode; unchecked: ReactNode };
}

const iconLayer = cn(
  "col-start-1 row-start-1 flex items-center justify-center [&_svg]:size-3",
  "transition-[opacity,scale,rotate] duration-[var(--duration-normal)] ease-[var(--ease-overshoot)]",
);

export function Switch({ className, variant = "default", icons, ...props }: SwitchProps) {
  const squash = variant === "squash" || variant === "morph";

  return (
    <SwitchPrimitive.Root
      data-slot="switch"
      data-variant={variant === "default" ? undefined : variant}
      className={cn(
        "peer inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent p-0.5",
        "transition-colors duration-[var(--duration-fast)] ease-[var(--ease-out-quint)]",
        "bg-input data-[state=checked]:bg-primary",
        "disabled:cursor-not-allowed disabled:opacity-55",
        squash && "group/switch",
        focusRing,
        className,
      )}
      {...props}
    >
      <SwitchPrimitive.Thumb
        data-slot="switch-thumb"
        className={cn(
          "pointer-events-none block size-4 rounded-full bg-background shadow-sm ring-0",
          "transition-transform duration-[var(--duration-fast)] ease-[var(--ease-out-quint)]",
          "translate-x-0 data-[state=checked]:translate-x-4",
          // Stretch from the edge it is leaving, so the far edge reaches ahead.
          squash &&
            "origin-left data-[state=checked]:origin-right motion-safe:group-active/switch:scale-x-125",
          variant === "morph" &&
            "rounded-[30%] transition-[transform,translate,scale,border-radius] data-[state=checked]:rounded-[50%]",
          icons && "group/thumb grid place-items-center",
        )}
      >
        {icons ? (
          <>
            <span
              aria-hidden="true"
              data-slot="switch-icon"
              data-icon="unchecked"
              className={cn(
                iconLayer,
                "text-muted-foreground",
                "group-data-[state=checked]/thumb:scale-50 group-data-[state=checked]/thumb:-rotate-90 group-data-[state=checked]/thumb:opacity-0",
              )}
            >
              {icons.unchecked}
            </span>
            <span
              aria-hidden="true"
              data-slot="switch-icon"
              data-icon="checked"
              className={cn(
                iconLayer,
                "text-primary",
                "scale-50 rotate-90 opacity-0",
                "group-data-[state=checked]/thumb:scale-100 group-data-[state=checked]/thumb:rotate-0 group-data-[state=checked]/thumb:opacity-100",
              )}
            >
              {icons.checked}
            </span>
          </>
        ) : null}
      </SwitchPrimitive.Thumb>
    </SwitchPrimitive.Root>
  );
}