on or off at once: a track and a thumb sliding across
npx shadcn add @ag/switchAlready installed? Preview an update
npx shadcn add @ag/switch --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-11
- New: inside a
Settingwhose 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).
also called toggle
shelf controls
uses field-context, material, setting-context
used by components, film-mosaic
the demo above, verbatim
"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
"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>
)
}