Installation
Or, with @layro in your components.json:
Code
import { motion } from "motion/react";
import type * as React from "react";
import { useId } from "react";
import { cn } from "@/lib/utils";
/*
A row of mutually exclusive options with a sliding highlight. Also
published on Layro as `segmented-control` by @layro.
*/
export interface SegmentedOption<T extends string> {
value: T;
label: string;
/** Shown before the label, e.g. a small icon or logo. */
icon?: React.ReactNode;
}
export interface SegmentedControlProps<T extends string> {
options: SegmentedOption<T>[];
value: T;
onValueChange: (value: T) => void;
/** Accessible name for the group. */
label: string;
size?: "sm" | "md";
className?: string;
}
export function SegmentedControl<T extends string>({
options,
value,
onValueChange,
label,
size = "md",
className,
}: SegmentedControlProps<T>) {
const id = useId();
return (
<div
role="radiogroup"
aria-label={label}
className={cn(
"inline-flex items-center gap-0.5 rounded-xl bg-[var(--surface-muted)] p-1 ring-1 ring-[var(--border)] ring-inset",
className,
)}
>
{options.map((option) => {
const active = option.value === value;
return (
// biome-ignore lint/a11y/useSemanticElements: styled radio button with a shared sliding highlight
<button
key={option.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => onValueChange(option.value)}
className={cn(
"relative rounded-lg font-medium transition-colors",
size === "sm"
? "h-7 px-3 text-[12.5px]"
: "h-8 px-3.5 text-[13px]",
active
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{active && (
<motion.span
layoutId={`segment-${id}`}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
className="absolute inset-0 rounded-lg bg-foreground/[0.07] shadow-[inset_0_1px_0_rgba(255,255,255,0.06)] ring-1 ring-foreground/[0.07] ring-inset"
/>
)}
<span className="relative inline-flex items-center gap-1.5">
{option.icon}
{option.label}
</span>
</button>
);
})}
</div>
);
}Versions
1.0.0 · current
Oct 27, 2026
First release.