Segmented Control
A pill slides to the chosen option; a radio group with arrow-key support.
Showing grid for this week
components/ui/segmented-control.tsx
"use client";
import { motion } from "motion/react";
import { LayoutGrid, List, Rows3 } from "lucide-react";
import { useId, useState, type ComponentType, type KeyboardEvent } from "react";
type Option<T extends string> = { value: T; label: string; icon?: ComponentType<{ className?: string }> };
type SegmentedControlProps<T extends string> = {
options: Option<T>[];
value: T;
onChange: (value: T) => void;
label: string;
size?: "sm" | "md";
};
/** iOS-style segmented control: a pill that slides to the chosen option. A radio group, so arrow keys work. */
export function SegmentedControl<T extends string>({ options, value, onChange, label, size = "md" }: SegmentedControlProps<T>) {
const id = useId();
const index = options.findIndex((o) => o.value === value);
function onKey(e: KeyboardEvent) {
const d = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
if (!d) return;
e.preventDefault();
onChange(options[(index + d + options.length) % options.length].value);
}
return (
<div role="radiogroup" aria-label={label} onKeyDown={onKey} className="inline-flex rounded-xl bg-zinc-100 p-1">
{options.map((o) => {
const on = o.value === value;
return (
<button
key={o.value}
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => onChange(o.value)}
className={`relative flex items-center gap-1.5 rounded-lg font-medium outline-none ring-blue-500/50 focus-visible:ring-2 ${size === "sm" ? "px-2.5 py-1 text-xs" : "px-3.5 py-1.5 text-sm"}`}
>
{on && <motion.span layoutId={`seg-${id}`} className="absolute inset-0 rounded-lg bg-white shadow-sm ring-1 ring-zinc-200" transition={{ type: "spring", stiffness: 500, damping: 36 }} />}
{o.icon && <o.icon className={`relative size-4 transition-colors ${on ? "text-zinc-900" : "text-zinc-400"}`} />}
<span className={`relative transition-colors ${on ? "text-zinc-900" : "text-zinc-500"}`}>{o.label}</span>
</button>
);
})}
</div>
);
}
export default function Demo() {
const [view, setView] = useState<"grid" | "list" | "rows">("grid");
const [period, setPeriod] = useState<"day" | "week" | "month" | "year">("week");
return (
<div className="flex flex-col items-center gap-6">
<SegmentedControl
label="View"
value={view}
onChange={setView}
options={[
{ value: "grid", label: "Grid", icon: LayoutGrid },
{ value: "list", label: "List", icon: List },
{ value: "rows", label: "Rows", icon: Rows3 },
]}
/>
<SegmentedControl
label="Period"
size="sm"
value={period}
onChange={setPeriod}
options={[
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
{ value: "year", label: "Year" },
]}
/>
<p className="text-sm text-zinc-500">
Showing <strong className="text-zinc-900">{view}</strong> for this <strong className="text-zinc-900">{period}</strong>
</p>
</div>
);
}
Edit with AIPro
Describe a change to Segmented Control and get the updated code. The live preview above stays as the original.
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/segmented-control.tsxcomponents/ui/segmented-control.tsx"use client"; import { motion } from "motion/react"; import { LayoutGrid, List, Rows3 } from "lucide-react"; import { useId, useState, type ComponentType, type KeyboardEvent } from "react"; type Option<T extends string> = { value: T; label: string; icon?: ComponentType<{ className?: string }> }; type SegmentedControlProps<T extends string> = { options: Option<T>[]; value: T; onChange: (value: T) => void; label: string; size?: "sm" | "md"; }; /** iOS-style segmented control: a pill that slides to the chosen option. A radio group, so arrow keys work. */ export function SegmentedControl<T extends string>({ options, value, onChange, label, size = "md" }: SegmentedControlProps<T>) { const id = useId(); const index = options.findIndex((o) => o.value === value); function onKey(e: KeyboardEvent) { const d = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0; if (!d) return; e.preventDefault(); onChange(options[(index + d + options.length) % options.length].value); } return ( <div role="radiogroup" aria-label={label} onKeyDown={onKey} className="inline-flex rounded-xl bg-zinc-100 p-1"> {options.map((o) => { const on = o.value === value; return ( <button key={o.value} role="radio" aria-checked={on} tabIndex={on ? 0 : -1} onClick={() => onChange(o.value)} className={`relative flex items-center gap-1.5 rounded-lg font-medium outline-none ring-blue-500/50 focus-visible:ring-2 ${size === "sm" ? "px-2.5 py-1 text-xs" : "px-3.5 py-1.5 text-sm"}`} > {on && <motion.span layoutId={`seg-${id}`} className="absolute inset-0 rounded-lg bg-white shadow-sm ring-1 ring-zinc-200" transition={{ type: "spring", stiffness: 500, damping: 36 }} />} {o.icon && <o.icon className={`relative size-4 transition-colors ${on ? "text-zinc-900" : "text-zinc-400"}`} />} <span className={`relative transition-colors ${on ? "text-zinc-900" : "text-zinc-500"}`}>{o.label}</span> </button> ); })} </div> ); } export default function Demo() { const [view, setView] = useState<"grid" | "list" | "rows">("grid"); const [period, setPeriod] = useState<"day" | "week" | "month" | "year">("week"); return ( <div className="flex flex-col items-center gap-6"> <SegmentedControl label="View" value={view} onChange={setView} options={[ { value: "grid", label: "Grid", icon: LayoutGrid }, { value: "list", label: "List", icon: List }, { value: "rows", label: "Rows", icon: Rows3 }, ]} /> <SegmentedControl label="Period" size="sm" value={period} onChange={setPeriod} options={[ { value: "day", label: "Day" }, { value: "week", label: "Week" }, { value: "month", label: "Month" }, { value: "year", label: "Year" }, ]} /> <p className="text-sm text-zinc-500"> Showing <strong className="text-zinc-900">{view}</strong> for this <strong className="text-zinc-900">{period}</strong> </p> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.