A macOS segmented control: mutually exclusive options with a selected pill that springs between segments and hairline dividers between unselected ones. A Radix radio group underneath.
Calendar
Sep 27 – Oct 3
Appearance
Follow the system or pick one.
View
How files are listed.
Sidebar icon size
"use client"
import * as React from "react"
import { Columns3, LayoutGrid, List, Monitor, Moon, Sun } from "lucide-react"
import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control"
const ranges = { day: "Tuesday, Sep 29", week: "Sep 27 – Oct 3", month: "September 2026", year: "2026" } as const
export default function SegmentedControlDemo() {
const [range, setRange] = React.useState<keyof typeof ranges>("week")
return (
<div className="w-full max-w-[460px] overflow-hidden rounded-xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-16px_rgb(0_0_0/0.2)]">
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
<div className="min-w-0">
<p className="text-[13px] font-semibold">Calendar</p>
<p className="text-xs text-muted-foreground tabular-nums">{ranges[range]}</p>
</div>
<SegmentedControl aria-label="Range" value={range} onValueChange={(v) => setRange(v as keyof typeof ranges)}>
<SegmentedControlItem value="day">Day</SegmentedControlItem>
<SegmentedControlItem value="week">Week</SegmentedControlItem>
<SegmentedControlItem value="month">Month</SegmentedControlItem>
<SegmentedControlItem value="year">Year</SegmentedControlItem>
</SegmentedControl>
</div>
<div className="divide-y">
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div>
<p className="text-[13px] font-medium">Appearance</p>
<p className="text-xs text-muted-foreground">Follow the system or pick one.</p>
</div>
<SegmentedControl aria-label="Appearance" defaultValue="auto">
<SegmentedControlItem value="auto">
<Monitor aria-hidden="true" />
Auto
</SegmentedControlItem>
<SegmentedControlItem value="light">
<Sun aria-hidden="true" />
Light
</SegmentedControlItem>
<SegmentedControlItem value="dark">
<Moon aria-hidden="true" />
Dark
</SegmentedControlItem>
</SegmentedControl>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div>
<p className="text-[13px] font-medium">View</p>
<p className="text-xs text-muted-foreground">How files are listed.</p>
</div>
<SegmentedControl aria-label="View" defaultValue="grid" size="sm">
<SegmentedControlItem value="list" aria-label="List" className="px-2.5">
<List aria-hidden="true" />
</SegmentedControlItem>
<SegmentedControlItem value="grid" aria-label="Icons" className="px-2.5">
<LayoutGrid aria-hidden="true" />
</SegmentedControlItem>
<SegmentedControlItem value="columns" aria-label="Columns" className="px-2.5">
<Columns3 aria-hidden="true" />
</SegmentedControlItem>
</SegmentedControl>
</div>
<div className="px-4 py-3">
<p className="mb-2 text-[13px] font-medium">Sidebar icon size</p>
<SegmentedControl aria-label="Sidebar icon size" defaultValue="medium" fullWidth>
<SegmentedControlItem value="small">Small</SegmentedControlItem>
<SegmentedControlItem value="medium">Medium</SegmentedControlItem>
<SegmentedControlItem value="large">Large</SegmentedControlItem>
</SegmentedControl>
</div>
</div>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/segmented-controlInstall the dependencies.
$ pnpm add motion@^12 radix-ui class-variance-authorityAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/directionCopy the source into your project.
components/ballmac/segmented-control.tsx// Ballmac UI: Segmented Control. https://ui.ballmac.com/components/segmented-control "use client" import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { MotionConfig, motion } from "motion/react" import { RadioGroup as RadioGroupPrimitive } from "radix-ui" import { useDirection } from "@/lib/ballmac/direction" import { cn } from "@/lib/utils" const segmentedControlVariants = cva( "relative inline-flex w-fit max-w-full items-center rounded-[9px] bg-foreground/[0.07] p-0.5 shadow-[inset_0_0_0_0.5px_rgb(0_0_0/0.06)] dark:bg-white/[0.08] dark:shadow-[inset_0_0_0_0.5px_rgb(255_255_255/0.06)] data-[disabled]:opacity-50", { variants: { size: { sm: "h-7 text-xs", default: "h-8 text-[13px]", lg: "h-9 text-sm", }, }, defaultVariants: { size: "default" }, } ) type SegmentedControlContextValue = { value: string; layoutId: string; values: string[] } const SegmentedControlContext = React.createContext<SegmentedControlContextValue | null>(null) type SegmentedControlProps = Omit<React.ComponentProps<typeof RadioGroupPrimitive.Root>, "value" | "defaultValue" | "onValueChange" | "orientation"> & VariantProps<typeof segmentedControlVariants> & { /** Selected value (controlled). */ value?: string /** Initially selected value when uncontrolled. Defaults to the first item. */ defaultValue?: string /** Called with the newly selected value. */ onValueChange?: (value: string) => void /** Stretch to the container width with equal segments. */ fullWidth?: boolean } /** * A macOS segmented control: a row of mutually exclusive options with a selected pill that slides between them. * It is a radio group (Radix): Tab focuses the selected segment, arrow keys move and select. */ function SegmentedControl({ value: valueProp, defaultValue, onValueChange, size = "default", fullWidth = false, className, children, ...props }: SegmentedControlProps) { const values = React.Children.toArray(children).flatMap((child) => React.isValidElement<{ value?: unknown }>(child) && typeof child.props.value === "string" ? [child.props.value] : [] ) const [uncontrolled, setUncontrolled] = React.useState(defaultValue ?? values[0] ?? "") const value = valueProp ?? uncontrolled const layoutId = `segmented-pill-${React.useId()}` const dir = useDirection(props.dir) return ( <SegmentedControlContext.Provider value={{ value, layoutId, values }}> <MotionConfig reducedMotion="user"> <RadioGroupPrimitive.Root data-slot="segmented-control" orientation="horizontal" dir={dir} value={value} onValueChange={(next) => { if (valueProp === undefined) setUncontrolled(next) onValueChange?.(next) }} className={cn(segmentedControlVariants({ size }), fullWidth && "flex w-full [&>[data-slot=segmented-control-item]]:flex-1", className)} {...props} > {children} </RadioGroupPrimitive.Root> </MotionConfig> </SegmentedControlContext.Provider> ) } type SegmentedControlItemProps = React.ComponentProps<typeof RadioGroupPrimitive.Item> /** One segment. Icon-only segments need an aria-label. */ function SegmentedControlItem({ value, className, children, ...props }: SegmentedControlItemProps) { const context = React.useContext(SegmentedControlContext) if (!context) throw new Error("SegmentedControlItem must be used inside <SegmentedControl>.") const selected = context.value === value const index = context.values.indexOf(value) // A hairline divider sits between two unselected neighbors, as on macOS. const showDivider = index > 0 && !selected && context.values[index - 1] !== context.value return ( <RadioGroupPrimitive.Item value={value} data-slot="segmented-control-item" className={cn( "group/segment relative flex h-full flex-auto cursor-default items-center justify-center gap-1.5 rounded-[7px] px-3 font-medium whitespace-nowrap text-foreground/70 outline-none transition-colors duration-150 select-none", "hover:text-foreground data-[state=checked]:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50", "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} > <span aria-hidden="true" data-slot="segmented-control-divider" className={cn( "pointer-events-none absolute top-1/2 -start-px h-[45%] w-px -translate-y-1/2 bg-foreground/15 transition-opacity duration-150", showDivider ? "opacity-100" : "opacity-0" )} /> {selected ? ( <motion.span layoutId={context.layoutId} aria-hidden="true" data-slot="segmented-control-indicator" className="absolute inset-0 rounded-[7px] bg-background shadow-[0_0_0_0.5px_rgb(0_0_0/0.08),0_1px_2px_0_rgb(0_0_0/0.14),0_2px_6px_-2px_rgb(0_0_0/0.1)] dark:bg-white/[0.2] dark:shadow-[0_0_0_0.5px_rgb(0_0_0/0.4),inset_0_0.5px_0_rgb(255_255_255/0.12),0_1px_2px_rgb(0_0_0/0.3)]" transition={{ type: "spring", stiffness: 500, damping: 38, mass: 0.8 }} /> ) : null} <span className="relative z-10 flex items-center gap-1.5">{children}</span> </RadioGroupPrimitive.Item> ) } export { SegmentedControl, SegmentedControlItem, segmentedControlVariants, type SegmentedControlItemProps, type SegmentedControlProps }Update the import paths to match your project setup.
Usage
import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control"The full example is in the Code tab above.
API reference
| Prop | Type | Default |
|---|---|---|
valueSelected value (controlled). | string | — |
defaultValueInitially selected value when uncontrolled. Defaults to the first item. | string | — |
onValueChangeCalled with the newly selected value. | (value: string) => void | — |
fullWidthStretch to the container width with equal segments. | boolean | false |
size | "sm" | "default" | "lg" | "default" |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Focuses the selected segment (one tab stop) |
| ArrowLeft / ArrowRight | Moves to and selects the previous or next segment, wrapping |
| Space | Selects the focused segment |
Use with AI
<SegmentedControl aria-label value|defaultValue onValueChange size><SegmentedControlItem value>Label</SegmentedControlItem>…</SegmentedControl>. Exactly one segment is always selected. Icon-only items need aria-label; fullWidth stretches segments equally. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Segmented Control (@ballmac/segmented-control) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Switching a view mode or range (Day / Week / Month, List / Grid)
- A short, always-visible choice in settings (Light / Dark / Auto)
- Filtering one list by a few exclusive categories
Not for
- Switching between panels of content with their own semantics (use tabs)
- More than about five options or long labels (use select)
- Options that can be combined (use checkboxes or a toggle group)
Registry JSON: https://ui.ballmac.com/r/segmented-control.json
Credits
Free to use in personal and commercial projects.
- Registry
- @ballmac/directionshadcn/utils
Pairs well with
Mac Window
macOS window chrome: traffic lights whose glyphs appear on hover, a centered title or unified toolbar, an optional translucent sidebar with vibrancy, and active and inactive appearances.
Control Center
The macOS Control Center panel in frosted glass: toggle tiles with round badges, grouped Wi-Fi, Bluetooth and AirDrop rows, pill sliders with the icon in the track, and a Now Playing tile. Real toggle buttons and Radix sliders.
Desktop Icons
A macOS desktop of icons: box-select with the pointer, drag to move with snapping to grid cells, double-click or Return to open, arrow keys to move focus and Alt plus arrows to move an icon.
Dock
A macOS-style dock whose icons grow with spring physics as the pointer approaches and push their neighbors apart. Labels, running dots, a launch bounce and arrow-key navigation included.