# Segmented Control

Two to five exclusive options with a pill that glides to the selection. Arrow keys move and select.

**Category:** Inputs  
**Exports:** `SegmentedControl`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/segmented-control.json
```
The file lands at `components/ui/segmented-control.tsx`; import from `@/components/ui/segmented-control`.

## API — Segmented Control
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `options` | `{ value: T; label: ReactNode; icon?: ReactNode; disabled?: boolean }[]` | — |  |
| `value` | `T` | — | Controlled value. |
| `defaultValue` | `T` | — | Initial value when uncontrolled. |
| `onValueChange` | `(value: T) => void` | — |  |
| `size` | `"sm" \| "md"` | `"md"` |  |
| `aria-label` | `string` | — |  |

## Example — Segmented Control

```tsx
import { LayoutGrid, Layers, Code } from "lucide-react";
import { SegmentedControl } from "@/components/ui/segmented-control";

export default function SegmentedControlDemo({ size = "md" }: { size?: "sm" | "md" }) {
  return (
    <div className="flex flex-col items-center gap-6">
      <SegmentedControl
        size={size}
        aria-label="Billing period"
        defaultValue="yearly"
        options={[
          { value: "monthly", label: "Monthly" },
          { value: "yearly", label: "Yearly" },
          { value: "lifetime", label: "Lifetime" },
        ]}
      />
      <SegmentedControl
        size="sm"
        aria-label="View"
        options={[
          { value: "grid", label: "Grid", icon: <LayoutGrid /> },
          { value: "layers", label: "Layers", icon: <Layers /> },
          { value: "code", label: "Code", icon: <Code /> },
        ]}
      />
    </div>
  );
}
```

## Source — components/ui/segmented-control.tsx

```tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type SegmentedOption<T extends string> = {
  value: T;
  label: React.ReactNode;
  icon?: React.ReactNode;
  disabled?: boolean;
};

export type SegmentedControlProps<T extends string> = {
  options: 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: valueProp,
  defaultValue,
  onValueChange,
  size = "md",
  className,
  ...aria
}: SegmentedControlProps<T>) {
  const id = React.useId();
  const reduce = useReducedMotion();
  const [inner, setInner] = React.useState<T>(defaultValue ?? options[0]?.value);
  const value = valueProp ?? inner;
  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);

  const select = (next: T) => {
    if (valueProp === undefined) setInner(next);
    onValueChange?.(next);
  };

  const onKeyDown = (e: React.KeyboardEvent, index: number) => {
    const dir = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    for (let i = 1; i <= options.length; i++) {
      const j = (index + dir * i + options.length) % options.length;
      if (!options[j].disabled) {
        select(options[j].value);
        refs.current[j]?.focus();
        return;
      }
    }
  };

  return (
    <div
      role="radiogroup"
      {...aria}
      className={cn(
        "inline-flex items-center gap-0.5 rounded-xl bg-muted p-1 ring-1 ring-inset ring-border",
        className,
      )}
    >
      {options.map((option, i) => {
        const active = option.value === value;
        return (
          <button
            key={option.value}
            ref={(el) => {
              refs.current[i] = el;
            }}
            type="button"
            role="radio"
            aria-checked={active}
            tabIndex={active ? 0 : -1}
            disabled={option.disabled}
            onClick={() => select(option.value)}
            onKeyDown={(e) => onKeyDown(e, i)}
            className={cn(
              "relative inline-flex cursor-pointer select-none items-center justify-center gap-1.5 whitespace-nowrap font-medium outline-none transition-colors duration-150",
              "focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40 [&_svg]:size-4",
              size === "sm" ? "h-7 rounded-md px-2.5 text-xs" : "h-8 rounded-lg px-3.5 text-sm",
              active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
            )}
          >
            {active && (
              <motion.span
                layoutId={`${id}-pill`}
                aria-hidden
                className={cn(
                  "absolute inset-0 bg-background shadow-[0_1px_2px_rgb(0_0_0/0.12),0_0_0_1px_var(--color-border)] dark:bg-accent",
                  size === "sm" ? "rounded-md" : "rounded-lg",
                )}
                transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 36 }}
              />
            )}
            <span className="relative z-10 inline-flex items-center gap-1.5">
              {option.icon}
              {option.label}
            </span>
          </button>
        );
      })}
    </div>
  );
}
```