Segmented Control
A row of options with a selected fill that slides between them.
From the Nitip template — see it in contextShowing projects as grid
"use client";
import { useState } from "react";
import { SegmentedControl } from "@/components/ui/segmented-control";
const options = [
{ value: "grid", label: "Grid" },
{ value: "list", label: "List" },
{ value: "slides", label: "Slides" },
] as const;
export default function SegmentedControlDemo() {
const [view, setView] = useState<(typeof options)[number]["value"]>("grid");
return (
<div className="flex flex-col items-center gap-4">
<SegmentedControl label="Project view" options={[...options]} value={view} onValueChange={setView} />
<p className="text-sm text-muted-foreground">
Showing projects as <span className="font-medium text-foreground">{view}</span>
</p>
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/segmented-control.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/segmented-control.tsx."use client"; import * as React from "react"; import { motion, type Transition } from "motion/react"; import { cn } from "@/lib/utils"; /* * Segmented Control — a shadcn Tabs-style row of options with one selected (from the Nitip "Switch"). * The selected fill slides to the chosen option (Nitip swaps it instantly). Unselected options brighten on hover * instead of Nitip's fade to 50%, which is too faint on muted text. * Keyboard: arrow keys, Home and End move the selection (radio group pattern). */ const spring: Transition = { type: "spring", bounce: 0.15, duration: 0.45 }; export type SegmentedOption<T extends string> = { value: T; label: React.ReactNode; disabled?: boolean }; export type SegmentedControlProps<T extends string> = { options: SegmentedOption<T>[]; value?: T; defaultValue?: T; onValueChange?: (value: T) => void; /** Accessible name of the group. */ label: string; size?: "sm" | "md"; className?: string; }; export function SegmentedControl<T extends string>({ options, value, defaultValue, onValueChange, label, size = "md", className, }: SegmentedControlProps<T>) { const [internal, setInternal] = React.useState<T | undefined>(defaultValue ?? options[0]?.value); const selected = value ?? internal; const layoutId = React.useId(); const refs = React.useRef<(HTMLButtonElement | null)[]>([]); function select(next: T) { if (value === undefined) setInternal(next); onValueChange?.(next); } function onKeyDown(event: React.KeyboardEvent, index: number) { const enabled = options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0); const at = enabled.indexOf(index); let target: number | undefined; if (event.key === "ArrowRight" || event.key === "ArrowDown") target = enabled[(at + 1) % enabled.length]; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") target = enabled[(at - 1 + enabled.length) % enabled.length]; else if (event.key === "Home") target = enabled[0]; else if (event.key === "End") target = enabled[enabled.length - 1]; if (target === undefined) return; event.preventDefault(); select(options[target].value); refs.current[target]?.focus(); } return ( <div role="radiogroup" aria-label={label} className={cn( "inline-flex w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground", size === "sm" ? "h-8" : "h-9", className, )} > {options.map((option, index) => { const active = option.value === selected; return ( <button key={option.value} ref={(el) => { refs.current[index] = el; }} type="button" role="radio" aria-checked={active} disabled={option.disabled} tabIndex={active ? 0 : -1} onClick={() => select(option.value)} onKeyDown={(e) => onKeyDown(e, index)} className={cn( "relative inline-flex h-full cursor-pointer items-center justify-center rounded-md border border-transparent font-medium whitespace-nowrap transition-colors duration-200 outline-none", "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", "disabled:pointer-events-none disabled:opacity-50", size === "sm" ? "px-2.5 text-xs" : "px-3 text-sm", active ? "text-foreground" : "text-muted-foreground hover:text-foreground", )} > {active && ( <motion.span layoutId={layoutId} transition={spring} className="absolute -inset-px rounded-md border border-transparent bg-background shadow-sm dark:border-input dark:bg-input/30" aria-hidden /> )} <span className="relative">{option.label}</span> </button> ); })} </div> ); }
Usage
import { SegmentedControl } from "@/components/ui/segmented-control";
const [view, setView] = useState("grid");
<SegmentedControl
label="Project view"
options={[{ value: "grid", label: "Grid" }, { value: "list", label: "List" }]}
value={view}
onValueChange={setView}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| options | { value, label, disabled? }[] | — | The choices, in order. |
| label | string | — | Accessible name of the group. |
| value | string | — | Selected value (controlled). |
| defaultValue | string | first option | Initial value (uncontrolled). |
| onValueChange | (value) => void | — | Called when the selection changes. |
| size | "sm" | "md" | "md" | Compact or regular. |
- Keyboard: arrow keys, Home and End move the selection (radio group).
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS + Motion (motion/react) component called SegmentedControl.
- Look like the shadcn/ui TabsList: bg-muted, rounded-lg, 3px padding, h-9; options text-sm font-medium px-3,
unselected text-muted-foreground (hover text-foreground).
- The selected option has a bg-background rounded-md shadow-sm fill behind its label that slides between options with a
shared layoutId (spring bounce 0.15, duration 0.45s); its text is text-foreground.
- Props: options {value,label,disabled?}[], value / defaultValue / onValueChange (controlled or not), label (aria-label), size sm|md.
- Accessibility: role radiogroup, each option role radio with aria-checked, roving tabindex,
arrow keys / Home / End move and select. Visible focus ring.










