← ui

switch

ui

on or off at once: a track and a thumb sliding across

npx shadcn add @ag/switch
Already installed? Preview an update
npx shadcn add @ag/switch --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-11
  • New: inside a Setting whose value is inherited, a ghost of the value that applies: the thumb hollow where the value puts it, the track at a fifth of its colour and dashed (glass: the material's ghost). Described by the setting's words; read-only while it is locked (data-readonly, the default cursor).
Updated 2026-10-11 · release notes

also called toggle

shelf controls

uses field-context, material, setting-context

used by components, film-mosaic

the demo above, verbatim

switch.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Setting } from "@/registry/base-nova/ui/setting"
import { Switch } from "@/registry/base-nova/ui/switch"

// #region inherited
/** Settings that fall back to the workspace's: a ghost of the value that applies
 *  until one is set here; × (or ⌥-click, or Delete) clears it back. */
function Inherited() {
  const [sound, setSound] = React.useState<boolean | undefined>(undefined)
  const [captions, setCaptions] = React.useState<boolean | undefined>(true)
  return (
    <div className="flex flex-col gap-2">
      <Setting
        label="sound"
        value={sound}
        fallback={{ value: true, from: "the workspace" }}
        onValueChange={setSound}
      >
        {(s) => (
          <Switch checked={s.value} onCheckedChange={s.pin}>
            sound
          </Switch>
        )}
      </Setting>
      <Setting
        label="captions"
        value={captions}
        fallback={{ value: false, from: "the workspace" }}
        onValueChange={setCaptions}
      >
        {(s) => (
          <Switch checked={s.value} onCheckedChange={s.pin}>
            captions
          </Switch>
        )}
      </Setting>
    </div>
  )
}
// #endregion

// #region settings
function Settings() {
  return (
    <div className="flex flex-col gap-3">
      <Switch defaultChecked>sound</Switch>
      <Switch>captions</Switch>
      <Switch defaultChecked disabled>
        autoplay (set by your workspace)
      </Switch>
    </div>
  )
}
// #endregion

export default function SwitchDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="materials"
        title="plain and glass"
        description="a setting that takes effect the moment it flips; one that waits for a form's submit is a checkbox."
        with="settings"
      >
        <div className="grid gap-4 sm:grid-cols-2">
          <div className="p-6">
            <Settings />
          </div>
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/shore.jpg)] bg-cover bg-center p-6 text-white">
              <Settings />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample name="sizes" glance title="three sizes">
        <div className="flex flex-wrap items-center gap-6">
          <Switch size="sm" defaultChecked aria-label="small" />
          <Switch defaultChecked aria-label="medium" />
          <Switch size="lg" defaultChecked aria-label="large" />
          <Switch aria-invalid aria-label="wrong" />
        </div>
      </Sample>
      <Sample
        name="inherited"
        title="following a default"
        description="sound follows the workspace (a hollow thumb on a dashed track); captions is set here. hover either to read where its value comes from."
        with="inherited"
      >
        <div className="grid gap-4 sm:grid-cols-2">
          <div className="p-6">
            <Inherited />
          </div>
          <MaterialProvider material="glass" scheme="dark">
            <div className="rounded-xl bg-[url(/lightbox/shore.jpg)] bg-cover bg-center p-6">
              <Inherited />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

switch.tsx
"use client"

import { Switch as BaseSwitch } from "@base-ui/react/switch"
import type * as React from "react"
import { cn } from "@/lib/utils"
import { fieldProps, useField } from "@/registry/base-nova/ui/field-context"
import {
  type Material,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import {
  GHOST_EDGE,
  GLASS_GHOST,
  settingProps,
  useSetting,
} from "@/registry/base-nova/ui/setting-context"

// On or off, at once (Base UI's switch: a hidden input a form submits, Space and Enter
// flip it). A setting that takes effect the moment it changes is a switch; a choice
// that waits for a form's submit is a checkbox. Given `children`, the switch and its
// words are one label.
//
// Plain: a track in the field's well tone, the page's primary when on, a round thumb
// that slides across. `material="glass"`: a track of glass, lit and coloured by the
// text when on, the thumb a bright bead riding over it. Inside a `Setting` whose
// value is inherited, a ghost of the value that applies: the thumb hollow where the
// value puts it, the track faint and dashed (glass: the material's ghost).

export type SwitchSize = "sm" | "md" | "lg"

/** The track, its thumb, and how far the thumb travels: the track less its padding
 *  (2 px a side) less the thumb. */
const TRACK: Record<SwitchSize, string> = {
  sm: "h-4 w-7",
  md: "h-5 w-9",
  lg: "h-6 w-11",
}
/** The glass track's radius, half of `TRACK`'s height. */
const GLASS_RADIUS: Record<SwitchSize, string> = {
  sm: "[--glass-radius:8px]",
  md: "[--glass-radius:10px]",
  lg: "[--glass-radius:12px]",
}
const THUMB: Record<SwitchSize, string> = {
  sm: "size-3 data-checked:translate-x-3",
  md: "size-4 data-checked:translate-x-4",
  lg: "size-5 data-checked:translate-x-5",
}
const WORDS: Record<SwitchSize, string> = {
  sm: "gap-1.5 text-[12px]",
  md: "gap-2 text-[13px]",
  lg: "gap-2.5 text-sm",
}

const PLAIN =
  "bg-foreground/15 transition-colors hover:bg-foreground/22 data-checked:bg-primary data-checked:hover:bg-primary/90 aria-invalid:ring-1 aria-invalid:ring-tone-bad/70 data-invalid:ring-1 data-invalid:ring-tone-bad/70"
const GLASS =
  "hover:[--glass-rim:1.4]! data-checked:[--glass-stain:currentColor]! data-checked:[--glass-stain-amount:30%]! data-checked:[--glass-rim:1.8]! aria-invalid:[--glass-stain:var(--tone-bad)]! aria-invalid:[--glass-rim-color:var(--tone-bad)]! aria-invalid:[--glass-rim:2.2]! data-invalid:[--glass-stain:var(--tone-bad)]! data-invalid:[--glass-rim-color:var(--tone-bad)]! data-invalid:[--glass-rim:2.2]!"

const PLAIN_THUMB =
  "bg-background shadow-[0_1px_2px_rgb(0_0_0/0.25)] scheme-dark:data-checked:bg-primary-foreground scheme-dark:data-unchecked:bg-foreground/85"
const GLASS_THUMB =
  "bg-white shadow-[0_1px_3px_rgb(0_0_0/0.35),inset_0_-1px_1px_rgb(0_0_0/0.08)]"

/** Inherited (inside a `Setting`, nothing set here): a ghost of the value that
 *  applies. The track's colour at a fifth and a dashed edge plain, the material's
 *  ghost in glass; the thumb hollow, in the colour it would be filled with, where
 *  the value puts it. */
const PLAIN_GHOST = cn(
  "bg-transparent hover:bg-foreground/6 data-checked:bg-primary/20 data-checked:hover:bg-primary/26",
  GHOST_EDGE,
)
const GHOST_THUMB =
  "bg-transparent ring-[1.5px] ring-foreground/45 ring-inset data-checked:ring-primary"
const GLASS_GHOST_THUMB =
  "bg-transparent ring-[1.5px] ring-current/75 ring-inset"

export type SwitchProps = Omit<
  React.ComponentProps<typeof BaseSwitch.Root>,
  "className" | "children"
> & {
  size?: SwitchSize
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
  /** Its words: the switch and these are one label. */
  children?: React.ReactNode
  className?: string
}

export function Switch({
  size = "md",
  material,
  scheme,
  children,
  className,
  ref,
  ...props
}: SwitchProps) {
  const m = useMaterial(material)
  // Over a picture it is read by the part of it under the track, the surface; its
  // words beside it wear the same.
  const [s, here] = useSurfaceScheme<HTMLElement>(scheme, ref)
  const field = useField()
  // Inside a setting: the value that applies drawn as a ghost while it is
  // inherited, described by where it comes from, read-only while locked there.
  const setting = useSetting()
  const ghost = setting?.inherited ?? false
  const glass = m === "glass"
  const wired = fieldProps(field, { named: !children })
  const track = (
    <BaseSwitch.Root
      ref={here}
      data-slot="switch"
      data-size={size}
      data-material={m}
      data-inherited={ghost ? "" : undefined}
      className={cn(
        "relative inline-flex shrink-0 cursor-pointer items-center rounded-full p-0.5 data-disabled:cursor-not-allowed data-readonly:cursor-default",
        TRACK[size],
        // With its words the label wears the scheme, the track inside it with them.
        !children && schemeOf(s),
        glass
          ? [
              glassVariants({ shape: "surface" }),
              GLASS,
              GLASS_RADIUS[size],
              ghost && GLASS_GHOST,
            ]
          : [PLAIN, ghost && PLAIN_GHOST],
        !children && ["data-disabled:opacity-50", className],
      )}
      // Inside a field: its wiring and read-only, named by the field's label unless
      // its own words name it; what is passed here wins.
      {...wired}
      {...settingProps(setting, wired["aria-describedby"])}
      {...props}
    >
      <BaseSwitch.Thumb
        data-slot="switch-thumb"
        className={cn(
          "pointer-events-none block rounded-full transition-[translate,background-color,box-shadow] duration-200 ease-[cubic-bezier(0.34,1.36,0.64,1)] motion-reduce:transition-none",
          THUMB[size],
          ghost
            ? glass
              ? GLASS_GHOST_THUMB
              : GHOST_THUMB
            : glass
              ? GLASS_THUMB
              : PLAIN_THUMB,
        )}
      />
    </BaseSwitch.Root>
  )
  if (!children) return track
  return (
    // biome-ignore lint/a11y/noLabelWithoutControl: the switch inside is its control
    <label
      data-slot="switch-label"
      className={cn(
        "inline-flex cursor-pointer items-center select-none has-[[data-disabled]]:cursor-not-allowed has-[[data-disabled]]:opacity-50",
        schemeOf(s),
        WORDS[size],
        m === "glass" ? "text-current/90" : "text-foreground/85",
        className,
      )}
    >
      {track}
      {children}
    </label>
  )
}