Skip to content
EMBOSS
Docs menu

Segmented Control

A bank of positions milled into one housing — a debossed carriage glides between segments with a sprung settle.

@emboss/segmented-control
MonoStereoWide

Installation

pnpm dlx shadcn@latest add @emboss/segmented-control

Usage

example.tsx
import {
  SegmentedControl,
  SegmentedControlItem,
} from "@/components/ui/segmented-control";

export function Example() {
  return (
    <SegmentedControl defaultValue="m" aria-label="Size">
      <SegmentedControlItem value="s">S</SegmentedControlItem>
      <SegmentedControlItem value="m">M</SegmentedControlItem>
      <SegmentedControlItem value="l">L</SegmentedControlItem>
    </SegmentedControl>
  );
}

API reference

SegmentedControl

Radio-group semantics: exactly one segment is always engaged. Label the group with aria-label.

PropTypeDefaultDescription
value / defaultValue / onValueChangestring / string / (value) => voidControlled and uncontrolled selection.

SegmentedControlItem

PropTypeDefaultDescription
valuestringThe position this segment represents.
disabledbooleanfalseRemoves the segment from selection.
Data attributeDescription
data-checkedPresent on the engaged segment.

Keyboard

KeysAction
ArrowLeftArrowRightMoves the carriage to the previous or next segment.
SpaceEngages the focused segment.

Source

View source — segmented-control.tsx
segmented-control.tsx
"use client";

import { RadioGroup as BaseRadioGroup } from "@base-ui/react/radio-group";
import { Radio as BaseRadio } from "@base-ui/react/radio";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { springs } from "@/lib/springs";
import { cn } from "@/lib/utils";

const SegmentedContext = React.createContext<string | null>(null);

export type SegmentedControlProps = Omit<
  React.ComponentProps<typeof BaseRadioGroup>,
  "value" | "defaultValue" | "onValueChange"
> & {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
};

/**
 * A bank of positions milled into one housing. Exactly one segment is
 * always engaged: a debossed carriage sits latched under it and glides to
 * the next position with a sprung settle.
 *
 * @example
 * <SegmentedControl defaultValue="m" aria-label="Channel width">
 *   <SegmentedControlItem value="s">Mono</SegmentedControlItem>
 *   <SegmentedControlItem value="m">Stereo</SegmentedControlItem>
 *   <SegmentedControlItem value="w">Wide</SegmentedControlItem>
 * </SegmentedControl>
 */
function SegmentedControl({
  className,
  value,
  defaultValue,
  onValueChange,
  ...props
}: SegmentedControlProps) {
  const carriageId = React.useId();
  return (
    <SegmentedContext.Provider value={carriageId}>
      <BaseRadioGroup
        data-slot="segmented-control"
        value={value}
        defaultValue={defaultValue}
        onValueChange={(next) => {
          if (typeof next === "string") onValueChange?.(next);
        }}
        className={cn(
          "inline-flex w-fit items-center gap-1 rounded-md bg-surface-2 p-1 shadow-emboss-1",
          className,
        )}
        {...props}
      />
    </SegmentedContext.Provider>
  );
}

function SegmentedControlItem({
  className,
  children,
  ...props
}: React.ComponentProps<typeof BaseRadio.Root>) {
  const carriageId = React.useContext(SegmentedContext);
  const reduceMotion = useReducedMotion() ?? false;
  return (
    <BaseRadio.Root
      data-slot="segmented-control-item"
      className={cn(
        "relative inline-flex h-7 cursor-pointer items-center justify-center rounded-sm px-3 text-sm font-medium whitespace-nowrap text-ink-muted focus-ring transition-colors duration-(--duration-settle) hover:text-ink data-checked:text-ink data-disabled:pointer-events-none data-disabled:opacity-50",
        className,
      )}
      {...props}
    >
      <BaseRadio.Indicator
        keepMounted={false}
        data-slot="segmented-control-indicator"
        className="absolute inset-0"
      >
        <motion.span
          aria-hidden
          layoutId={carriageId ?? undefined}
          transition={reduceMotion ? { duration: 0 } : springs.settle}
          className="absolute inset-0 rounded-sm bg-well shadow-deboss-1"
        />
      </BaseRadio.Indicator>
      <span className="relative z-10">{children}</span>
    </BaseRadio.Root>
  );
}

export { SegmentedControl, SegmentedControlItem };