UI Elements

Segmented Control

Pill segmented control on Base UI ToggleGroup: a muted track where a lifted card-ground thumb glides to the pressed item, still under reduced motion. Horizontal only; values are arrays, as in Base UI.

Free, MIT licenseOpen in Browse
"use client"

import { createContext, useContext, useId, useMemo, useState } from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { cn } from "cn"
import { LayoutGroup, motion, useReducedMotion } from "motion/react"

// value/onValueChange/defaultValue take arrays, per Base UI ToggleGroup.
// className is a string here, not Base UI's state callback: the styling is
// the kit's, and state comes through data-pressed and data-disabled.
type Styled<P> = Omit<P, "className"> & {
  /**
   * Extra classes, as a string only: Base UI's function form is not
   * accepted. On `SegmentedControl` they land on the track, and on
   * `SegmentedControlItem` on the toggle button.
   */
  className?: string
}

// The group keeps the pressed values so each item knows whether it holds the
// thumb; a controlled value wins over the inner state.
const PressedContext = createContext<{
  pressed: Set<string>
  group: string
  multiple: boolean
}>({ pressed: new Set(), group: "", multiple: false })

/**
 * A row of toggle buttons on a rounded track. The pressed one sits on a
 * raised thumb that slides over from the last one pressed. Put
 * `SegmentedControlItem` parts inside.
 *
 * It is Base UI's `ToggleGroup`, so `value`, `defaultValue` and
 * `onValueChange` use arrays of item values. Pressing the active item again
 * clears it; to keep one always on, drop empty updates in `onValueChange`:
 * skip them when you control `value`, or call `cancel()` on the second
 * argument. It is horizontal only, and `className` takes a string.
 */
function SegmentedControl({
  className,
  value,
  defaultValue,
  onValueChange,
  multiple = false,
  ...props
}: Styled<Omit<ToggleGroupPrimitive.Props, "orientation">>) {
  const group = useId()
  const [inner, setInner] = useState<string[]>(
    (defaultValue as string[] | undefined) ?? []
  )
  const current = (value as string[] | undefined) ?? inner
  const key = current.join("\u0000")
  const context = useMemo(
    () => ({
      pressed: new Set(key ? key.split("\u0000") : []),
      group,
      multiple,
    }),
    [key, group, multiple]
  )
  return (
    <PressedContext.Provider value={context}>
      <LayoutGroup id={group}>
        <ToggleGroupPrimitive
          data-slot="segmented-control"
          value={current}
          multiple={multiple}
          onValueChange={(next, event) => {
            // The consumer goes first: event.cancel() keeps the old value.
            onValueChange?.(next, event)
            if (!event.isCanceled) setInner(next as string[])
          }}
          className={cn(
            // The track: a muted well; in dark a faint lit rim on its top edge.
            "inline-flex items-center gap-1 rounded-full bg-muted p-1 ring-1 ring-border",
            "dark:bg-foreground/6 dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] dark:ring-foreground/10",
            className
          )}
          {...props}
        />
      </LayoutGroup>
    </PressedContext.Provider>
  )
}

/**
 * One option in a `SegmentedControl`, built on Base UI's `Toggle`. Give it a
 * string `value`. It is square for one character or an icon and widens to
 * fit a word.
 *
 * The pressed item sits on the thumb, a card-colored pill with a soft shadow
 * (a light wash in dark mode) that glides over from the last item pressed;
 * reduced motion moves it at once. The other items stay muted and come up
 * to full ink on hover. When the control has `multiple`, each pressed item
 * gets its own thumb, without the glide.
 */
function SegmentedControlItem({
  className,
  children,
  value,
  ...props
}: Styled<TogglePrimitive.Props>) {
  const { pressed, multiple } = useContext(PressedContext)
  const reduce = useReducedMotion()
  const on = typeof value === "string" && pressed.has(value)
  return (
    <TogglePrimitive
      data-slot="segmented-control-item"
      value={value}
      className={cn(
        "relative isolate flex h-8 min-w-8 items-center justify-center rounded-full px-2.5 text-sm font-medium text-muted-foreground tabular-nums",
        "transition-colors duration-150 ease-out motion-reduce:transition-none",
        "hover:text-foreground",
        "focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
        "data-disabled:pointer-events-none data-disabled:opacity-50",
        "data-pressed:text-foreground",
        className
      )}
      {...props}
    >
      {/* The thumb: one shared element per group, so it glides to whichever
          item is pressed instead of popping. With several pressed at once
          each keeps its own, still. */}
      {on && (
        <motion.span
          aria-hidden="true"
          layoutId={multiple ? undefined : "thumb"}
          transition={
            reduce
              ? { duration: 0 }
              : { type: "spring", stiffness: 520, damping: 42, mass: 0.8 }
          }
          className="absolute inset-0 -z-10 rounded-full bg-card shadow-xs dark:bg-foreground/15"
        />
      )}
      {children}
    </TogglePrimitive>
  )
}

export { SegmentedControl, SegmentedControlItem }

Installation

pnpm dlx shadcn add @01orbital/segmented-control
npx shadcn add @01orbital/segmented-control
yarn shadcn add @01orbital/segmented-control
bunx --bun shadcn add @01orbital/segmented-control

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/toggle/segmented-control.tsx, as source you own and edit.

The example also imports @phosphor-icons/react from npm, which the install does not add:

pnpm add @phosphor-icons/react
npm install @phosphor-icons/react
yarn add @phosphor-icons/react
bun add @phosphor-icons/react

Usage

"use client"

import { useState } from "react"
import {
  TextAlignCenterIcon,
  TextAlignLeftIcon,
  TextAlignRightIcon,
} from "@phosphor-icons/react"

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

// Year is disabled, so that state shows beside the live ones.
const RANGES = ["Day", "Week", "Month", "Year"] as const

const ALIGNMENTS = [
  { value: "left", label: "Align left", Icon: TextAlignLeftIcon },
  { value: "center", label: "Align center", Icon: TextAlignCenterIcon },
  { value: "right", label: "Align right", Icon: TextAlignRightIcon },
] as const

export default function SegmentedControlDemo() {
  const [range, setRange] = useState<string>("Week")
  return (
    <div className="flex flex-wrap items-center justify-center gap-8">
      {/* Range keeps one on: an empty update is ignored. */}
      <SegmentedControl
        aria-label="Range"
        value={[range]}
        onValueChange={(next) => {
          if (next[0]) setRange(next[0])
        }}
      >
        {RANGES.map((value) => (
          <SegmentedControlItem
            key={value}
            value={value}
            disabled={value === "Year"}
          >
            {value}
          </SegmentedControlItem>
        ))}
      </SegmentedControl>
      <SegmentedControl aria-label="Text alignment" defaultValue={["left"]}>
        {ALIGNMENTS.map(({ value, label, Icon }) => (
          <SegmentedControlItem key={value} value={value} aria-label={label}>
            <Icon aria-hidden="true" />
          </SegmentedControlItem>
        ))}
      </SegmentedControl>
    </div>
  )
}

Anatomy

Segmented Control is built from 2 parts. In the example they nest like this:

SegmentedControl
└─ SegmentedControlItem
  • SegmentedControl: A row of toggle buttons on a rounded track. The pressed one sits on a raised thumb that slides over from the last one pressed. Put SegmentedControlItem parts inside.
  • SegmentedControlItem: One option in a SegmentedControl, built on Base UI's Toggle. Give it a string value. It is square for one character or an icon and widens to fit a word.

API reference

SegmentedControl

A row of toggle buttons on a rounded track. The pressed one sits on a raised thumb that slides over from the last one pressed. Put SegmentedControlItem parts inside.

It is Base UI's ToggleGroup, so value, defaultValue and onValueChange use arrays of item values. Pressing the active item again clears it; to keep one always on, drop empty updates in onValueChange: skip them when you control value, or call cancel() on the second argument. It is horizontal only, and className takes a string.

Prop

Type

It also accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.

SegmentedControlItem

One option in a SegmentedControl, built on Base UI's Toggle. Give it a string value. It is square for one character or an icon and widens to fit a word.

The pressed item sits on the thumb, a card-colored pill with a soft shadow (a light wash in dark mode) that glides over from the last item pressed; reduced motion moves it at once. The other items stay muted and come up to full ink on hover. When the control has multiple, each pressed item gets its own thumb, without the glide.

Prop

Type

It also accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.

Dependencies

npm: @base-ui/react, cn, motion. The CLI installs them for you.

No other registry pieces.