docs / core / switch

Switch

a labelled on/off control that takes effect immediately. flipping it changes something now, with no save button after.

intensity · recedesrsc · client

provenance

authored in usva

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

settings rows and preference panels, one per line with a labeldescription carries the consequence when the label alone is vaguenot for choices a form submits later. that is Checkboxnever unlabelled. the thumb alone says nothing about what it toggles

a11y

role="switch" named by its label · toggles with space · disabled sets aria-disabled · focus ring on the trackjest-axe

dependencies

@base-ui/react (Switch + Field) · class-variance-authority
live demo · try it out

Enable push notifications for updates.

customize
labelnames the control
descriptionhelper text under the switch
sizesm for dense rows
defaultCheckedinitial on state
disableddims and blocks the pointer
usagecopy
import { Switch } from "usva/primitives/switch";

<Switch
  label="Notifications"
  description="Enable push notifications for updates."
/>

props

proptypedefaultnotes
checkedbooleancontrolled checked state. pair with onCheckedChange.
defaultCheckedbooleanfalseinitial state when uncontrolled.
onCheckedChange(checked: boolean) => voidfires with the next state on every toggle.
disabledbooleanfalsedims to 50% and blocks the pointer. disables the whole field.
labelReactNodethe field label, wired to the control by id.
descriptionReactNodehelper text under the control.
size"sm" | "md""md"sm for dense rows. both keep a full-height hit area.

get it

npx shadcn add https://usva.build/r/switch.jsoncopy
source · components/ui/switch.tsxexactly what this command copiescopy
"use client";
import { Field } from "@base-ui/react/field";
import { Switch as Base } from "@base-ui/react/switch";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";

const trackVariants = cva(
  "relative inline-flex shrink-0 items-center rounded-full border border-border bg-surface-2 outline-none transition-control duration-base ease-soft before:absolute before:content-[''] data-[unchecked]:hover:border-border-strong data-[checked]:border-accent data-[checked]:bg-accent data-[checked]:bg-gradient-accent data-[checked]:glow-ring focus-visible:border-transparent focus-visible:ring-focus aria-invalid:border-danger data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
  {
    variants: {
      size: {
        sm: "h-5 w-9 before:-inset-y-3 before:-inset-x-1",
        md: "h-6 w-11 before:-inset-y-2.5 before:inset-x-0",
      },
    },
    defaultVariants: { size: "md" },
  },
);

const thumbVariants = cva(
  "block rounded-full bg-ink shadow-raised transition-layout duration-base ease-spring motion-reduce:transition-none motion-reduce:transform-none data-[checked]:bg-on-accent",
  {
    variants: {
      size: {
        sm: "h-4 w-4 translate-x-0.5 data-[checked]:translate-x-4",
        md: "h-5 w-5 translate-x-0.5 data-[checked]:translate-x-5",
      },
    },
    defaultVariants: { size: "md" },
  },
);

// Indent the description to line up under the label: track width + the row gap.
const descIndent: Record<NonNullable<SwitchProps["size"]>, string> = {
  sm: "pl-12",
  md: "pl-14",
};

export interface SwitchProps
  extends Omit<
      React.ComponentPropsWithoutRef<typeof Base.Root>,
      "render" | "className"
    >,
    VariantProps<typeof trackVariants> {
  className?: string;
  label?: React.ReactNode;
  description?: React.ReactNode;
}

export const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
  ({ className, size, label, description, id, disabled, ...props }, ref) => {
    const generatedId = React.useId();
    const switchId = id ?? generatedId;

    return (
      <Field.Root className="flex flex-col gap-1" disabled={disabled}>
        <div className="flex items-center gap-3">
          <Base.Root
            ref={ref}
            id={switchId}
            disabled={disabled}
            className={cn(trackVariants({ size }), className)}
            {...props}
          >
            <Base.Thumb className={thumbVariants({ size })} />
          </Base.Root>
          {label ? (
            <Field.Label
              htmlFor={switchId}
              className="text-sm text-ink select-none data-[disabled]:opacity-50"
            >
              {label}
            </Field.Label>
          ) : null}
        </div>
        {description ? (
          <Field.Description
            className={cn("text-xs text-muted", descIndent[size ?? "md"])}
          >
            {description}
          </Field.Description>
        ) : null}
      </Field.Root>
    );
  },
);
Switch.displayName = "Switch";