a segmented control: one or several options lit, a highlight gliding
npx shadcn add @ag/toggle-groupAlready installed? Preview an update
npx shadcn add @ag/toggle-group --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-14
variant="dots": each option a dot, the chosen one stretched into a short pill (a pager, a story's acts), its words kept for a screen reader, its target 24 px.
also called segmented control, button group
shelf controls
uses field-context, material, setting-context
used by components, thumbs, verdict, log
the demo above, verbatim
"use client"
import {
Bold,
Grid2x2,
Italic,
LayoutGrid,
List,
Underline,
} from "lucide-react"
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 {
ToggleGroup,
ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"
// #region inherited
/** A choice that falls back to the workspace's: the highlight is a ghost on the
* option that applies until one is set here (pressing that very option sets it
* here); × (or ⌥-click, or Delete) clears it back. */
function Inherited() {
const [view, setView] = React.useState<string | undefined>(undefined)
return (
<Setting
label="view"
value={view}
fallback={{ value: "tiles", from: "the workspace" }}
onValueChange={setView}
>
{(s) => (
<ToggleGroup aria-label="view" value={s.value} onValueChange={s.pin}>
<ToggleGroupItem value="grid">grid</ToggleGroupItem>
<ToggleGroupItem value="tiles">tiles</ToggleGroupItem>
<ToggleGroupItem value="list">list</ToggleGroupItem>
</ToggleGroup>
)}
</Setting>
)
}
// #endregion
// #region scale
/** How likely, 0 to 10 (a net promoter score): eleven choices and words at the
* ends. A scale of judgement needs no item of its own. */
function Scale() {
const [score, setScore] = React.useState<string | null>(null)
return (
<div className="flex w-fit flex-col gap-1.5">
<ToggleGroup
size="sm"
aria-label="how likely are you to recommend it, 0 to 10"
value={score}
onValueChange={setScore}
>
{Array.from({ length: 11 }, (_, n) => (
<ToggleGroupItem key={String(n)} value={String(n)} className="w-7">
{n}
</ToggleGroupItem>
))}
</ToggleGroup>
<div
aria-hidden
className="flex justify-between text-[11px] text-current/55"
>
<span>not likely</span>
<span>very likely</span>
</div>
</div>
)
}
// #endregion
// #region bars
function Bars() {
const [view, setView] = React.useState("grid")
const [marks, setMarks] = React.useState<string[]>(["bold"])
return (
<div className="flex flex-col items-start gap-3">
<ToggleGroup aria-label="view" value={view} onValueChange={setView}>
<ToggleGroupItem value="grid">
<LayoutGrid /> grid
</ToggleGroupItem>
<ToggleGroupItem value="tiles">
<Grid2x2 /> tiles
</ToggleGroupItem>
<ToggleGroupItem value="list">
<List /> list
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup
multiple
aria-label="text style"
value={marks}
onValueChange={setMarks}
>
<ToggleGroupItem value="bold" aria-label="bold">
<Bold />
</ToggleGroupItem>
<ToggleGroupItem value="italic" aria-label="italic">
<Italic />
</ToggleGroupItem>
<ToggleGroupItem value="underline" aria-label="underline">
<Underline />
</ToggleGroupItem>
</ToggleGroup>
</div>
)
}
// #endregion
export default function ToggleGroupDemo() {
return (
<div className="space-y-8">
<Sample
name="materials"
title="plain and glass"
description="one of them: the highlight glides to the chosen. several: each pressed one lifts on its own."
with="bars"
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="p-6">
<Bars />
</div>
<MaterialProvider material="glass">
<div className="rounded-xl bg-[url(/lightbox/river.jpg)] bg-cover bg-center p-6 text-white">
<Bars />
</div>
</MaterialProvider>
</div>
</Sample>
<Sample name="sizes" glance title="the input's heights">
<div className="flex flex-wrap items-center gap-3">
{(["sm", "md", "lg"] as const).map((size) => (
<ToggleGroup
key={size}
size={size}
aria-label={`${size} range`}
defaultValue="week"
>
<ToggleGroupItem value="day">day</ToggleGroupItem>
<ToggleGroupItem value="week">week</ToggleGroupItem>
<ToggleGroupItem value="month">month</ToggleGroupItem>
</ToggleGroup>
))}
</div>
</Sample>
<Sample name="inherited" title="following a default" 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/river.jpg)] bg-cover bg-center p-6">
<Inherited />
</div>
</MaterialProvider>
</div>
</Sample>
<Sample
name="scale"
title="a 0 to 10 scale"
description="a net promoter score is a toggle group of eleven, its words at the ends (rating, vote, thumbs and reactions are the judgements that needed items of their own)."
with="scale"
>
<Scale />
</Sample>
<Sample
name="read-only"
title="read-only"
description="shown, never changed here: a press changes nothing, the options keep their Tab stop and the arrows still move between them. never dimmed, which is disabled."
>
<ToggleGroup aria-label="plan" value="team" readOnly>
<ToggleGroupItem value="solo">solo</ToggleGroupItem>
<ToggleGroupItem value="team">team</ToggleGroupItem>
<ToggleGroupItem value="studio">studio</ToggleGroupItem>
</ToggleGroup>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import { Toggle as BaseToggle } from "@base-ui/react/toggle"
import { ToggleGroup as BaseToggleGroup } from "@base-ui/react/toggle-group"
import * as React from "react"
import { cn } from "@/lib/utils"
import { useField } from "@/registry/base-nova/ui/field-context"
import {
type Material,
MaterialProvider,
type Scheme,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import {
glassEdge,
glassLift,
glassVariants,
schemeOf,
} from "@/registry/base-nova/ui/material-glass"
import {
GLASS_GHOST,
useSetting,
} from "@/registry/base-nova/ui/setting-context"
// A few options side by side in one bar, a segmented control (Base UI's toggle group:
// toggle buttons, one Tab stop, the arrow keys moving between them).
//
// one `value` is one option, always one once chosen (pressing the chosen one
// keeps it): a highlight GLIDES to it, so a change reads as one thing
// moving, never two states blinking.
// multiple `value` is a set: each pressed option lit on its own (bold, italic).
//
// Plain: a sunken well, the chosen lifted out of it as the page's own surface.
// `material="glass"`: a bar of glass, the chosen a lit pane inside it. At the input's
// heights, so it sits in a form's row as a field does. `readOnly`: shown, never
// changed (a press changes nothing, the Tab stop and the arrows stay). Inside a
// `Setting` whose value is inherited, the mark on the option that applies is a
// ghost, hollow and dashed (glass: the material's ghost), and pressing that option
// sets it there.
//
// Two looks (`variant`): `segment`, the bar above; `dots`, each option a dot and the
// chosen one stretched into a short pill (a carousel's pager, a story's acts), its
// words kept for a screen reader only, its target still a comfortable 24 px.
//
// <ToggleGroup variant="dots" value={act} onValueChange={seek}>
// {acts.map((a) => <ToggleGroupItem key={a.id} value={a.id}>{a.title}</ToggleGroupItem>)}
// </ToggleGroup>
export type ToggleGroupSize = "sm" | "md" | "lg"
export type ToggleGroupVariant = "segment" | "dots"
/** A dot, and the chosen one stretched: the text's colour at a low alpha, lit and
* wider when chosen, the width easing on the same curve as the segment's glide.
* Glass: the same in the glass's words' colour, the chosen one with the rim's
* light round it. */
const DOT = cn(
"block h-1.5 w-1.5 rounded-full bg-current/30 transition-[width,background-color] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
"group-hover/toggle:bg-current/55 group-data-pressed/toggle:w-4 group-data-pressed/toggle:bg-current/90",
)
const GLASS_DOT =
"group-data-pressed/toggle:shadow-[0_0_6px_color-mix(in_oklab,currentColor_55%,transparent)]"
const BAR: Record<ToggleGroupSize, string> = {
sm: "h-7 text-[12px]",
md: "h-8 text-[13px]",
lg: "h-10 text-sm",
}
const ITEM: Record<ToggleGroupSize, string> = {
sm: "h-6 px-2 [&_svg:not([class*='size-'])]:size-3.5",
md: "h-7 px-2.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 px-3.5 [&_svg:not([class*='size-'])]:size-4",
}
const PLAIN_BAR = "rounded-lg bg-foreground/[0.06]"
/** The glass bar is a pill: its radius half of `BAR`'s height. */
const GLASS_BAR: Record<ToggleGroupSize, string> = {
sm: "[--glass-radius:14px]",
md: "[--glass-radius:16px]",
lg: "[--glass-radius:20px]",
}
/** What lifts the chosen: the page's surface in the well, a lit pane in the glass
* (`glassLift`, the glass's own edge). One element either way: the highlight that
* glides to the one chosen, or each pressed option's own where several can be. */
const LIFT: Record<Material, string> = {
plain:
"rounded-md bg-background shadow-[0_1px_2px_rgb(0_0_0/0.15)] ring-1 ring-foreground/8 scheme-dark:bg-foreground/12",
glass: glassLift,
}
/** The glass lift is a pill: its radius half of `ITEM`'s height. */
const LIFT_RADIUS: Record<ToggleGroupSize, string> = {
sm: "[--glass-radius:12px]",
md: "[--glass-radius:14px]",
lg: "[--glass-radius:18px]",
}
/** Inherited (inside a `Setting`, nothing set here): the mark on the option that
* applies is a ghost, hollow. Plain, a dashed outline where the lift would be;
* glass, the lit pane at the material's ghost, its fill a third as strong. */
const GHOST_LIFT: Record<Material, string> = {
plain: "rounded-md border border-dashed border-current/45",
glass: cn("bg-current/4", glassEdge, GLASS_GHOST),
}
const Group = React.createContext<{
size: ToggleGroupSize
variant: ToggleGroupVariant
material: Material
multiple: boolean
ghost: boolean
} | null>(null)
type One = {
multiple?: false
value?: string | null
defaultValue?: string | null
onValueChange?: (value: string) => void
}
type Many = {
multiple: true
value?: readonly string[]
defaultValue?: readonly string[]
onValueChange?: (value: string[]) => void
}
export type ToggleGroupProps = (One | Many) & {
size?: ToggleGroupSize
/** `segment` (default), a bar; `dots`, a dot per option (`ToggleGroupVariant`). */
variant?: ToggleGroupVariant
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
disabled?: boolean
/** Shown, never changed here: a press changes nothing, the options stay in the
* Tab order and the arrows still move between them. Never dimmed (that is
* `disabled`). A field's read-only and a locked setting's hold it the same way. */
readOnly?: boolean
/** What is being chosen, for assistive tech. */
"aria-label"?: string
/** Or the label that names it (a field's: `aria-labelledby={c.labelId}`). */
"aria-labelledby"?: string
/** What else says something about it (a field's description and error). */
"aria-describedby"?: string
className?: string
children: React.ReactNode
}
const list = (v: string | null | undefined) => (v ? [v] : [])
export function ToggleGroup(props: ToggleGroupProps) {
const {
size = "md",
variant = "segment",
material,
scheme,
disabled,
"aria-label": label,
className,
children,
} = props
const m = useMaterial(material)
const field = useField()
// Inside a setting: the chosen option's mark a ghost while the value is
// inherited, described by where it comes from, held still while locked there.
const setting = useSetting()
const ghost = setting?.inherited ?? false
const readOnly =
(props.readOnly ?? false) ||
(field?.readOnly ?? false) ||
(setting?.readOnly ?? false)
const multiple = props.multiple === true
const bar = React.useRef<HTMLDivElement>(null)
// Over a picture the bar is read by the part of it under the bar.
const [s, here] = useSurfaceScheme(scheme, bar)
// One: the highlight's place, measured off the pressed option itself, so it fits
// labels of any width.
const [at, setAt] = React.useState<{ left: number; width: number } | null>(
null,
)
React.useLayoutEffect(() => {
if (multiple) return
const chosen = bar.current?.querySelector<HTMLElement>(
"[data-slot=toggle-group-item][data-pressed]",
)
const next = chosen
? { left: chosen.offsetLeft, width: chosen.offsetWidth }
: null
if (next?.left !== at?.left || next?.width !== at?.width) setAt(next)
})
// One, uncontrolled: the group holds the choice itself, not Base UI, so a press
// renders the group and the highlight above is measured again (held inside Base UI
// a press changed the pressed option and left the highlight where it was).
const [own, setOwn] = React.useState(
multiple ? null : ((props as One).defaultValue ?? null),
)
const controlled = props.value !== undefined
const value = multiple
? (props as Many).value
: list(controlled ? (props as One).value : own)
const defaultValue = multiple ? (props as Many).defaultValue : undefined
const look = React.useMemo(
() => ({ size, variant, material: m, multiple, ghost }),
[size, variant, m, multiple, ghost],
)
const dots = variant === "dots"
const described =
[
props["aria-describedby"] ?? field?.wiring["aria-describedby"],
setting?.says,
]
.filter(Boolean)
.join(" ") || undefined
return (
<MaterialProvider material={m} scheme={s}>
<Group.Provider value={look}>
<BaseToggleGroup
ref={here}
data-slot="toggle-group"
data-size={size}
data-material={m}
data-inherited={ghost ? "" : undefined}
data-readonly={readOnly ? "" : undefined}
aria-label={label}
// Inside a field: named by its label and described by it, unless named
// here; described by a setting's words too.
aria-labelledby={
props["aria-labelledby"] ?? (label ? undefined : field?.labelId)
}
aria-describedby={described}
aria-invalid={field?.wiring["aria-invalid"]}
multiple={multiple}
disabled={disabled ?? field?.wiring.disabled}
value={value as string[] | undefined}
defaultValue={defaultValue as string[] | undefined}
onValueChange={(next, details) => {
// Read-only: a press changes nothing, the focus stays where it is.
if (readOnly) return details.cancel()
if (multiple) return (props as Many).onValueChange?.(next)
const [chosen] = next
if (chosen === undefined) {
// One, always one: pressing the chosen one keeps it. While its value
// is inherited, that press says "this one, set here": reported.
const kept = controlled ? (props as One).value : own
if (ghost && kept) (props as One).onValueChange?.(kept)
return
}
if (!controlled) setOwn(chosen)
;(props as One).onValueChange?.(chosen)
}}
className={cn(
"relative inline-flex w-fit items-center data-disabled:opacity-50",
schemeOf(s),
dots
? // No bar: the dots stand on what is round them.
"gap-0"
: [
"gap-0.5 p-0.5",
BAR[size],
m === "glass"
? [glassVariants({ shape: "surface" }), GLASS_BAR[size]]
: PLAIN_BAR,
],
className,
)}
>
{!multiple && !dots && (
<span
aria-hidden
data-slot="toggle-group-highlight"
className={cn(
"pointer-events-none absolute top-0.5 bottom-0.5 transition-[left,width,opacity] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
ghost ? GHOST_LIFT[m] : LIFT[m],
m === "glass" && LIFT_RADIUS[size],
at ? "opacity-100" : "opacity-0",
)}
style={at ?? undefined}
/>
)}
{children}
</BaseToggleGroup>
</Group.Provider>
</MaterialProvider>
)
}
export type ToggleGroupItemProps = Omit<
React.ComponentProps<typeof BaseToggle>,
"className"
> & {
/** Its name, when it shows only a glyph. */
"aria-label"?: string
className?: string
}
/** One option of the group around it. */
export function ToggleGroupItem({
className,
children,
...props
}: ToggleGroupItemProps) {
const group = React.useContext(Group)
if (!group) throw new Error("ToggleGroupItem: place it inside a ToggleGroup")
const glass = group.material === "glass"
if (group.variant === "dots")
return (
<BaseToggle
data-slot="toggle-group-item"
className={cn(
"group/toggle grid size-6 shrink-0 cursor-pointer place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/60 data-disabled:cursor-not-allowed data-disabled:opacity-50 in-data-readonly:cursor-default",
className,
)}
{...props}
>
<span aria-hidden className={cn(DOT, glass && GLASS_DOT)} />
{/* Its words, said and never shown. */}
<span className="sr-only">{children}</span>
</BaseToggle>
)
return (
<BaseToggle
data-slot="toggle-group-item"
className={cn(
"group/toggle relative isolate inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 font-medium whitespace-nowrap transition-[color,background-color,opacity,scale] duration-200 select-none data-disabled:cursor-not-allowed data-disabled:opacity-50 in-data-readonly:cursor-default [&_svg]:shrink-0",
ITEM[group.size],
glass
? "rounded-full ease-[cubic-bezier(0.34,1.56,0.64,1)] active:scale-[1.06] motion-reduce:active:scale-100 in-data-readonly:active:scale-100"
: "rounded-md",
// Inherited, the option that applies is lit a step less than one set here.
glass
? cn(
"opacity-60 hover:opacity-90 data-pressed:opacity-100",
group.ghost && "data-pressed:opacity-80",
)
: cn(
"text-foreground/55 hover:text-foreground/85 data-pressed:text-foreground",
group.ghost && "data-pressed:text-foreground/75",
),
className,
)}
{...props}
>
{/* Several: each pressed option lifts on its own, under its words. */}
{group.multiple && (
<span
aria-hidden
data-slot="toggle-group-lift"
className={cn(
"pointer-events-none absolute inset-0 -z-10 opacity-0 transition-opacity duration-200 group-data-pressed/toggle:opacity-100 motion-reduce:transition-none",
group.ghost ? GHOST_LIFT[group.material] : LIFT[group.material],
glass && LIFT_RADIUS[group.size],
)}
/>
)}
{children}
</BaseToggle>
)
}