Segmented Control

A pill slides to the chosen option; a radio group with arrow-key support.

Showing grid for this week

Edit with AIPro

Describe a change to Segmented Control and get the updated code. The live preview above stays as the original.

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/segmented-control.tsx

    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>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.