"use client"
import * as React from "react"
import { Check, Share2 } from "lucide-react"
import { flowToneStyle } from "@/components/ui/flow-diagram/tone"
import { TonePicker, toneOptions } from "@/components/ui/tone-picker/tone-picker"
// 内置色调里取五个,色点排一行刚好
const TONES = toneOptions(["primary", "blue", "green", "amber", "violet"])
/**
* 默认示例:给发布卡片挑强调色。色点换一个,下面卡片上的竖条、图标块、进度与徽章立刻跟着换,
* 预览区自己拿 `--flow-tone` 取色,与色点用的是同一份主题变量。
*/
export default function TonePickerDemo() {
const [tone, setTone] = React.useState("violet")
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<TonePicker
label="强调色"
options={TONES}
value={tone}
onValueChange={setTone}
/>
<div
className="relative mt-3 overflow-hidden rounded-lg border border-edge bg-muted/20"
style={flowToneStyle(tone)}
>
<span
aria-hidden="true"
className="absolute inset-y-0 start-0 w-1 bg-(--flow-tone)"
/>
<div className="p-3 ps-4">
<div className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="grid size-9 shrink-0 place-items-center rounded-lg bg-(--flow-tone)/15 text-(--flow-tone) ring-1 ring-(--flow-tone)/30 ring-inset"
>
<Check className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">v2.4.0 已发布</p>
<p className="truncate text-[11px] text-muted-foreground">
静态产物已同步到最后一次提交
</p>
</div>
<span className="flex shrink-0 items-center gap-1 rounded-full bg-(--flow-tone)/15 px-2 py-0.5 text-[11px] text-foreground ring-1 ring-(--flow-tone)/30 ring-inset dark:bg-(--flow-tone)/25">
<span
aria-hidden="true"
className="size-1.5 rounded-full bg-(--flow-tone)"
/>
稳定版
</span>
</div>
<div className="mt-3 flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-foreground/10">
<span className="block h-full w-3/5 rounded-full bg-(--flow-tone)" />
</span>
<span className="shrink-0 font-mono">3 / 5</span>
</div>
<div className="mt-3 flex items-center gap-2">
<span className="inline-flex h-7 items-center gap-1 rounded-md border border-edge bg-background px-2.5 text-xs font-medium">
<Share2 className="size-3.5" aria-hidden="true" />
分享更新
</span>
<span className="text-[11px] text-muted-foreground">24 分钟前</span>
</div>
</div>
</div>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/tone-picker.json用法
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:
variant | sm | default | lg |
|---|---|---|---|
bar | 28px | 32px | 36px |
compact | 18px | 22px | 24px |
标签省略时色点推到最右(ms-auto),此时务必给 aria-label——单选组本身也需要一个名字。
示例
自定义选项
标签的强调色。第一项 tone: null 画成虚线空心圆,表示「跟随上级」;最后一项用 var(--flow-cyan) 演示任意 CSS 颜色也能当色点。
"use client"
import * as React from "react"
import { Tag } from "lucide-react"
import { flowToneStyle, type FlowTone } from "@/components/ui/flow-diagram/tone"
import { TonePicker } from "@/components/ui/tone-picker/tone-picker"
/**
* 自定义选项:`tone` 写内置色调名,也可以写任意 CSS 颜色(这里用主题变量,不写死色值);
* `tone: null` 画成虚线空心圆,表示「跟着上级走」。
*/
const OPTIONS = [
{ value: "inherit", label: "跟随上级", tone: null },
{ value: "primary", label: "主题色", tone: "primary" as FlowTone },
{ value: "blue", label: "蓝", tone: "blue" as FlowTone },
{ value: "rose", label: "玫红", tone: "rose" as FlowTone },
{ value: "mint", label: "青绿(主题变量)", tone: "var(--flow-cyan)" },
] as const
/**
* 自定义选项示例:标签的强调色。选「跟随上级」时标签退回中性灰,
* 其余选项用各自的色点;最后一个用 `var(--flow-cyan)` 演示任意 CSS 颜色也能当色点。
*/
export default function TonePickerCustomDemo() {
const [tone, setTone] = React.useState<string>("inherit")
const inherited = tone === "inherit"
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2 pb-3">
<p className="text-sm font-medium">标签样式</p>
<p className="text-xs text-muted-foreground">v2.4.0</p>
</div>
<TonePicker
label="强调色"
options={OPTIONS}
value={tone}
onValueChange={setTone}
/>
<div className="mt-3 flex flex-wrap items-center gap-1.5 rounded-lg border border-edge bg-muted/20 p-3">
<span
className={
inherited
? "inline-flex items-center gap-1 rounded-md border border-edge bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground"
: "inline-flex items-center gap-1 rounded-md bg-(--flow-tone)/15 px-1.5 py-0.5 text-[11px] text-foreground ring-1 ring-(--flow-tone)/35 ring-inset dark:bg-(--flow-tone)/25"
}
style={inherited ? undefined : flowToneStyle(tone)}
>
<Tag className="size-3" aria-hidden="true" />
{inherited ? "跟随上级" : "已上线"}
</span>
<span className="inline-flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
<Tag className="size-3" aria-hidden="true" />
文档
</span>
<p className="w-full pt-1 text-[11px] text-muted-foreground">
{inherited
? "没有自己的颜色,用上级容器的样式。"
: "这一枚标签有了自己的强调色。"}
</p>
</div>
</div>
)
}
多行
图表三组数据的配色。variant="compact" 让三条色点选择器排成一列也不占地方,每条只管自己那一组;上面的柱状图读同一份 --flow-tone,改完立刻换色。
"use client"
import * as React from "react"
import { flowToneStyle, type FlowTone } from "@/components/ui/flow-diagram/tone"
import { TonePicker, toneOptions } from "@/components/ui/tone-picker/tone-picker"
const TONES = toneOptions()
const SERIES = [
{
id: "visits",
label: "访问",
tone: "blue" as FlowTone,
values: [42, 58, 51, 74],
},
{
id: "signups",
label: "注册",
tone: "violet" as FlowTone,
values: [28, 36, 44, 52],
},
{
id: "orders",
label: "下单",
tone: "amber" as FlowTone,
values: [18, 22, 26, 31],
},
] as const
type SeriesId = (typeof SERIES)[number]["id"]
const QUARTERS = ["Q1", "Q2", "Q3", "Q4"]
const INITIAL = Object.fromEntries(
SERIES.map((item) => [item.id, item.tone as string]),
) as Record<SeriesId, string>
/**
* 多行示例:图表三组数据的配色。`variant="compact"` 让三条色点选择器排成一列也不占地方,
* 每条只管自己那一组;上面的分组柱状图读同一份 `--flow-tone`,改完立刻换色。
*/
export default function TonePickerSeriesDemo() {
const [colors, setColors] = React.useState(INITIAL)
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2">
<p className="text-sm font-medium">季度明细</p>
<p className="font-mono text-[11px] text-muted-foreground">2026</p>
</div>
<div className="mt-3 rounded-lg border border-edge bg-muted/20 p-2">
<div className="grid h-24 grid-cols-4 gap-2">
{QUARTERS.map((quarter, index) => (
<div
key={quarter}
className="flex items-end justify-center gap-0.5"
>
{SERIES.map((series) => (
<span
key={series.id}
className="w-2 rounded-t-[2px] bg-(--flow-tone)"
style={{
...flowToneStyle(colors[series.id]),
height: `${series.values[index]}%`,
}}
/>
))}
</div>
))}
</div>
<div className="mt-1.5 grid grid-cols-4 gap-2">
{QUARTERS.map((quarter) => (
<span
key={quarter}
className="text-center font-mono text-[10px] text-muted-foreground"
>
{quarter}
</span>
))}
</div>
</div>
<div className="mt-3 flex flex-col gap-1.5">
{SERIES.map((series) => (
<TonePicker
key={series.id}
variant="compact"
label={series.label}
options={TONES}
value={colors[series.id]}
modified={colors[series.id] !== series.tone}
onValueChange={(tone) =>
setColors((current) => ({ ...current, [series.id]: tone }))
}
/>
))}
</div>
</div>
)
}
API
TonePicker
可选的色调;不传时是主题色加六种多彩色调
类型readonly TonePickerOption[] | undefinedstring | undefined非受控时的初始值;不传时取第一项
类型string | undefined((value: string) => void) | undefined左侧标签,同时作为单选组的可访问名称
类型React.ReactNode高度档:bar 与数值滑块默认同高,compact 是面板里用的中等高度
TonePickerVariant | undefined"bar"尺寸档
类型TonePickerSize | undefined"default"标记为「已改动」:标签改用前景色,根元素带 data-modified
boolean | undefinedfalse除上表外,其余属性直接交给 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变化后如果当前值已不存在,组件不会自动改选,请在父级同步。
源码
"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,
}
"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 }