跳至正文
组件

Tone Picker色调选择

方条左侧标签、右侧一排色点的单选组,色点取主题变量,跟随亮暗与颜色主题。

正在加载…

安装

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

用法

components/accent-picker.tsx
import { useState } from "react"
import { TonePicker } from "@/components/ui/tone-picker/tone-picker"

export function AccentPicker() {
  const [tone, setTone] = useState("primary")

  return <TonePicker label="强调色" value={tone} onValueChange={setTone} />
}

不传 options 时用内置的七个色调:主题色、蓝、绿、琥珀、玫红、紫、青,色点旁的名称也一并带上。value 存的是选项的 value(字符串),拿到之后自己去拼 style={{ "--flow-tone": ... }} 或套组件的 tone 属性。

选项与色点

每个选项有三个字段:

字段说明
value提交给 onValueChange 的值,字符串
label可访问名称(aria-label)与悬停提示(title),必填
tone色点颜色:内置色调名(primary、blue…)、任意 CSS 颜色,或 null

tone: null 画成虚线空心圆,通常表示「不上色」「跟随上级」这类不指定颜色的选项。任意 CSS 颜色都行,写 var(--flow-cyan)、oklch(…)、#f97316 都可以;想要跟随颜色主题,就写主题变量。

toneOptions(tones) 把一串色调名变成选项数组,名称取内置的中文名表(primary 主题色、foreground 前景色、blue 蓝、green 绿、amber 琥珀、rose 玫红、violet 紫、cyan 青),不在表里的名字原样用作名称。

import { toneOptions } from "@/components/ui/tone-picker/tone-picker"

const TONES = toneOptions(["primary", "blue", "violet"])
// [{ value: "primary", label: "主题色", tone: "primary" }, …]

高度

variant 与 size 的对照表和数值滑块一致;色点大小跟着档位走,sm 12px、default 14px、lg 16px,compact 统一 10px:

variantsmdefaultlg
bar28px32px36px
compact18px22px24px

标签省略时色点推到最右(ms-auto),此时务必给 aria-label——单选组本身也需要一个名字。

示例

自定义选项

标签的强调色。第一项 tone: null 画成虚线空心圆,表示「跟随上级」;最后一项用 var(--flow-cyan) 演示任意 CSS 颜色也能当色点。

正在加载…

多行

图表三组数据的配色。variant="compact" 让三条色点选择器排成一列也不占地方,每条只管自己那一组;上面的柱状图读同一份 --flow-tone,改完立刻换色。

正在加载…

API

TonePicker

除上表外,其余属性直接交给 Base UI 的 RadioGroup.Root:disabled、aria-label、required、orientation 等。

toneOptions

function toneOptions(tones?: readonly FlowTone[]): TonePickerOption[]

不传 tones 时返回默认的七项。名称取自内置的中文名表,表里没有的名字直接当名称用。

tonePickerVariants

cva 生成的根元素样式函数,有 size 与 variant 两个变体。高度通过根元素的 --tone-picker-height、色点直径通过 --tone-picker-swatch 传给内部。

const tonePickerVariants: (props?: {
  size?: TonePickerSize
  variant?: TonePickerVariant
}) => string

类型

type TonePickerSize = "sm" | "default" | "lg"

type TonePickerVariant = "bar" | "compact"

type TonePickerOption = {
  value: string
  /** 可访问名称,悬停时也作为提示 */
  label: string
  /** 内置色调名或任意 CSS 颜色;`null` 画成空心圆 */
  tone: FlowTone | null
}

type TonePickerProps = Omit<
  React.ComponentProps<typeof RadioGroup>,
  "value" | "defaultValue" | "onValueChange" | "children"
> & {
  options?: readonly TonePickerOption[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  label?: React.ReactNode
  variant?: TonePickerVariant
  size?: TonePickerSize
  modified?: boolean
  className?: string
}

data-slot

元素data-slot
根元素tone-picker
左侧标签tone-picker-label
色点容器tone-picker-swatches
色点tone-picker-swatch

根元素始终带 data-variant(bar / compact),modified 为真时带 data-modified;色点上是 Base UI 给的 data-checked,data-disabled 落在根元素上。

可访问性

  • 语义是单选组:根元素 role="radiogroup",每个色点 role="radio"。方向键在色点间移动并直接选中。
  • 每个色点必须有名称,label 同时写进 aria-label 与 title。颜色不是唯一的信息载体——不要写只有色点、没有名称的选项。
  • 选中状态除了名称还有两层视觉:色点外一圈前景色细环(ring-2 加 2px 偏移),色点本身在悬停时放大 10%。
  • 组的名称取自 label(aria-labelledby);省略 label 时用 aria-label 补上。
  • 焦点环只在键盘聚焦时出现(focus-visible)。

注意事项

  • 色点颜色来自主题变量,亮暗与颜色主题切换时会跟着变;要固定成品牌色就写任意 CSS 颜色。
  • modified 只有配 label 时才有可见效果(标签从次要色变成前景色);没有 label 时只留下 data-modified。
  • 色点数量超过七、八个时一行会挤,考虑换成下拉或分成两行;色点直径不随容器缩放。
  • 受控 / 非受控共用一个 useControllableState;options 变化后如果当前值已不存在,组件不会自动改选,请在父级同步。

源码

components/ui/tone-picker/tone-picker.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 { 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 tonePickerVariants = cva(
  "group/tone-picker flex h-(--tone-picker-height) w-full min-w-0 items-center justify-between gap-3 rounded-md border border-edge bg-muted/60 px-3 select-none",
  {
    variants: {
      size: {
        sm: "text-xs [--tone-picker-swatch:0.75rem]",
        default: "text-sm [--tone-picker-swatch:0.875rem]",
        lg: "text-sm [--tone-picker-swatch:1rem]",
      },
      variant: {
        bar: "",
        compact: "text-xs [--tone-picker-swatch:0.625rem]",
      },
    },
    compoundVariants: [
      { variant: "bar", size: "sm", class: "[--tone-picker-height:1.75rem]" },
      { variant: "bar", size: "default", class: "[--tone-picker-height:2rem]" },
      { variant: "bar", size: "lg", class: "[--tone-picker-height:2.25rem]" },
      {
        variant: "compact",
        size: "sm",
        class: "[--tone-picker-height:1.125rem]",
      },
      {
        variant: "compact",
        size: "default",
        class: "[--tone-picker-height:1.375rem]",
      },
      {
        variant: "compact",
        size: "lg",
        class: "[--tone-picker-height:1.5rem]",
      },
    ],
    defaultVariants: {
      size: "default",
      variant: "bar",
    },
  },
)

type TonePickerSize = "sm" | "default" | "lg"
type TonePickerVariant = "bar" | "compact"

type TonePickerOption = {
  value: string
  /** 可访问名称,悬停时也作为提示 */
  label: string
  /** 色点的颜色:内置色调名或任意 CSS 颜色;`null` 画成空心圆(表示「不上色」「沿用」这类选项) */
  tone: FlowTone | null
}

/** 内置色调的中文名 */
const TONE_LABELS: Record<string, string> = {
  primary: "主题色",
  foreground: "前景色",
  blue: "蓝",
  green: "绿",
  amber: "琥珀",
  rose: "玫红",
  violet: "紫",
  cyan: "青",
}

const DEFAULT_TONES: FlowTone[] = [
  "primary",
  "blue",
  "green",
  "amber",
  "rose",
  "violet",
  "cyan",
]

/** 把色调名列表变成选项;`TonePicker` 不传 `options` 时用的就是它 */
function toneOptions(tones: readonly FlowTone[] = DEFAULT_TONES) {
  return tones.map<TonePickerOption>((tone) => ({
    value: tone,
    label: TONE_LABELS[tone] ?? tone,
    tone,
  }))
}

type TonePickerProps = Omit<
  React.ComponentProps<typeof RadioGroup>,
  "value" | "defaultValue" | "onValueChange" | "children"
> & {
  /** 可选的色调;不传时是主题色加六种多彩色调 */
  options?: readonly TonePickerOption[]
  value?: string
  /** 非受控时的初始值;不传时取第一项 */
  defaultValue?: string
  onValueChange?: (value: string) => void
  /** 左侧标签,同时作为单选组的可访问名称 */
  label?: React.ReactNode
  /**
   * 高度档:`bar` 与数值滑块默认同高,`compact` 是面板里用的中等高度
   * @default "bar"
   */
  variant?: TonePickerVariant
  /**
   * 尺寸档
   * @default "default"
   */
  size?: TonePickerSize
  /**
   * 标记为「已改动」:标签改用前景色,根元素带 `data-modified`
   * @default false
   */
  modified?: boolean
}

/**
 * 色调选择:方条左侧标签、右侧一排色点,单选语义,方向键切换。
 * 色点颜色都来自主题变量,亮暗与颜色主题切换时跟着变。
 */
function TonePicker({
  options: optionsProp,
  value: valueProp,
  defaultValue,
  onValueChange,
  label,
  variant = "bar",
  size = "default",
  modified = false,
  className,
  ...props
}: TonePickerProps) {
  const options = optionsProp ?? toneOptions()
  const [value, setValue] = useControllableState<string>({
    prop: valueProp,
    defaultProp: defaultValue ?? options[0]?.value ?? "",
    onChange: onValueChange,
  })
  const labelId = React.useId()

  return (
    <RadioGroup
      {...props}
      value={value}
      onValueChange={(next) => setValue(next as string)}
      aria-labelledby={label !== undefined ? labelId : undefined}
      data-slot="tone-picker"
      data-variant={variant}
      data-modified={modified ? "" : undefined}
      className={cn(
        tonePickerVariants({ size, variant }),
        "data-[disabled]:opacity-50",
        className,
      )}
    >
      {label !== undefined ? (
        <span
          id={labelId}
          data-slot="tone-picker-label"
          className="truncate font-medium text-muted-foreground transition-colors group-data-[modified]/tone-picker:text-foreground"
        >
          {label}
        </span>
      ) : null}
      <span
        data-slot="tone-picker-swatches"
        className="ms-auto flex shrink-0 items-center gap-1.5"
      >
        {options.map((option) => (
          <Radio.Root
            key={option.value}
            value={option.value}
            aria-label={option.label}
            title={option.label}
            data-slot="tone-picker-swatch"
            className={cn(
              "relative size-(--tone-picker-swatch) shrink-0 cursor-pointer rounded-full transition-transform outline-none hover:scale-110 focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none motion-reduce:hover:scale-100",
              // 选中:外面再套一圈前景色细环,和色点之间隔一道背景色缝
              "data-[checked]:ring-2 data-[checked]:ring-foreground/70 data-[checked]:ring-offset-2 data-[checked]:ring-offset-background",
              option.tone === null
                ? "border border-dashed border-muted-foreground/60"
                : "bg-(--flow-tone)",
            )}
            style={flowToneStyle(option.tone ?? undefined)}
          />
        ))}
      </span>
    </RadioGroup>
  )
}

export { TonePicker, tonePickerVariants, toneOptions }
export type {
  TonePickerOption,
  TonePickerProps,
  TonePickerSize,
  TonePickerVariant,
}
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 }