Pill segmented control on Base UI ToggleGroup: a muted track where a lifted card-ground thumb glides to the pressed item, still under reduced motion. Horizontal only; values are arrays, as in Base UI.
"use client"
import { createContext, useContext, useId, useMemo, useState } from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { cn } from "cn"
import { LayoutGroup, motion, useReducedMotion } from "motion/react"
// value/onValueChange/defaultValue take arrays, per Base UI ToggleGroup.
// className is a string here, not Base UI's state callback: the styling is
// the kit's, and state comes through data-pressed and data-disabled.
type Styled<P> = Omit<P, "className"> & {
/**
* Extra classes, as a string only: Base UI's function form is not
* accepted. On `SegmentedControl` they land on the track, and on
* `SegmentedControlItem` on the toggle button.
*/
className?: string
}
// The group keeps the pressed values so each item knows whether it holds the
// thumb; a controlled value wins over the inner state.
const PressedContext = createContext<{
pressed: Set<string>
group: string
multiple: boolean
}>({ pressed: new Set(), group: "", multiple: false })
/**
* A row of toggle buttons on a rounded track. The pressed one sits on a
* raised thumb that slides over from the last one pressed. Put
* `SegmentedControlItem` parts inside.
*
* It is Base UI's `ToggleGroup`, so `value`, `defaultValue` and
* `onValueChange` use arrays of item values. Pressing the active item again
* clears it; to keep one always on, drop empty updates in `onValueChange`:
* skip them when you control `value`, or call `cancel()` on the second
* argument. It is horizontal only, and `className` takes a string.
*/
function SegmentedControl({
className,
value,
defaultValue,
onValueChange,
multiple = false,
...props
}: Styled<Omit<ToggleGroupPrimitive.Props, "orientation">>) {
const group = useId()
const [inner, setInner] = useState<string[]>(
(defaultValue as string[] | undefined) ?? []
)
const current = (value as string[] | undefined) ?? inner
const key = current.join("\u0000")
const context = useMemo(
() => ({
pressed: new Set(key ? key.split("\u0000") : []),
group,
multiple,
}),
[key, group, multiple]
)
return (
<PressedContext.Provider value={context}>
<LayoutGroup id={group}>
<ToggleGroupPrimitive
data-slot="segmented-control"
value={current}
multiple={multiple}
onValueChange={(next, event) => {
// The consumer goes first: event.cancel() keeps the old value.
onValueChange?.(next, event)
if (!event.isCanceled) setInner(next as string[])
}}
className={cn(
// The track: a muted well; in dark a faint lit rim on its top edge.
"inline-flex items-center gap-1 rounded-full bg-muted p-1 ring-1 ring-border",
"dark:bg-foreground/6 dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] dark:ring-foreground/10",
className
)}
{...props}
/>
</LayoutGroup>
</PressedContext.Provider>
)
}
/**
* One option in a `SegmentedControl`, built on Base UI's `Toggle`. Give it a
* string `value`. It is square for one character or an icon and widens to
* fit a word.
*
* The pressed item sits on the thumb, a card-colored pill with a soft shadow
* (a light wash in dark mode) that glides over from the last item pressed;
* reduced motion moves it at once. The other items stay muted and come up
* to full ink on hover. When the control has `multiple`, each pressed item
* gets its own thumb, without the glide.
*/
function SegmentedControlItem({
className,
children,
value,
...props
}: Styled<TogglePrimitive.Props>) {
const { pressed, multiple } = useContext(PressedContext)
const reduce = useReducedMotion()
const on = typeof value === "string" && pressed.has(value)
return (
<TogglePrimitive
data-slot="segmented-control-item"
value={value}
className={cn(
"relative isolate flex h-8 min-w-8 items-center justify-center rounded-full px-2.5 text-sm font-medium text-muted-foreground tabular-nums",
"transition-colors duration-150 ease-out motion-reduce:transition-none",
"hover:text-foreground",
"focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
"data-disabled:pointer-events-none data-disabled:opacity-50",
"data-pressed:text-foreground",
className
)}
{...props}
>
{/* The thumb: one shared element per group, so it glides to whichever
item is pressed instead of popping. With several pressed at once
each keeps its own, still. */}
{on && (
<motion.span
aria-hidden="true"
layoutId={multiple ? undefined : "thumb"}
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 520, damping: 42, mass: 0.8 }
}
className="absolute inset-0 -z-10 rounded-full bg-card shadow-xs dark:bg-foreground/15"
/>
)}
{children}
</TogglePrimitive>
)
}
export { SegmentedControl, SegmentedControlItem }
pnpm dlx shadcn add @01orbital/segmented-controlnpx shadcn add @01orbital/segmented-controlyarn shadcn add @01orbital/segmented-controlbunx --bun shadcn add @01orbital/segmented-controlFree under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.
It lands at components/toggle/segmented-control.tsx, as source you own and edit.
The example also imports @phosphor-icons/react from npm, which the install does not add:
pnpm add @phosphor-icons/reactnpm install @phosphor-icons/reactyarn add @phosphor-icons/reactbun add @phosphor-icons/react"use client"
import { useState } from "react"
import {
TextAlignCenterIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
} from "@phosphor-icons/react"
import {
SegmentedControl,
SegmentedControlItem,
} from "@/components/toggle/segmented-control"
// Year is disabled, so that state shows beside the live ones.
const RANGES = ["Day", "Week", "Month", "Year"] as const
const ALIGNMENTS = [
{ value: "left", label: "Align left", Icon: TextAlignLeftIcon },
{ value: "center", label: "Align center", Icon: TextAlignCenterIcon },
{ value: "right", label: "Align right", Icon: TextAlignRightIcon },
] as const
export default function SegmentedControlDemo() {
const [range, setRange] = useState<string>("Week")
return (
<div className="flex flex-wrap items-center justify-center gap-8">
{/* Range keeps one on: an empty update is ignored. */}
<SegmentedControl
aria-label="Range"
value={[range]}
onValueChange={(next) => {
if (next[0]) setRange(next[0])
}}
>
{RANGES.map((value) => (
<SegmentedControlItem
key={value}
value={value}
disabled={value === "Year"}
>
{value}
</SegmentedControlItem>
))}
</SegmentedControl>
<SegmentedControl aria-label="Text alignment" defaultValue={["left"]}>
{ALIGNMENTS.map(({ value, label, Icon }) => (
<SegmentedControlItem key={value} value={value} aria-label={label}>
<Icon aria-hidden="true" />
</SegmentedControlItem>
))}
</SegmentedControl>
</div>
)
}Segmented Control is built from 2 parts. In the example they nest like this:
SegmentedControl
└─ SegmentedControlItemSegmentedControl: A row of toggle buttons on a rounded track. The pressed one sits on a raised thumb that slides over from the last one pressed. Put SegmentedControlItem parts inside.SegmentedControlItem: One option in a SegmentedControl, built on Base UI's Toggle. Give it a string value. It is square for one character or an icon and widens to fit a word.A row of toggle buttons on a rounded track. The pressed one sits on a raised thumb that slides over from the last one pressed. Put SegmentedControlItem parts inside.
It is Base UI's ToggleGroup, so value, defaultValue and onValueChange use arrays of item values. Pressing the active item again clears it; to keep one always on, drop empty updates in onValueChange: skip them when you control value, or call cancel() on the second argument. It is horizontal only, and className takes a string.
Prop
Type
It also accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.
One option in a SegmentedControl, built on Base UI's Toggle. Give it a string value. It is square for one character or an icon and widens to fit a word.
The pressed item sits on the thumb, a card-colored pill with a soft shadow (a light wash in dark mode) that glides over from the last item pressed; reduced motion moves it at once. The other items stay muted and come up to full ink on hover. When the control has multiple, each pressed item gets its own thumb, without the glide.
Prop
Type
It also accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.
npm: @base-ui/react, cn, motion. The CLI installs them for you.
No other registry pieces.
Install Switcher
Install command with the package manager as a gliding segmented switch inside the pill: pick pnpm, npm, yarn or bun and the command rewrites; the copy button copies the one showing. Narrow, the switch takes the pill's top row. Add the dark class over art.
Stepper
Composable step indicator with list, item, trigger, indicator, title, separator and content parts. Choose numbered steps or progress bars and one of four rail inks; navigation is linear by default.