Segmented control
A compact single choice with a pill that slides to the selection.
$
pnpm dlx shadcn@latest add https://lodeui.com/r/segmented-control.jsonShowing the board view
components/examples/segmented-control-demo.tsx
"use client";
import * as React from "react";
import { SegmentedControl } from "@/components/ui/segmented-control";
const views = [
{ value: "list", label: "List" },
{ value: "board", label: "Board" },
{ value: "timeline", label: "Timeline" },
] as const;
export default function SegmentedControlDemo() {
const [view, setView] = React.useState<(typeof views)[number]["value"]>("board");
return (
<div className="flex flex-col items-center gap-4">
<SegmentedControl aria-label="View" options={views} value={view} onValueChange={setView} />
<p className="text-sm text-muted-foreground">
Showing the <span className="text-foreground">{view}</span> view
</p>
</div>
);
}When to use
- Two to five mutually exclusive options that should all be visible, like 7D / 30D / 90D.
- Switching a view (list, board, calendar) or a billing cycle.
When not to use
- More than five options or long labels: use a select.
- Options that trigger actions rather than set a value: use buttons.
- Navigation between pages: use tabs or links.
Installation
$
pnpm dlx shadcn@latest add https://lodeui.com/r/segmented-control.json1Install the dependencies
npm install motion2Copy the source into your project
components/ui/segmented-control.tsx"use client"; import * as React from "react"; import { motion } from "motion/react"; import { cn } from "@/lib/utils"; export interface SegmentedOption<T extends string> { value: T; label: React.ReactNode; } export interface SegmentedControlProps<T extends string> { options: readonly SegmentedOption<T>[]; value?: T; defaultValue?: T; onValueChange?: (value: T) => void; size?: "sm" | "md"; className?: string; "aria-label"?: string; } export function SegmentedControl<T extends string>({ options, value, defaultValue, onValueChange, size = "md", className, ...aria }: SegmentedControlProps<T>) { const id = React.useId(); const [internal, setInternal] = React.useState<T>(defaultValue ?? options[0]?.value); const current = value ?? internal; const refs = React.useRef<(HTMLButtonElement | null)[]>([]); const select = (next: T) => { if (value === undefined) setInternal(next); onValueChange?.(next); }; const onKeyDown = (e: React.KeyboardEvent, index: number) => { const last = options.length - 1; let next: number | null = null; if (e.key === "ArrowRight" || e.key === "ArrowDown") next = index === last ? 0 : index + 1; if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = index === 0 ? last : index - 1; if (e.key === "Home") next = 0; if (e.key === "End") next = last; if (next === null) return; e.preventDefault(); select(options[next].value); refs.current[next]?.focus(); }; return ( <div role="radiogroup" aria-label={aria["aria-label"]} className={cn( "inline-flex items-center rounded-lg border border-border bg-muted p-1", size === "sm" ? "h-9 text-[13px]" : "h-11 text-sm", className, )} > {options.map((option, i) => { const active = option.value === current; return ( <button key={option.value} ref={(el) => { refs.current[i] = el; }} type="button" role="radio" aria-checked={active} tabIndex={active ? 0 : -1} onClick={() => select(option.value)} onKeyDown={(e) => onKeyDown(e, i)} className={cn( "relative h-full rounded-md font-medium outline-none transition-colors duration-200", "focus-visible:ring-2 focus-visible:ring-ring", size === "sm" ? "px-3" : "px-4", active ? "text-foreground" : "text-muted-foreground hover:text-foreground", )} > {active && ( <motion.span layoutId={`segment-${id}`} className="absolute inset-0 rounded-md bg-card shadow-sm ring-1 ring-border dark:bg-accent" transition={{ type: "spring", bounce: 0.15, duration: 0.45 }} /> )} <span className="relative z-10">{option.label}</span> </button> ); })} </div> ); }
Usage
Import it from @/components/ui/segmented-control after installing.
example.tsx
import { SegmentedControl } from "@/components/ui/segmented-control";
export function RangePicker({ value, onChange }: { value: "7" | "30" | "90"; onChange: (v: "7" | "30" | "90") => void }) {
return (
<SegmentedControl
aria-label="Range"
value={value}
onValueChange={onChange}
options={[
{ value: "7", label: "7 days" },
{ value: "30", label: "30 days" },
{ value: "90", label: "90 days" },
]}
/>
);
}API reference
SegmentedControl
A radio group drawn as a pill track. Generic over the option value type, so onValueChange is typed.
| Prop | Type | Default | Description |
|---|---|---|---|
| options | readonly { value: T; label: ReactNode }[] | – | The choices, in order. Labels can include icons. |
| value / defaultValue | T | – | Controlled or uncontrolled value. Defaults to the first option. |
| onValueChange | (value: T) => void | – | Called with the newly selected value. |
| size | "sm" | "md" | "md" | 36px or 44px tall. |
| aria-label | string | – | Names the group for screen readers. Required unless a visible label is linked. |
Keyboard interactions
| Keys | Action |
|---|---|
| ←or→ | Select the previous or next option |
| ↑or↓ | Same as left and right |
| HomeorEnd | Select the first or last option |
Accessibility
- role="radiogroup" with role="radio" items and aria-checked.
- Roving tabindex: Tab enters the group once, arrows move inside it.
- The selected option is not conveyed by color alone; the pill has a ring and shadow.
Motion
- The pill is a shared-layout element that springs between options (bounce 0.15, 450ms).
- Text color crossfades in 200ms.
Responsive behavior
- Width hugs the labels. Add className="w-full [&>button]:flex-1" to stretch it on phones.
- Keep labels short (one or two words) so it fits at 320px.
Performance
- One animated element per control; options are plain buttons.
Notes for AI
- Always pass aria-label.
- Use `as const` on the options array to get a literal union for T.
This page as Markdown, for assistants and coding agents:
/components/segmented-control/markdownThe full library index for assistants is at /llms.txt.
Lode222 doc pagesllms.txt