LLayro

Segmented Control

A row of options with a highlight that slides to the one you pick.

Installation

npx shadcn@latest add https://layro.dev/r/layro/segmented-control.json

Or, with @layro in your components.json:

npx shadcn@latest add @layro/layro/segmented-control

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. 1.0.0 · current

    Oct 27, 2026

    First release.

Similar components