# Segmented Control

> Segmented radio group with a sliding thumb, arrow keys, optional icons and two sizes.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/segmented-control
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/segmented-control.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/segmented-control-demo.tsx"
"use client"

import * as React from "react"
import { LayoutGrid, List, Rows3 } from "lucide-react"

import { SegmentedControl } from "@/components/segmented-control"

export default function SegmentedControlDemo() {
  const [view, setView] = React.useState("grid")

  return (
    <div className="grid w-full max-w-sm justify-items-center gap-6">
      <SegmentedControl
        aria-label="Layout"
        value={view}
        onValueChange={setView}
        options={[
          { value: "grid", label: "Grid", icon: <LayoutGrid /> },
          { value: "list", label: "List", icon: <List /> },
          { value: "table", label: "Table", icon: <Rows3 /> },
        ]}
      />
      <p className="text-xs text-muted-foreground">
        View: <span className="font-medium text-foreground">{view}</span>
      </p>
      <SegmentedControl
        aria-label="Billing period"
        size="sm"
        fullWidth
        defaultValue="monthly"
        options={[
          { value: "monthly", label: "Monthly" },
          { value: "yearly", label: "Yearly" },
        ]}
      />
    </div>
  )
}
```

## Source

```tsx title="components/segmented-control.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export type SegmentedOption = { value: string; label: string; icon?: React.ReactNode }

export interface SegmentedControlProps extends Omit<React.ComponentProps<"div">, "children" | "defaultValue" | "onChange"> {
  options: SegmentedOption[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  size?: "sm" | "md"
  fullWidth?: boolean
  "aria-label"?: string
}

const sizes = { sm: "h-8 text-xs", md: "h-10 text-sm" } as const

export function SegmentedControl({
  options,
  value,
  defaultValue,
  onValueChange,
  size = "md",
  fullWidth = false,
  className,
  ...props
}: SegmentedControlProps) {
  const [inner, setInner] = React.useState(defaultValue ?? options[0]?.value)
  const current = value ?? inner
  const index = Math.max(0, options.findIndex((option) => option.value === current))
  const group = React.useRef<HTMLDivElement>(null)

  const select = (next: number) => {
    const option = options[(next + options.length) % options.length]
    if (value === undefined) setInner(option.value)
    onValueChange?.(option.value)
    group.current?.querySelectorAll<HTMLElement>("[role=radio]")[(next + options.length) % options.length]?.focus()
  }

  return (
    <div
      ref={group}
      role="radiogroup"
      onKeyDown={(event) => {
        if (event.key === "ArrowRight" || event.key === "ArrowDown") select(index + 1)
        else if (event.key === "ArrowLeft" || event.key === "ArrowUp") select(index - 1)
        else return
        event.preventDefault()
      }}
      style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
      className={cn("relative grid rounded-lg bg-muted p-1", fullWidth ? "w-full" : "w-fit", className)}
      {...props}
    >
      <span
        aria-hidden
        className="absolute inset-y-1 left-1 rounded-md bg-background shadow-sm transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]"
        style={{ width: `calc((100% - 0.5rem) / ${options.length})`, transform: `translateX(${index * 100}%)` }}
      />
      {options.map((option) => {
        const selected = option.value === current
        return (
          <button
            key={option.value}
            type="button"
            role="radio"
            aria-checked={selected}
            tabIndex={selected ? 0 : -1}
            onClick={() => select(options.indexOf(option))}
            className={cn(
              "relative z-10 inline-flex items-center justify-center gap-1.5 rounded-md px-4 font-medium whitespace-nowrap text-muted-foreground transition-colors hover:text-foreground aria-checked:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none [&_svg]:size-4",
              sizes[size]
            )}
          >
            {option.icon}
            {option.label}
          </button>
        )
      })}
    </div>
  )
}
```
