跳至正文
组件

Segmented Control分段选择

方条样式的互斥选项组,选中块在选项间滑动,可带图标或小样,与数值滑块同一套高度。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/segmented-control.json
会连带安装 本站条目 grid-layout、flow-diagram。已存在的文件会询问是否覆盖。

用法

components/view-switch.tsx
import { LayoutGrid, List } from "lucide-react"
import { useState } from "react"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"

const OPTIONS = [
  { value: "list", label: "列表", icon: <List /> },
  { value: "grid", label: "网格", icon: <LayoutGrid /> },
]

export function ViewSwitch() {
  const [view, setView] = useState("list")

  return (
    <SegmentedControl
      aria-label="视图"
      options={OPTIONS}
      value={view}
      onValueChange={setView}
    />
  )
}

options 是必需项,值是字符串。传 value 与 onValueChange 走受控;只传 defaultValue(或都不传,此时落在第一项)由组件自己管。

结构与语义

外框是方条:细边框(border-edge)、小圆角、bg-muted/60 的淡底,和数值滑块同一副外观。每个选项是一个 Radio.Root,整块可点;选中时选项背后滑入一块 segmented-control-indicator,用 Motion 的共享布局动画在选项之间滑动,不会闪一下再出现。

label 是方条左侧的纯文字(与数值滑块、开关的标签同一种写法),选项则收进右侧一道微微下凹的槽里,选中项是槽里凸起的一块,字段名与可选项一眼分得开。label 同时作为整个单选组的可访问名称(aria-labelledby),写了它就不必再写 aria-label;不写 label 时整条都是选项,没有这道槽。

<SegmentedControl label="对齐" options={ALIGN_OPTIONS} defaultValue="left" />

高度与色调

variant 与 size 共同决定高度,两档与数值滑块完全一致,放在同一个面板里能对齐:

variantsmdefaultlg
bar28px32px36px
compact18px22px24px

compact 同时把字号收到 text-xs,适合挤在调整面板里的一排排参数。

tone 给选中块染上一档淡色。它会把 --flow-tone 写在根元素上,所以子树里其他读同一个变量的图形也会跟着变色。不传 tone 时选中块用背景色加一圈细描边,在浅色底上像一块浮起的白色贴片。

<SegmentedControl
  aria-label="时间范围"
  tone="blue"
  size="sm"
  options={OPTIONS}
/>

选项里的小样

icon 是选项文字前的小样,放图标、色点、材质方块都行;它包在 aria-hidden 的容器里,读屏只念 label。图标尺寸默认收到 14px(size-3.5),自己给 size-* 可以覆盖。

const OPTIONS = [
  { value: "cool", label: "冷色", icon: <Swatch tones={["blue", "cyan"]} /> },
  { value: "warm", label: "暖色", icon: <Swatch tones={["amber", "rose"]} /> },
]

示例

色调

访问量卡片上的时间范围切换。tone="blue" 只给选中块染上一档淡蓝;下面那条柱状图自己拿 --flow-tone 取色,和选中块用的是同一个变量。

正在加载…

左侧标签

label 是左侧的字段名,选项在右侧的槽里,读起来像「字段名 + 取值」。下面把对齐方式直接套在段落上,看得见这一行确实在起作用。

正在加载…

小样

选项文字前的 icon 换成两个色点,「中性 / 冷色 / 暖色」一眼可辨。色点对读屏隐藏,信息仍由文字承担。

正在加载…

API

SegmentedControl

除上表外,其余属性直接交给 Base UI 的 RadioGroup.Root:disabled(整组禁用,选中块与文字一起淡到 50%)、aria-label、required 等。

segmentedControlVariants

cva 生成的根元素样式函数,有 size 与 variant 两个变体。写自己的包装层时可以复用它,高度通过根元素上的 --segmented-control-height 变量传给内部。

const segmentedControlVariants: (props?: {
  size?: SegmentedControlSize
  variant?: SegmentedControlVariant
}) => string

类型

type SegmentedControlSize = "sm" | "default" | "lg"

type SegmentedControlVariant = "bar" | "compact"

type SegmentedControlOption = {
  value: string
  label: React.ReactNode
  /** 文字前的小样:图标、色点、材质方块 */
  icon?: React.ReactNode
  disabled?: boolean
}

type SegmentedControlProps = Omit<
  React.ComponentProps<typeof RadioGroup>,
  "value" | "defaultValue" | "onValueChange" | "children"
> & {
  options: readonly SegmentedControlOption[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  label?: React.ReactNode
  variant?: SegmentedControlVariant
  size?: SegmentedControlSize
  tone?: FlowTone
  modified?: boolean
  className?: string
  style?: React.CSSProperties
}

data-slot

元素data-slot
根元素segmented-control
左侧标签segmented-control-label
选项槽(有 label 时带凹槽底色)segmented-control-options
选项segmented-control-item
选中块segmented-control-indicator

根元素始终带 data-variant(bar / compact),modified 为真时带 data-modified,禁用时带 data-disabled;选项上是 Base UI 给的 data-checked、data-disabled。

可访问性

  • 语义是单选组:根元素是 role="radiogroup",每个选项是 role="radio",选中状态由 aria-checked 表达。方向键在选项间移动并直接选中(不是「移过去再空格确认」)。
  • 组的名称取自 label(aria-labelledby);没写 label 时请务必给 aria-label,否则读屏只能念出一个没有名字的单选组。
  • 选项的小样是装饰,aria-hidden;选项文字才是名称。
  • 键盘焦点在选项上时有一圈 focus-visible 焦点环,并临时抬高 z-index,不会被旁边滑过来的选中块盖住。
  • modified 只有配 label 时才有可见效果(标签从次要色变成前景色);没有 label 时只留下 data-modified,供外界用选择器自己加样式。

注意事项

  • 同一页里可能有多个分段选择,选中块的共享布局 id 用 React.useId() 各自生成,动画不会串到另一个组上。
  • 系统开启「减少动态效果」时,选中块直接跳到位,不做滑动。
  • 选项文字不换行(whitespace-nowrap + truncate),选项太多太窄时优先缩短文字,不要靠缩小字号。
  • 值是字符串。需要数字或其他类型时在 onValueChange 里自己转,或者用 String(value) 存选项的 value。
  • 受控 / 非受控共用一个 useControllableState;options 变化后如果当前值已不存在,组件不会自动改选,请在父级同步。
  • tone 的颜色变量由 flow-diagram 条目提供,安装时会一并拉取。

源码

components/ui/segmented-control/segmented-control.tsx
"use client"

import * as React from "react"
import { Radio } from "@base-ui/react/radio"
import { RadioGroup } from "@base-ui/react/radio-group"
import { cva } from "class-variance-authority"
import { motion, useReducedMotion } from "motion/react"

import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { flowToneStyle, type FlowTone } from "@/components/ui/flow-diagram/tone"

/**
 * 外框与数值滑块同一套方条:细边框、小圆角、淡底。
 * 高度表也与数值滑块一致(bar 28 / 32 / 36px,compact 18 / 22 / 24px),放在同一个面板里能对齐。
 */
const segmentedControlVariants = cva(
  "group/segmented-control relative flex h-(--segmented-control-height) w-full min-w-0 items-stretch overflow-hidden rounded-md border border-edge bg-muted/60 select-none",
  {
    variants: {
      size: {
        sm: "text-xs",
        default: "text-sm",
        lg: "text-sm",
      },
      variant: {
        bar: "",
        compact: "text-xs",
      },
    },
    compoundVariants: [
      {
        variant: "bar",
        size: "sm",
        class: "[--segmented-control-height:1.75rem]",
      },
      {
        variant: "bar",
        size: "default",
        class: "[--segmented-control-height:2rem]",
      },
      {
        variant: "bar",
        size: "lg",
        class: "[--segmented-control-height:2.25rem]",
      },
      {
        variant: "compact",
        size: "sm",
        class: "[--segmented-control-height:1.125rem]",
      },
      {
        variant: "compact",
        size: "default",
        class: "[--segmented-control-height:1.375rem]",
      },
      {
        variant: "compact",
        size: "lg",
        class: "[--segmented-control-height:1.5rem]",
      },
    ],
    defaultVariants: {
      size: "default",
      variant: "bar",
    },
  },
)

type SegmentedControlSize = "sm" | "default" | "lg"
type SegmentedControlVariant = "bar" | "compact"

type SegmentedControlOption = {
  value: string
  label: React.ReactNode
  /** 文字前的小样:图标、色点、材质方块 */
  icon?: React.ReactNode
  disabled?: boolean
}

type SegmentedControlProps = Omit<
  React.ComponentProps<typeof RadioGroup>,
  "value" | "defaultValue" | "onValueChange" | "children"
> & {
  options: readonly SegmentedControlOption[]
  /** 受控值 */
  value?: string
  /** 非受控时的初始值;不传时取第一项 */
  defaultValue?: string
  onValueChange?: (value: string) => void
  /** 画在最左侧格子里的标签,同时作为单选组的可访问名称 */
  label?: React.ReactNode
  /**
   * 高度档:`bar` 与数值滑块默认同高,`compact` 是面板里用的中等高度
   * @default "bar"
   */
  variant?: SegmentedControlVariant
  /**
   * 尺寸档
   * @default "default"
   */
  size?: SegmentedControlSize
  /** 色调:选中块染上 `--flow-tone` 的淡色;不传时选中块用背景色加描边 */
  tone?: FlowTone
  /**
   * 标记为「已改动」:标签改用前景色,根元素带 `data-modified`
   * @default false
   */
  modified?: boolean
}

/**
 * 分段选择:一排互斥选项,选中块在选项之间滑动。
 * 语义是单选组(`role="radiogroup"`),方向键在选项间移动并直接选中。
 */
function SegmentedControl({
  options,
  value: valueProp,
  defaultValue,
  onValueChange,
  label,
  variant = "bar",
  size = "default",
  tone,
  modified = false,
  disabled,
  className,
  style,
  ...props
}: SegmentedControlProps) {
  const [value, setValue] = useControllableState<string>({
    prop: valueProp,
    defaultProp: defaultValue ?? options[0]?.value ?? "",
    onChange: onValueChange,
  })
  const labelId = React.useId()
  // 同一页里可能有多个分段选择,选中块的共享布局 id 必须各自独立
  const layoutId = React.useId()
  const reduced = useReducedMotion()
  const hasTone = tone !== undefined

  return (
    <RadioGroup
      {...props}
      value={value}
      onValueChange={(next) => setValue(next as string)}
      disabled={disabled}
      aria-labelledby={label !== undefined ? labelId : undefined}
      data-slot="segmented-control"
      data-variant={variant}
      data-modified={modified ? "" : undefined}
      className={cn(
        segmentedControlVariants({ size, variant }),
        "data-[disabled]:opacity-50",
        className,
      )}
      style={{ ...flowToneStyle(tone), ...style }}
    >
      {label !== undefined ? (
        <span
          id={labelId}
          data-slot="segmented-control-label"
          className="flex shrink-0 items-center ps-3 pe-2.5 font-medium whitespace-nowrap text-muted-foreground transition-colors group-data-[modified]/segmented-control:text-foreground"
        >
          {label}
        </span>
      ) : null}
      {/*
        有标签时,选项收进右侧一道微微下凹的槽里:左边是纯文字的字段名(与数值滑块、开关的标签同一种写法),
        右边才是可选的东西,标签不会被误认成第一个选项。没有标签时整条都是选项,不需要这道槽。
      */}
      <span
        data-slot="segmented-control-options"
        className={cn(
          "relative flex min-w-0 flex-1 items-stretch",
          label !== undefined &&
            "my-[3px] me-[3px] rounded-sm bg-foreground/5 dark:bg-foreground/10",
        )}
      >
        {options.map((option) => {
          const selected = option.value === value
          return (
            <Radio.Root
              key={option.value}
              value={option.value}
              disabled={option.disabled}
              data-slot="segmented-control-item"
              className="relative flex min-w-0 flex-auto cursor-pointer items-center justify-center gap-1.5 px-2 whitespace-nowrap text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:z-10 focus-visible:ring-3 focus-visible:ring-ring/50 data-[checked]:text-foreground data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50"
            >
              {selected ? (
                <motion.span
                  layoutId={layoutId}
                  aria-hidden="true"
                  data-slot="segmented-control-indicator"
                  className={cn(
                    "absolute inset-0.5 rounded-sm",
                    hasTone
                      ? "bg-(--flow-tone)/20 dark:bg-(--flow-tone)/35"
                      : "bg-background shadow-xs dark:bg-foreground/15",
                  )}
                  transition={
                    reduced
                      ? { duration: 0 }
                      : { type: "spring", bounce: 0.18, visualDuration: 0.25 }
                  }
                />
              ) : null}
              {option.icon ? (
                <span
                  aria-hidden="true"
                  className="relative flex shrink-0 items-center [&_svg:not([class*='size-'])]:size-3.5"
                >
                  {option.icon}
                </span>
              ) : null}
              <span className="relative truncate">{option.label}</span>
            </Radio.Root>
          )
        })}
      </span>
    </RadioGroup>
  )
}

export { SegmentedControl, segmentedControlVariants }
export type {
  SegmentedControlOption,
  SegmentedControlProps,
  SegmentedControlSize,
  SegmentedControlVariant,
}
hooks/use-controllable-state.ts
"use client"

import * as React from "react"

type UseControllableStateParams<T> = {
  /** 受控值;传入后组件不再维护内部状态 */
  prop?: T
  /** 非受控时的初始值 */
  defaultProp: T
  /** 值变化时回调(受控与非受控都会触发) */
  onChange?: (value: T) => void
}

/**
 * 同时支持受控与非受控的状态 hook。
 * 约定见 component-conventions 技能第 4 节:open/defaultOpen/onOpenChange 等成对 props 都用它实现。
 */
function useControllableState<T>({
  prop,
  defaultProp,
  onChange,
}: UseControllableStateParams<T>) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)
  const isControlled = prop !== undefined
  const value = isControlled ? prop : uncontrolled

  // 用 ref 保存最新的回调和值,保证 setValue 引用稳定
  const onChangeRef = React.useRef(onChange)
  const valueRef = React.useRef(value)
  React.useEffect(() => {
    onChangeRef.current = onChange
    valueRef.current = value
  })

  const setValue = React.useCallback(
    (next: React.SetStateAction<T>) => {
      const resolved =
        typeof next === "function"
          ? (next as (prev: T) => T)(valueRef.current)
          : next
      if (Object.is(resolved, valueRef.current)) return
      valueRef.current = resolved
      if (!isControlled) setUncontrolled(resolved)
      onChangeRef.current?.(resolved)
    },
    [isControlled],
  )

  return [value, setValue] as const
}

export { useControllableState }