"use client"
import * as React from "react"
import {
Flame,
Snowflake,
Sun,
SunDim,
Volume1,
Volume2,
VolumeX,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
ValueSlider,
useValueSliderMuted,
} from "@/components/ui/value-slider/value-slider"
const PERCENT: Intl.NumberFormatOptions = { style: "unit", unit: "percent" }
const KELVIN = (value: number) => `${value} K`
/** 音量图标按档位换:静音、低、高 */
function volumeIcon(value: number) {
if (value === 0) return <VolumeX />
return value < 50 ? <Volume1 /> : <Volume2 />
}
/** 音效开关:静音偏好存在本地,全站带音效的组件共用 */
function SoundToggle() {
const [muted, setMuted] = useValueSliderMuted()
const label = muted ? "打开拖动音效" : "关闭拖动音效"
return (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-pressed={!muted}
aria-label={label}
title={label}
onClick={() => setMuted(!muted)}
>
{muted ? <VolumeX /> : <Volume2 />}
</Button>
)
}
/** 默认示例:一条「显示与声音」设置卡,三个滑块各用一种色调。 */
export default function ValueSliderDemo() {
const [brightness, setBrightness] = React.useState(64)
const [volume, setVolume] = React.useState(40)
// 右侧的太阳跟着亮度长大:14px(size-3.5)到 20px(size-5)
const sunSize = Math.round(14 + (brightness / 100) * 6)
return (
<div className="relative flex h-80 w-full items-center justify-center overflow-hidden bg-dots px-4">
{/* 主题色光晕:从卡片下面往外晕开,颜色随颜色主题变化 */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_55%_45%_at_50%_55%,color-mix(in_oklab,var(--primary)_18%,transparent),transparent_72%)]"
/>
<div className="relative w-full max-w-sm rounded-xl border border-edge bg-background p-5 shadow-sm">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">显示与声音</p>
<SoundToggle />
</div>
<div className="mt-3 flex flex-col gap-4">
<div className="flex h-10 items-center">
<ValueSlider
label="亮度"
size="lg"
tone="amber"
value={brightness}
onValueChange={setBrightness}
format={PERCENT}
startIcon={<SunDim />}
// 右侧图标都放进同宽插槽:太阳长大时轨道宽度不变,三条轨道也对齐
endIcon={
<span className="grid size-5 place-items-center">
<Sun style={{ width: sunSize, height: sunSize }} />
</span>
}
/>
</div>
<div className="flex h-10 items-center">
<ValueSlider
label="音量"
size="lg"
tone="blue"
value={volume}
onValueChange={setVolume}
format={PERCENT}
startIcon={volumeIcon(volume)}
endIcon={
<span className="grid size-5 place-items-center">
<Volume2 />
</span>
}
sound
/>
</div>
<div className="flex h-10 items-center">
<ValueSlider
label="色温"
size="lg"
tone="cyan"
min={2700}
max={6500}
step={100}
largeStep={500}
defaultValue={4500}
ticks={4}
snapToTicks
formatValue={KELVIN}
startIcon={<Flame />}
endIcon={
<span className="grid size-5 place-items-center">
<Snowflake />
</span>
}
/>
</div>
</div>
</div>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/value-slider.json用法
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
export function VolumeSlider() {
return <ValueSlider label="音量" defaultValue={40} />
}不带状态时用 defaultValue 给初始值(不传就落在 min),组件自己管;需要读值就加 value 与 onValueChange 走受控。轨道本身即控件:点轨道跳转、按住手柄拖动,键盘也能操作。
标签与读数
labelPlacement="inside"(默认)时 label 渲染在轨道左端内部,右侧读数是格式化后的当前值,两者都是 aria-hidden,真正的语义挂在手柄上。手柄靠近文字时自动变淡,文字不会被挡住。showValue={false} 只留标签,format 或 formatValue 决定读数长什么样。
<ValueSlider
label="亮度"
defaultValue={64}
format={{ style: "unit", unit: "percent" }}
/>labelPlacement="outside" 把标签与读数移到轨道上方一行:左侧标签、右侧读数,轨道里不放文字,手柄常驻可见也不再让位。区间这类要同时看清两个手柄的场景用它更合适。variant="slim" 的轨道放不下文字,固定按这一档处理,labelPlacement 在它下面不生效。
<ValueSlider
label="价格"
labelPlacement="outside"
min={0}
max={2000}
step={50}
defaultValue={[200, 1200]}
/>弹性过冲
往轨道外继续拖时,轨道按饱和的橡皮筋曲线被拉伸:越往外越难拉,最大位移是 elasticity 像素(默认 12),松手时用弹簧回到原位,两端的图标跟着轻轻一推。键盘在手柄已经位于端点时继续按同一个方向,也会拉一下再弹回来,让人知道已经到头。elasticity={0} 完全关掉。
拉伸只做在轨道内部的视觉层上,Slider.Control 自己不缩放,所以把指针拖出右端再拉回轨道中间,值仍然连续变化,不会跳。换边(从一端外面直接拖到另一端外面)时缩放原点固定在中心,只改平移量,同样不会跳。
<ValueSlider label="音量" defaultValue={40} elasticity={24} />刻度与磁吸
ticks 传数字表示把范围等分成几段,画 n - 1 条内部刻度线;传数组表示在指定值处画线。它同时是 snapToTicks 的目标:指针松手时(拖动或点击轨道)如果与最近刻度相差不到整个范围的 3%,值会吸过去;键盘逐步移动不吸附,所以方向键可以从刻度上走开,不会被反复拉回同一个点。刻度线用逻辑属性定位,RTL 下跟着手柄与填充一起从右边排布。
刻度建议落在 step 上。不在 step 上的刻度会被对齐到最近的 step 再提交,避免出现 3650 这种步长之外的数值;区间模式吸附后还要保证两个值顺序不变、且至少相隔 minStepsBetweenValues 个 step,不满足就放弃那次吸附。
<ValueSlider
label="色温"
min={2700}
max={6500}
step={100}
largeStep={500}
defaultValue={4500}
ticks={4}
snapToTicks
formatValue={(value) => `${value} K`}
/>缓冲进度
buffer 是「已缓冲到的值」——比如视频已经加载到第 N 秒。它会在已选填充下面再画一段从 min 到 buffer 的次级填充,颜色比已选填充淡一档,用来区分「已加载」与「已播放」。只有单值模式生效,区间模式会忽略它。
<ValueSlider
aria-label="播放进度"
min={0}
max={214}
defaultValue={62}
buffer={102}
/>格式化
format 是 Intl.NumberFormat 的选项,locale 默认 zh-CN,读数的小数位默认按 step 推导(0.5 显示一位)。formatValue 优先于 format,返回的字符串同时用于右侧读数与手柄的 aria-valuetext,适合加单位、正负号或把数字换成词语。
<ValueSlider
label="曝光"
min={-100}
max={100}
defaultValue={0}
formatValue={(value) => (value > 0 ? `+${value}` : String(value))}
/>直接输入
读数平时是文字,editable 让它变成入口:点一下读数,或在手柄上按回车,原地换成一个输入框,读数变成可编辑的虚线。回车或失焦提交,Esc 放弃;输入里的千分位、单位之类会被忽略,只取数字部分,再夹到 min–max 之间并对齐到 step。回车提交后焦点还给手柄,键盘用户可以接着用方向键微调。
<ValueSlider
label="宽度"
min={480}
max={3840}
step={40}
editable
defaultValue={1920}
formatValue={(value) => `${value}px`}
/>一次输入当作一次提交(触发 onValueCommitted),不放刻度音效,也不经过中间的过渡值。区间模式下不可输入(输入框只有一个,说不清改哪个手柄),disabled 时也不可输入。
modified 是给控制面板用的标记:传 true 时标签与读数从次要色提到前景色,根元素带上 data-modified。它不改变值,只是「这一项已改动」的视觉与钩子,通常写成 modified={value !== defaultValue}。
<ValueSlider
label="高度"
editable
modified={height !== 1080}
value={height}
onValueChange={setHeight}
/>区间
值传数组即为区间:数组长度决定手柄数量,读数显示为「起点 – 终点」。数组在内部按升序处理,label 是字符串时手柄名称自动加上「起点」「终点」。两个手柄相遇时的行为由 Base UI 的 thumbCollisionBehavior 决定(默认 push),最小间隔由 minStepsBetweenValues 控制。
<ValueSlider
label="价格"
min={0}
max={2000}
step={50}
defaultValue={[200, 1200]}
format={{ style: "currency", currency: "CNY", maximumFractionDigits: 0 }}
/>音效
sound 打开音效:值变化时(拖动、点击轨道、键盘)播放刻度音,碰到两端播放碰边音。音效默认关闭,浏览器要求先有用户操作才能出声,所以音频在第一次交互时才初始化。
import { useValueSliderMuted } from "@/components/ui/value-slider/value-slider"
const [muted, setMuted] = useValueSliderMuted()
<ValueSlider label="音量" defaultValue={40} sound />
<button type="button" onClick={() => setMuted(!muted)}>
{muted ? "打开音效" : "静音"}
</button>静音偏好与站内其他带音效的组件共用(localStorage 的 travinsy:sound:muted):同一页面里一处静音,其他组件的静音按钮与音效立刻跟上;换到另一个标签页也会同步。
示例
每个示例的标题栏右侧都有「调整」开关:打开后可以实时调整过冲强度、尺寸、高度档、色调、刻度和磁吸,并开关读数与音效;未改动的项显示示例自己的取值。换尺寸档时,把滑块放进固定高度插槽的示例画框不会跳;换成 slim 时标签与读数会从轨道里移到上方、额外占一行,画框可能随之变高。
轨道高度
variant 是三档高度,外观都是同一种方条(细边框、淡色分层填充、竖线手柄)。默认的 bar 最高,标签与读数画在轨道里;compact 是中等高度,文字仍在轨道里、字号收到 xs,适合工具面板里一排排的参数;slim 是矮轨,标签与读数移到轨道上方,竖线手柄比轨道高出一截。下面这张画笔卡片里,笔刷大小与不透明度用 variant="compact",平滑度用 variant="slim" 并带上刻度(ticks={4})。
"use client"
import * as React from "react"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
/** 像素单位与百分比的读数格式,定义在组件外,避免每次渲染重建 */
const formatPixels = (value: number) => `${value}px`
const formatPercent = (value: number) => `${value}%`
/** 画笔轨迹的采样点(viewBox 300 × 80)。固定值,每次构建都一样 */
const POINTS = [
[20, 50],
[65, 26],
[110, 54],
[155, 24],
[200, 54],
[245, 26],
[285, 48],
] as const
/**
* 把采样点连成三次贝塞尔:`tension` 取 0 时控制点落在端点上,画出来是折线;
* 取 1 时是 Catmull-Rom 的标准切线,拐角最软。平滑度滑块改的就是这个系数。
*/
function brushPath(tension: number) {
const step = (Math.min(100, Math.max(0, tension)) / 100) * (1 / 6)
let d = `M${POINTS[0][0]} ${POINTS[0][1]}`
for (let index = 0; index < POINTS.length - 1; index += 1) {
const previous = POINTS[index - 1] ?? POINTS[index]
const start = POINTS[index]
const end = POINTS[index + 1]
const next = POINTS[index + 2] ?? end
// 固定一位小数:坐标在服务端与客户端算出的字符串完全一致
const c1x = (start[0] + (end[0] - previous[0]) * step).toFixed(1)
const c1y = (start[1] + (end[1] - previous[1]) * step).toFixed(1)
const c2x = (end[0] - (next[0] - start[0]) * step).toFixed(1)
const c2y = (end[1] - (next[1] - start[1]) * step).toFixed(1)
d += ` C${c1x} ${c1y} ${c2x} ${c2y} ${end[0]} ${end[1]}`
}
return d
}
/**
* 轨道高度示例:一张画笔设置卡片,笔刷大小与不透明度用 `compact`(中等高度,文字仍在轨道里),
* 平滑度用 `slim`(矮轨 + 刻度,文字在上方)。三条滑块都带 `tone`,填充与手柄跟着色调走。
*/
export default function ValueSliderVariantDemo() {
const [brush, setBrush] = React.useState(12)
const [opacity, setOpacity] = React.useState(85)
const [smoothness, setSmoothness] = React.useState(68)
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-5 shadow-sm">
<div className="flex items-baseline justify-between gap-2">
<p className="text-sm font-medium">画笔设置</p>
<p className="text-xs text-muted-foreground">铅笔 · 软头</p>
</div>
<div className="mt-3 grid h-20 place-items-center overflow-hidden rounded-lg border border-edge bg-muted/30">
<svg viewBox="0 0 300 80" aria-hidden="true" className="size-full">
<path
d={brushPath(smoothness)}
fill="none"
stroke="currentColor"
strokeWidth={brush}
strokeLinecap="round"
strokeLinejoin="round"
strokeOpacity={opacity / 100}
className="text-foreground"
/>
</svg>
</div>
{/* 每条滑块占固定高度:调整面板换尺寸时卡片高度不变(换到 slim 时文字移到上方,会多占一行) */}
<div className="mt-4 flex flex-col gap-2">
<div className="flex h-8 items-center">
<ValueSlider
label="笔刷大小"
variant="compact"
tone="amber"
min={1}
max={24}
step={1}
value={brush}
onValueChange={setBrush}
formatValue={formatPixels}
/>
</div>
<div className="flex h-8 items-center">
<ValueSlider
label="不透明度"
variant="compact"
tone="violet"
value={opacity}
onValueChange={setOpacity}
formatValue={formatPercent}
/>
</div>
<div className="flex h-12 items-center">
<ValueSlider
label="平滑度"
variant="slim"
tone="cyan"
value={smoothness}
onValueChange={setSmoothness}
formatValue={formatPercent}
ticks={4}
/>
</div>
</div>
</div>
)
}
音量与音效
sound 打开拖动时的刻度音效,startIcon 随值在三个喇叭图标之间切换,旁边的静音入口用 useValueSliderMuted 读写同一份偏好。
"use client"
import * as React from "react"
import { Volume1, Volume2, VolumeX } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
ValueSlider,
useValueSliderMuted,
} from "@/components/ui/value-slider/value-slider"
const PERCENT: Intl.NumberFormatOptions = { style: "unit", unit: "percent" }
/** 静音入口:和滑块共用同一份静音偏好,点一下全站的滑块都不再出声 */
function SoundToggle({
muted,
onChange,
}: {
muted: boolean
onChange: (muted: boolean) => void
}) {
const label = muted ? "打开拖动音效" : "关闭拖动音效"
return (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-pressed={!muted}
aria-label={label}
title={label}
onClick={() => onChange(!muted)}
>
{muted ? <VolumeX /> : <Volume2 />}
</Button>
)
}
/** 受控音量:起点图标随音量换档,拖动时每一步都有刻度音效 */
export default function ValueSliderVolumeDemo() {
const [volume, setVolume] = React.useState(60)
const [muted, setMuted] = useValueSliderMuted()
return (
<div className="w-full max-w-xs rounded-xl border border-edge bg-background p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">输出音量</p>
<p className="text-xs text-muted-foreground">拖动刻度会发出轻响</p>
</div>
<SoundToggle muted={muted} onChange={setMuted} />
</div>
<div className="mt-4">
<ValueSlider
label="音量"
tone="primary"
value={volume}
onValueChange={setVolume}
format={PERCENT}
sound
startIcon={
volume === 0 ? <VolumeX /> : volume < 50 ? <Volume1 /> : <Volume2 />
}
/>
</div>
<p className="mt-3 text-xs text-muted-foreground">
{muted ? "音效已静音,偏好记在本机" : "音效已打开,静音偏好记在本机"}
</p>
</div>
)
}
价格区间
数组值给两个手柄,labelPlacement="outside" 让两个手柄常驻可见,format 用 Intl 的货币格式;上方的柱子落在区间内时取主题色,范围外的退成灰色。
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
/** 每根柱子是一个 100 元档位的商品数,数据固定,每次构建都一样 */
const BUCKETS = [
2, 5, 9, 16, 25, 33, 30, 22, 17, 21, 26, 31, 24, 18, 12, 9, 6, 4, 2, 1,
]
const BUCKET_WIDTH = 100
const TALLEST = Math.max(...BUCKETS)
const TOTAL = BUCKETS.reduce((sum, count) => sum + count, 0)
const CURRENCY: Intl.NumberFormatOptions = {
style: "currency",
currency: "CNY",
maximumFractionDigits: 0,
}
/** 数组值即区间:两个手柄各自拖动,上方的柱状图跟着高亮 */
export default function ValueSliderRangeDemo() {
// 只用来画图与统计,滑块自己管值(非受控)
const [range, setRange] = React.useState<number[]>([200, 1200])
const [low, high] = range
const picked = BUCKETS.reduce((sum, count, index) => {
const start = index * BUCKET_WIDTH
return start >= low && start + BUCKET_WIDTH <= high ? sum + count : sum
}, 0)
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-sm">
<div className="flex items-baseline justify-between gap-2">
<p className="text-sm font-medium">价格分布</p>
<p className="text-xs text-muted-foreground">全场 {TOTAL} 件</p>
</div>
<div aria-hidden="true" className="mt-3 border-b border-edge pb-px">
<div className="flex h-16 items-end gap-1">
{BUCKETS.map((count, index) => {
const start = index * BUCKET_WIDTH
const active = start >= low && start + BUCKET_WIDTH <= high
return (
<span
key={start}
className={cn(
"w-full rounded-t-[2px]",
active ? "bg-primary" : "bg-muted-foreground/20",
)}
style={{ height: `${(count / TALLEST) * 100}%` }}
/>
)
})}
</div>
</div>
<div className="mt-1 flex justify-between text-[0.7rem] text-muted-foreground">
<span>¥0</span>
<span>¥2,000</span>
</div>
<div className="mt-4 flex h-14 items-center">
{/* 标签放到轨道上方:两个手柄常驻显示,轨道内不放文字 */}
<ValueSlider
label="价格"
labelPlacement="outside"
tone="primary"
min={0}
max={2000}
step={50}
largeStep={500}
defaultValue={[200, 1200]}
onValueChange={(next) => setRange([...next])}
format={CURRENCY}
/>
</div>
<p className="mt-3 text-xs text-muted-foreground">
所选区间共 {picked} 件商品
</p>
</div>
)
}
图片调色
三条滑块实时改 CSS filter 的 brightness、contrast、saturate,曝光用 ticks={[0]} 加 snapToTicks 让 0 点磁吸,formatValue 补上正负号。
"use client"
import * as React from "react"
import { RotateCcw, Sun, SunDim } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
/** 只有 0 一个刻度:松手时吸回中性位置 */
const NEUTRAL_TICK = [0] as const
/** 带正负号的读数:0 不显示符号 */
function signed(value: number) {
return value > 0 ? `+${value}` : String(value)
}
/** 三条滑块实时改预览图的 CSS filter,全程没有真实图片 */
export default function ValueSliderPhotoDemo() {
const [exposure, setExposure] = React.useState(0)
const [contrast, setContrast] = React.useState(0)
const [saturation, setSaturation] = React.useState(0)
const untouched = exposure === 0 && contrast === 0 && saturation === 0
const reset = () => {
setExposure(0)
setContrast(0)
setSaturation(0)
}
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-sm">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">调色</p>
<Button
type="button"
variant="ghost"
size="sm"
disabled={untouched}
onClick={reset}
>
<RotateCcw />
还原
</Button>
</div>
<div className="mt-3 overflow-hidden rounded-lg border border-edge">
<div
className="relative aspect-[16/10] w-full"
style={{
filter: `brightness(${1 + (exposure / 100) * 0.6}) contrast(${
1 + (contrast / 100) * 0.6
}) saturate(${Math.max(0, 1 + saturation / 100)})`,
}}
>
{/* 天空:从蓝到青,靠近地平线透出一点暖色 */}
<div
aria-hidden="true"
className="absolute inset-0"
style={{
background:
"linear-gradient(to bottom, color-mix(in oklab, var(--flow-blue) 52%, var(--background)), color-mix(in oklab, var(--flow-cyan) 26%, var(--background)) 62%, color-mix(in oklab, var(--flow-amber) 22%, var(--background)))",
}}
/>
<svg
viewBox="0 0 320 200"
preserveAspectRatio="none"
aria-hidden="true"
className="absolute inset-0 size-full"
>
{/* 太阳与它周围的光晕 */}
<defs>
<radialGradient id="value-slider-photo-glow">
<stop
offset="35%"
stopColor="var(--flow-amber)"
stopOpacity="0.5"
/>
<stop
offset="100%"
stopColor="var(--flow-amber)"
stopOpacity="0"
/>
</radialGradient>
</defs>
<circle
cx="250"
cy="56"
r="52"
fill="url(#value-slider-photo-glow)"
/>
<circle cx="250" cy="56" r="18" fill="var(--flow-amber)" />
{/* 远山与近处的坡地 */}
<path
d="M0 136 L56 82 L104 124 L154 70 L214 136 Z"
fill="var(--flow-violet)"
opacity="0.45"
/>
<path
d="M112 152 L182 88 L230 130 L276 98 L320 150 Z"
fill="var(--flow-green)"
opacity="0.6"
/>
<rect
y="150"
width="320"
height="50"
fill="var(--flow-green)"
opacity="0.32"
/>
</svg>
</div>
</div>
<div className="mt-4 flex flex-col gap-3">
{/* 每条滑块占固定高度:调整面板换尺寸档时卡片高度不变 */}
<div className="flex h-9 items-center">
<ValueSlider
label="曝光"
tone="amber"
min={-100}
max={100}
step={1}
value={exposure}
onValueChange={setExposure}
formatValue={signed}
ticks={NEUTRAL_TICK}
snapToTicks
startIcon={<SunDim />}
endIcon={<Sun />}
/>
</div>
<div className="flex h-9 items-center">
<ValueSlider
label="对比度"
tone="violet"
min={-100}
max={100}
step={1}
value={contrast}
onValueChange={setContrast}
formatValue={signed}
ticks={NEUTRAL_TICK}
snapToTicks
/>
</div>
<div className="flex h-9 items-center">
<ValueSlider
label="饱和度"
tone="green"
min={-100}
max={100}
step={1}
value={saturation}
onValueChange={setSaturation}
formatValue={signed}
ticks={NEUTRAL_TICK}
snapToTicks
/>
</div>
</div>
</div>
)
}
播放进度
variant="slim" + size="sm" 收成播放器里的细进度条,没有可见 label 时用 aria-label 提供名称;读数写成「已播 / 总长」显示在轨道上方,buffer 画出比当前进度多 40 秒的已缓冲段。
"use client"
import * as React from "react"
import { Disc3, Pause, Play } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
/** 时长 3:34:格式化成分:秒,读屏朗读的也是这个格式 */
const DURATION = 214
/** 已缓冲段固定比当前进度多 40 秒,封顶到总长 */
const BUFFER_AHEAD = 40
function clock(value: number) {
const minutes = Math.floor(value / 60)
const seconds = Math.floor(value % 60)
return `${minutes}:${String(seconds).padStart(2, "0")}`
}
/** 读数写成「已播 / 总长」,`aria-valuetext` 也是这个字符串 */
const progress = (value: number) => `${clock(value)} / ${clock(DURATION)}`
/** 播放进度:播放时每秒前进一格,拖到末尾自动停下 */
export default function ValueSliderPlaybackDemo() {
const [position, setPosition] = React.useState(62)
const [playing, setPlaying] = React.useState(false)
// 走到末尾就停:真正的播放状态由 position 一起决定,不需要额外的副作用
const running = playing && position < DURATION
React.useEffect(() => {
if (!running) return
const timer = window.setInterval(
() => setPosition((previous) => Math.min(DURATION, previous + 1)),
1000,
)
return () => window.clearInterval(timer)
}, [running])
const toggle = () => {
if (running) {
setPlaying(false)
return
}
if (position >= DURATION) setPosition(0)
setPlaying(true)
}
return (
<div className="w-full max-w-xs rounded-xl border border-edge bg-background p-4 shadow-sm">
<div className="flex items-center gap-3">
<div
className="grid size-12 shrink-0 place-items-center rounded-lg text-foreground/75"
style={{
background:
"linear-gradient(140deg, color-mix(in oklab, var(--flow-violet) 62%, var(--background)), color-mix(in oklab, var(--flow-rose) 52%, var(--background)))",
}}
>
<Disc3 className="size-5" aria-hidden="true" />
</div>
<div className="flex min-w-0 flex-1 flex-col">
<p className="truncate text-sm font-medium">晚风里的旧唱机</p>
<p className="truncate text-xs text-muted-foreground">
林砚 · 夜行手记
</p>
</div>
<Button
type="button"
variant="secondary"
size="icon-sm"
aria-label={running ? "暂停" : "播放"}
title={running ? "暂停" : "播放"}
onClick={toggle}
>
{running ? <Pause /> : <Play />}
</Button>
</div>
<div className="mt-4 flex h-11 items-center">
<ValueSlider
aria-label="播放进度"
variant="slim"
tone="primary"
size="sm"
min={0}
max={DURATION}
step={1}
largeStep={30}
value={position}
onValueChange={setPosition}
formatValue={progress}
buffer={Math.min(DURATION, position + BUFFER_AHEAD)}
/>
</div>
</div>
)
}
心情评分
min={1}、max={5}、step={1} 配 ticks={4} 得到五档,formatValue 把数字换成词语,读屏读到的也是词语。
"use client"
import * as React from "react"
import {
Annoyed,
Frown,
Laugh,
Meh,
Smile,
type LucideIcon,
} from "lucide-react"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
type Mood = {
label: string
tone: "rose" | "amber" | "primary" | "green"
icon: LucideIcon
}
/** 1 到 5 五档:读数与读屏文本都取这里的词 */
const MOODS: readonly Mood[] = [
{ label: "很差", tone: "rose", icon: Frown },
{ label: "不太好", tone: "rose", icon: Annoyed },
{ label: "一般", tone: "amber", icon: Meh },
{ label: "不错", tone: "primary", icon: Smile },
{ label: "很棒", tone: "green", icon: Laugh },
]
/** 内置色调名换成变量,图标与滑块用同一个颜色 */
const TONE_COLORS: Record<Mood["tone"], string> = {
rose: "var(--flow-rose)",
amber: "var(--flow-amber)",
primary: "var(--primary)",
green: "var(--flow-green)",
}
function moodOf(value: number) {
return MOODS[Math.min(MOODS.length, Math.max(1, Math.round(value))) - 1]
}
function moodLabel(value: number) {
return moodOf(value).label
}
/** 分值即词语:formatValue 同时决定右侧读数与 aria-valuetext */
export default function ValueSliderMoodDemo() {
const [score, setScore] = React.useState(4)
const mood = moodOf(score)
const MoodIcon = mood.icon
return (
<div
className="w-full max-w-xs rounded-xl border border-edge bg-background p-4 shadow-sm"
style={{ "--flow-tone": TONE_COLORS[mood.tone] } as React.CSSProperties}
>
<div className="flex items-center gap-3">
<span className="grid size-11 shrink-0 place-items-center rounded-full bg-(--flow-tone)/12 text-(--flow-tone)">
<MoodIcon className="size-6" aria-hidden="true" />
</span>
<div className="flex min-w-0 flex-col gap-0.5">
<p className="text-sm font-medium">今天感觉如何?</p>
<p className="text-xs text-muted-foreground">
{mood.label} · 1 到 5 共五档
</p>
</div>
</div>
<div className="mt-4">
<ValueSlider
label="心情"
min={1}
max={5}
step={1}
largeStep={1}
ticks={4}
value={score}
onValueChange={setScore}
formatValue={moodLabel}
tone={mood.tone}
/>
</div>
</div>
)
}
直接输入
导出尺寸卡片,三条滑块都开了 editable:点读数就能敲数字,回车确认、Esc 放弃。改过的项用 modified 把标签挑到前景色,一眼看出哪几项此时不是默认值。
"use client"
import * as React from "react"
import { Info } from "lucide-react"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
const DEFAULTS = { width: 1920, height: 1080, quality: 85 }
const formatPx = (value: number) => `${value}px`
const formatPercent = (value: number) => `${value}%`
/** 估算体积:像素数 × 质量系数,固定一位小数,服务端与客户端算出来一样 */
function estimateSize(width: number, height: number, quality: number) {
return ((width * height * (quality / 100)) / 1_000_000).toFixed(1)
}
/**
* 直接输入示例:导出尺寸卡片。三条滑块都开了 `editable`,点读数(或在手柄上按回车)
* 就能敲数字;改过的项用 `modified` 把标签挑到前景色,一眼看出哪几项此时不是默认值。
*/
export default function ValueSliderEditableDemo() {
const [width, setWidth] = React.useState(DEFAULTS.width)
const [height, setHeight] = React.useState(DEFAULTS.height)
const [quality, setQuality] = React.useState(DEFAULTS.quality)
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">PNG</p>
</div>
<div className="mt-3 rounded-lg border border-edge bg-muted/20 p-3 text-center">
<p className="font-mono text-base tabular-nums">
{width} × {height}
</p>
<p className="mt-0.5 text-[11px] text-muted-foreground">
约 {estimateSize(width, height, quality)} MB · 质量 {quality}%
</p>
</div>
<div className="mt-3 flex flex-col gap-2">
<ValueSlider
label="宽度"
min={480}
max={3840}
step={40}
largeStep={400}
editable
modified={width !== DEFAULTS.width}
value={width}
onValueChange={setWidth}
formatValue={formatPx}
/>
<ValueSlider
label="高度"
min={480}
max={2160}
step={40}
largeStep={400}
editable
modified={height !== DEFAULTS.height}
value={height}
onValueChange={setHeight}
formatValue={formatPx}
/>
<ValueSlider
label="质量"
min={40}
max={100}
step={1}
editable
modified={quality !== DEFAULTS.quality}
value={quality}
onValueChange={setQuality}
formatValue={formatPercent}
/>
</div>
<p className="mt-3 flex items-start gap-1.5 text-[11px] text-muted-foreground">
<Info className="mt-px size-3 shrink-0" aria-hidden="true" />
点右侧读数可以直接敲数字,回车确认、Esc
放弃;数字会夹在范围内并对齐到步长。
</p>
</div>
)
}
API
ValueSlider
受控值。传数组即为区间,数组长度决定手柄数量
类型Value | undefined非受控时的初始值;不传时取 min
Value | undefined最小值
类型number | undefined0最大值
类型number | undefined100步长,读数的默认小数位也随之变化
类型number | undefined1PageUp / PageDown 与 Shift + 方向键的步长
类型number | undefined10区间模式下两个手柄之间至少相隔几个 step,也参与磁吸后的合法性判断
number | undefined0是否禁用
类型boolean | undefinedfalse提交到表单时使用的字段名;不传时不生成表单字段
类型string | undefined值变化时回调(拖动、键盘、点击轨道都会触发)
类型((value: Value) => void) | undefined值提交时回调(松手、键盘操作结束)
类型((value: Value) => void) | undefined附加的样式类名
类型string | undefinedReact.CSSProperties | undefined数字格式化选项,交给 Intl.NumberFormat;不传时按 locale 与 step 推导
Intl.NumberFormatOptions | undefined数字格式化的区域,format 与默认读数都用它
Intl.LocalesArgument"zh-CN"读屏名称,优先于 label;都不传时手柄名称是「数值」
string | undefined标签;是字符串时同时作为手柄的读屏名称
类型React.ReactNode标签与读数的位置:inside 放在轨道内两端,outside 放在轨道上方一行
ValueSliderLabelPlacement | undefined"inside"已缓冲到的值(例如视频已加载到第 N 秒),在已选填充下面画一段次级填充;仅单值模式生效
类型number | undefined读数与 aria-valuetext 的格式化函数,优先于 format
((value: number) => string) | undefined是否显示读数。区间时显示为 起点 – 终点
boolean | undefinedtrue轨道左端外的图标,随左侧过冲平移
类型React.ReactNode轨道右端外的图标,随右侧过冲平移
类型React.ReactNode轨道内的刻度线。数字表示等分段数(画 n - 1 条内部刻度线),数组表示显式刻度值;
同时决定 snapToTicks 的吸附目标。建议刻度落在 step 上,不在的会对齐到最近的 step
number | readonly number[] | undefined指针松手时(拖动或点击轨道)若与最近刻度的距离不超过范围的 3%,吸到该刻度并触发 onValueChange;
键盘逐步移动不吸附
boolean | undefinedfalse拖出轨道两端时的最大过冲像素,0 关闭弹性
number | undefined12尺寸档,与 variant 一起决定轨道高度:
bar 为 sm 28px / default 32px / lg 36px,compact 为 18 / 22 / 24px,slim 为 6 / 8 / 10px
ValueSliderSize | undefined"default"轨道高度档,三档都是同一种方条:bar 高轨,标签与读数在轨道里;
compact 中等高度,文字仍在轨道里、字号收到 xs;
slim 矮轨,标签与读数移到轨道上方(此时忽略 labelPlacement),竖线手柄比轨道高出一截
ValueSliderVariant | undefined"bar"色调:填充取 --flow-tone 的淡色(slim 加深一档),手柄取实色;不传时用前景色
FlowTone | undefined读数可直接输入:点读数(或在手柄上按回车)换成输入框,回车 / 失焦提交,Esc 放弃。
输入值会夹在 min–max 之间并对齐到 step;区间模式下不可输入
boolean | undefinedfalse标记为「已改动」:标签与读数改用前景色,根元素带 data-modified。
控制面板里用来区分改过的参数与默认值
boolean | undefinedfalse拖动时是否有刻度与碰边的音效(尊重本地静音偏好)
类型boolean | undefinedfalse默认 min={0}、max={100}、step={1}、largeStep={10}、minStepsBetweenValues={0}、showValue、elasticity={12}、size="default"、variant="bar"、labelPlacement="inside"、locale="zh-CN";snapToTicks、sound、editable、modified、disabled 默认关闭,tone 不传时轨道用前景色,buffer 不传时不画缓冲段。
轨道高度由 size 与 variant 共同决定:bar 是 sm 28px、default 32px、lg 36px;compact 是 18px、22px、24px;slim 是 6px、8px、10px,竖线手柄依次高 14px、16px、18px,命中区至少 20px 高,矮轨也好按。读数小数位由 step 推导,format / formatValue 可以覆盖。
除上表外,其余属性直接交给 Base UI 的 Slider.Root:form(关联到页面上的某个表单)、id、minStepsBetweenValues(区间两个手柄之间至少隔几个 step)、thumbCollisionBehavior(push / swap / none,默认 push)。orientation、thumbAlignment 之类会影响横向布局的属性由组件接管,没有对外暴露——组件固定用 thumbAlignment="edge",手柄落在两端时贴着轨道内侧对齐,不会探出边框半截。
ValueSliderTuningContext
const ValueSliderTuningContext: React.Context<ValueSliderTuning>可选的覆盖层:子树里所有 ValueSlider 的 elasticity、size、variant、tone、ticks、snapToTicks、showValue、sound 会被 Context 里的值替换,onReport 回报组件自己的实际取值。不发这个 Context 时组件行为不变,正常使用不需要它。
useValueSliderMuted
function useValueSliderMuted(): readonly [
muted: boolean,
setMuted: (muted: boolean) => void,
]读写音效的静音偏好,存在 localStorage 的 travinsy:sound:muted 里,与站内其他带音效的组件共用同一份偏好:同一页面的所有消费者共享(写入后广播 travinsy:sound-muted-change 事件,不依赖只有跨标签页才触发的 storage),也在标签页之间同步;服务端与水合阶段按未静音渲染。
valueSliderVariants
cva 生成的根元素样式函数,有 size 与 variant 两个变体;写自己的触发器或包装层时可以复用它保持一致。轨道高度与手柄直径是通过根元素上的 CSS 变量(--value-slider-track-height、--value-slider-thumb-size)传给内部轨道与手柄的。
const valueSliderVariants: (props?: {
size?: ValueSliderSize
variant?: ValueSliderVariant
}) => string类型
type ValueSliderValue = number | readonly number[]
type ValueSliderSize = "sm" | "default" | "lg"
type ValueSliderVariant = "bar" | "compact" | "slim"
type ValueSliderLabelPlacement = "inside" | "outside"
type ValueSliderProps<Value extends ValueSliderValue = number> = {
value?: Value
defaultValue?: Value
min?: number
max?: number
step?: number
largeStep?: number
minStepsBetweenValues?: number
disabled?: boolean
name?: string
onValueChange?: (value: Value) => void
onValueCommitted?: (value: Value) => void
format?: Intl.NumberFormatOptions
locale?: Intl.LocalesArgument
label?: React.ReactNode
labelPlacement?: ValueSliderLabelPlacement
"aria-label"?: string
buffer?: number
formatValue?: (value: number) => string
showValue?: boolean
startIcon?: React.ReactNode
endIcon?: React.ReactNode
ticks?: number | readonly number[]
snapToTicks?: boolean
elasticity?: number
size?: ValueSliderSize
variant?: ValueSliderVariant
tone?: FlowTone
editable?: boolean
modified?: boolean
sound?: boolean
className?: string
style?: React.CSSProperties
}
type ValueSliderTuning = Partial<
Pick<
ValueSliderProps,
| "elasticity"
| "size"
| "variant"
| "ticks"
| "snapToTicks"
| "showValue"
| "sound"
>
> & {
/** `false` 表示去掉色调 */
tone?: FlowTone | false
onReport?: (report: ValueSliderReport) => void
}
type ValueSliderReport = {
elasticity: number
size: ValueSliderSize
variant: ValueSliderVariant
tone: FlowTone | undefined
/** 示例用显式刻度数组时是 undefined,表示「没有段数可报」 */
ticks: number | undefined
snapToTicks: boolean
showValue: boolean
sound: boolean
}示例用显式刻度数组时,调整面板的刻度滑块显示为默认的 0;一旦拖动刻度滑块,就换成等分刻度(刻度数组无法用段数表达)。
data-slot
| 元素 | data-slot |
|---|---|
| 根元素 | value-slider |
| 轨道上方的文字 | value-slider-labels |
| 交互区 | value-slider-control |
| 轨道外框 | value-slider-rail |
| 轨道 | value-slider-track |
| 缓冲填充 | value-slider-buffer |
| 已选填充 | value-slider-indicator |
| 刻度线 | value-slider-tick |
| 标签 | value-slider-label |
| 读数 | value-slider-value |
| 输入框 | value-slider-input |
| 手柄 | value-slider-thumb |
| 左端图标 | value-slider-start-icon |
| 右端图标 | value-slider-end-icon |
value-slider-labels 只在 labelPlacement="outside"(以及 variant="slim")时出现,label 与读数都放在它里面;inside 时这两个 data-slot 落在轨道内。value-slider-input 只在 editable 且正在输入时出现,临时替掉同一位置的读数。根元素在过冲时带 data-overshoot(start 往左、end 往右),禁用时带 data-disabled,拖动中带 data-dragging,手柄压住文字时带 data-near="true",modified 为真时带 data-modified,并且始终带 data-variant(bar / compact / slim)。
可访问性
- 手柄是原生输入元素,键盘操作:
←→↓↑加减一个step,Shift+ 方向键与PageUp/PageDown按largeStep,Home/End到两端;区间时Home/End不会越过另一个手柄。RTL 布局下左右方向键的增减是反的(方向取自 Base UI 的DirectionProvider,需要在应用里包一层<DirectionProvider direction="rtl">,同时给页面设dir="rtl")。 - 读屏名称优先取
aria-label;没有时用字符串label(区间自动补「起点」「终点」);两者都没有时退回「数值」,所以同一个表单里有多个滑块时务必给label或aria-label,否则几个手柄会重名。 aria-valuetext是格式化后的读数,所以format/formatValue的写法会直接决定读屏怎么念。- 焦点环只在键盘聚焦时出现(
:focus-visible),鼠标点击轨道不会留下焦点框。Control 上有一圈整体焦点环,聚焦的那个手柄还会自己出环,区间模式下能看出当前在调哪个手柄,并从 4px 加粗到 6px。 - 系统开启「减少动态效果」时不播放过冲与弹簧回弹,轨道不会被拉伸。
editable时读数是一个可点的文字入口:鼠标直接点,键盘在手柄上按Enter进入输入(回车被拦下,不会提交外层表单)。输入框自带名称(输入+ 手柄名称),回车提交后焦点回到手柄,Esc放弃并同样还回焦点;失焦提交时不抢焦点。- 缓冲填充是
aria-hidden的装饰,读屏只报当前值。 - 音效默认关闭。打开后请同时提供静音入口(
useValueSliderMuted),音效只是点缀,不承载信息;静音偏好存在localStorage的travinsy:sound:muted,与站内其他带音效的组件共用。
注意事项
- 组件在服务端就输出完整结构(手柄按
left%定位、填充有宽度),不需要预水合脚本,禁用 JS 时也能看到当前值。 - 拖动期间的过冲动画由 MotionValue 直接改
transform,不触发 React 渲染;数值变化本身仍会渲染读数,所以一次拖动里读数会跟着刷新。 - 用作表单字段时传
name,值会随表单提交;受控模式下记得同时处理onValueChange,否则拖动后值会弹回。需要挂到页面另一处的表单上时再传form。 format对象、formatValue函数与ticks数组建议定义在组件外,避免每次渲染都重建格式化器或刻度表。- 组件用一次
ResizeObserver观测轨道与文字宽度,服务端输出的是完整静态结构;首帧还没量到宽度时,落在两端 15% 以内的手柄先按「压在文字上」处理,量到之后按真实文字宽度判断。slim的文字在轨道外,不做让位。 tone的颜色变量由flow-diagram条目提供,安装时会一并拉取;手动安装需要自己补上--flow-*变量。
源码
"use client"
import * as React from "react"
import { useDirection } from "@base-ui/react/direction-provider"
import { Slider as SliderPrimitive } from "@base-ui/react/slider"
import { cva } from "class-variance-authority"
import { motion } from "motion/react"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { flowToneStyle } from "@/components/ui/flow-diagram/tone"
import { playEdge, playTick, useValueSliderMuted } from "./sound"
import type {
ValueSliderProps,
ValueSliderTuning,
ValueSliderValue,
} from "./types"
import { useElasticPull } from "./use-elastic-pull"
import { useHandleDodge } from "./use-handle-dodge"
import {
ValueSliderReadout,
ValueSliderThumb,
ValueSliderTrack,
type ValueSliderEditor,
} from "./value-slider-parts"
import {
clamp01,
crossedTick,
resolveTicks,
sameValue,
snapValuesToTicks,
stepDecimals,
toNumberList,
valueToPercent,
} from "./utils"
/** 刻度音效的最短间隔(毫秒),拖动时值变化很快,太密会糊成噪声。 */
const TICK_INTERVAL = 35
/** 碰边音效的最短间隔(毫秒),磁吸与过冲可能同时报同一次碰边。 */
const EDGE_INTERVAL = 250
/** 磁吸判定:与最近刻度的距离不超过整个范围的这个比例时吸过去。 */
const SNAP_RATIO = 0.03
const valueSliderVariants = cva("flex w-full flex-col select-none", {
variants: {
size: {
sm: "gap-1 text-xs [--value-slider-height:1.75rem]",
default: "gap-1 text-sm [--value-slider-height:2rem]",
lg: "gap-1 text-sm [--value-slider-height:2.25rem]",
},
variant: {
// 三种形态都是方条,只是高度不同:bar 沿用尺寸档的高度,compact / slim 在 compoundVariants 里按尺寸细分
bar: "[--value-slider-track-height:var(--value-slider-height)]",
// 中等高度:轨道里仍放标签与读数,字号统一收到 xs
compact: "text-xs",
// 矮轨:文字移到上方,手柄比轨道高出一截
slim: "gap-1.5",
},
},
compoundVariants: [
{
variant: "compact",
size: "sm",
class: "[--value-slider-track-height:1.125rem]",
},
{
variant: "compact",
size: "default",
class: "[--value-slider-track-height:1.375rem]",
},
{
variant: "compact",
size: "lg",
class: "[--value-slider-track-height:1.5rem]",
},
{
variant: "slim",
size: "sm",
class:
"[--value-slider-track-height:0.375rem] [--value-slider-thumb-height:0.875rem]",
},
{
variant: "slim",
size: "default",
class:
"[--value-slider-track-height:0.5rem] [--value-slider-thumb-height:1rem]",
},
{
variant: "slim",
size: "lg",
class:
"[--value-slider-track-height:0.625rem] [--value-slider-thumb-height:1.125rem]",
},
],
defaultVariants: {
size: "default",
variant: "bar",
},
})
/**
* 可选的调整上下文:子树里所有 ValueSlider 的对应属性被这里的值覆盖。
* 用于调试面板一类的场景;不包这一层时组件行为不变。
*/
const ValueSliderTuningContext = React.createContext<ValueSliderTuning>({})
/** 带标签与读数的数值滑块,拖出两端时有橡皮筋过冲与弹簧回弹。 */
function ValueSlider<Value extends ValueSliderValue = number>(
allProps: ValueSliderProps<Value>,
) {
const { onReport, ...overrides } = React.useContext(ValueSliderTuningContext)
const {
value: valueProp,
defaultValue,
onValueChange,
onValueCommitted,
min = 0,
max = 100,
step = 1,
largeStep = 10,
minStepsBetweenValues = 0,
disabled = false,
name,
format,
locale,
label,
"aria-label": ariaLabel,
labelPlacement = "inside",
buffer,
formatValue,
showValue: showValueProp = true,
startIcon,
endIcon,
ticks: ticksProp,
snapToTicks: snapToTicksProp = false,
elasticity: elasticityProp = 12,
size: sizeProp = "default",
variant: variantProp = "bar",
tone: toneProp,
sound: soundProp = false,
editable = false,
modified = false,
className,
style,
...props
} = allProps
// 回报示例自己的取值(覆盖前),面板打开时显示的就是示例现在的样子
React.useEffect(() => {
onReport?.({
elasticity: elasticityProp,
size: sizeProp,
variant: variantProp,
tone: toneProp,
// 显式刻度数组没有「段数」可言,报 undefined 让面板显示默认值
ticks: typeof ticksProp === "number" ? ticksProp : undefined,
snapToTicks: snapToTicksProp,
showValue: showValueProp,
sound: soundProp,
})
}, [
onReport,
elasticityProp,
sizeProp,
variantProp,
toneProp,
ticksProp,
snapToTicksProp,
showValueProp,
soundProp,
])
const elasticity = Math.max(0, overrides.elasticity ?? elasticityProp)
const size = overrides.size ?? sizeProp
const variant = overrides.variant ?? variantProp
const ticks = overrides.ticks ?? ticksProp
const snapToTicks = overrides.snapToTicks ?? snapToTicksProp
const showValue = overrides.showValue ?? showValueProp
const sound = overrides.sound ?? soundProp
const tone =
overrides.tone === false ? undefined : (overrides.tone ?? toneProp)
const hasTone = tone !== undefined
// slim 的轨道只有几像素高,文字放不进去,一律摆到上方,等同 labelPlacement="outside"
const slim = variant === "slim"
const outside = slim || labelPlacement === "outside"
// 读数、碰边判断都基于当前值,所以值统一收在这里,再以受控方式交给 Base UI
const [value, setValue] = useControllableState<Value>({
prop: valueProp,
defaultProp: (defaultValue ?? min) as Value,
onChange: onValueChange,
})
const values = React.useMemo(() => toNumberList(value), [value])
const isRange = values.length > 1
const direction = useDirection()
const [muted] = useValueSliderMuted()
const soundOn = sound && !muted
const soundOnRef = React.useRef(soundOn)
React.useEffect(() => {
soundOnRef.current = soundOn
})
const decimals = stepDecimals(step)
const formatNumber = React.useMemo(() => {
if (formatValue) return formatValue
const formatter = new Intl.NumberFormat(locale ?? "zh-CN", {
minimumFractionDigits: 0,
maximumFractionDigits: decimals,
...format,
})
return (item: number) => formatter.format(item)
}, [formatValue, locale, format, decimals])
const tickList = React.useMemo(
() => resolveTicks(ticks, min, max),
[ticks, min, max],
)
const innerTicks = React.useMemo(
() => tickList.filter((tick) => tick > min && tick < max),
[tickList, min, max],
)
/* ---------- 音效 ---------- */
const lastTickRef = React.useRef(0)
const lastEdgeRef = React.useRef(0)
const previousValuesRef = React.useRef<number[] | null>(null)
const playEdgeOnce = React.useCallback(() => {
const now = performance.now()
if (now - lastEdgeRef.current < EDGE_INTERVAL) return
lastEdgeRef.current = now
if (soundOnRef.current) playEdge()
}, [])
const handleValueChange = (next: Value) => {
const list = toNumberList(next)
const previous = previousValuesRef.current
previousValuesRef.current = list
setValue(next)
// 音效跟着变化最大的那个手柄走
let index = 0
let from = previous?.[0]
if (previous && previous.length === list.length) {
let largest = -1
list.forEach((item, itemIndex) => {
const delta = Math.abs(item - (previous[itemIndex] ?? item))
if (delta > largest) {
largest = delta
index = itemIndex
from = previous[itemIndex]
}
})
}
const changed = list[index] ?? min
if (changed <= min || changed >= max) playEdgeOnce()
if (!soundOn) return
const crossed = from !== undefined && crossedTick(from, changed, tickList)
const stepped =
from === undefined || Math.abs(changed - from) >= step - 1e-9
if (tickList.length > 0 ? crossed : stepped) {
const now = performance.now()
if (now - lastTickRef.current >= TICK_INTERVAL) {
lastTickRef.current = now
playTick(clamp01((changed - min) / Math.max(1e-6, max - min)))
}
}
}
/**
* 只有指针交互松开时才磁吸。
* Base UI 对每一次键盘变更也会调 `onValueCommitted`(reason 为 `"keyboard"`),
* 在那里吸附会让值被反复拉回最近的刻度,方向键就只能在刻度上原地挣扎。
*/
const handleValueCommitted = (
next: Value,
details: SliderPrimitive.Root.CommitEventDetails,
) => {
let committed = next
const fromPointer =
details.reason === "drag" || details.reason === "track-press"
if (snapToTicks && fromPointer && tickList.length > 0) {
const list = toNumberList(next)
const snapped = snapValuesToTicks(list, {
ticks: tickList,
min,
max,
step,
minGapSteps: minStepsBetweenValues,
tolerance: (max - min) * SNAP_RATIO,
})
if (!sameValue(snapped, list)) {
const result = (snapped.length === 1 ? snapped[0] : snapped) as Value
setValue(result)
committed = result
}
}
onValueCommitted?.(committed)
}
/* ---------- 过冲 ---------- */
const elastic = useElasticPull({ elasticity, disabled, onEdge: playEdgeOnce })
const handleThumbKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>,
index: number,
) => {
if (event.key === "Enter" && editor) {
event.preventDefault()
editor.start()
return
}
const current = values[index] ?? min
// RTL 时左右方向键的增减是反的;方向取自 Base UI 的 DirectionProvider,与它自己的按键映射一致
const rtl = direction === "rtl"
// 端点脉冲是物理方向的:RTL 时 min 在右边,所以两侧要互换
const minSide = rtl ? "end" : "start"
const maxSide = rtl ? "start" : "end"
const towardStart =
event.key === (rtl ? "ArrowRight" : "ArrowLeft") ||
event.key === "ArrowDown" ||
event.key === "PageDown" ||
event.key === "Home"
const towardEnd =
event.key === (rtl ? "ArrowLeft" : "ArrowRight") ||
event.key === "ArrowUp" ||
event.key === "PageUp" ||
event.key === "End"
if (towardStart && current <= min) elastic.pulse(minSide)
else if (towardEnd && current >= max) elastic.pulse(maxSide)
}
/* ---------- 手柄让开轨道内的文字 ---------- */
const insideText = !outside && (label !== undefined || showValue)
const { trackRef, labelRef, valueRef, isNear } = useHandleDodge(insideText)
const thumbName = ariaLabel ?? (typeof label === "string" ? label : "数值")
const readout = isRange
? `${formatNumber(values[0])} – ${formatNumber(values[values.length - 1])}`
: formatNumber(values[0] ?? min)
/* ---------- 读数手动输入 ---------- */
const [editing, setEditing] = React.useState(false)
const [draft, setDraft] = React.useState("")
// 回车提交后输入框卸载,可能紧跟着一次失焦提交;用 ref 保证只提交一次
const editingRef = React.useRef(false)
const canEdit = editable && !isRange && !disabled
const focusThumb = (from: HTMLElement) => {
const root = from.closest<HTMLElement>('[data-slot="value-slider"]')
// 等输入框卸载后再聚焦,否则焦点会落回正在消失的元素
requestAnimationFrame(() =>
root
?.querySelector<HTMLInputElement>(
'[data-slot="value-slider-thumb"] input',
)
?.focus(),
)
}
const editor: ValueSliderEditor | undefined = canEdit
? {
editing,
draft,
label: `输入${thumbName}`,
start: () => {
editingRef.current = true
setDraft((values[0] ?? min).toFixed(decimals))
setEditing(true)
},
change: setDraft,
commit: (from, refocus) => {
if (!editingRef.current) return
editingRef.current = false
setEditing(false)
if (refocus) focusThumb(from)
// 容忍千分位与单位:只取数字部分;解析不出数字时当作放弃
const parsed = Number.parseFloat(draft.replace(/[^\d.+-]/g, ""))
if (!Number.isFinite(parsed)) return
const stepped = min + Math.round((parsed - min) / step) * step
const next = Number(
Math.min(max, Math.max(min, stepped)).toFixed(decimals),
)
if (next === values[0]) return
// 手动输入是一次性跳到目标值,不放刻度音效,直接写值并当作一次提交
previousValuesRef.current = [next]
setValue(next as unknown as Value)
onValueCommitted?.(next as unknown as Value)
},
cancel: (from) => {
editingRef.current = false
setEditing(false)
focusThumb(from)
},
}
: undefined
const bufferPercent =
buffer === undefined || isRange
? undefined
: valueToPercent(buffer, min, max)
return (
<SliderPrimitive.Root
{...props}
value={value}
onValueChange={(next) => handleValueChange(next as unknown as Value)}
onValueCommitted={(next, details) =>
handleValueCommitted(next as unknown as Value, details)
}
min={min}
max={max}
step={step}
largeStep={largeStep}
minStepsBetweenValues={minStepsBetweenValues}
// 手柄在两端时贴着轨道内侧,不伸出边框(默认的 center 会探出半个手柄)
thumbAlignment="edge"
disabled={disabled}
name={name}
format={format}
locale={locale}
data-slot="value-slider"
data-variant={variant}
data-modified={modified ? "" : undefined}
data-overshoot={elastic.side ?? undefined}
data-disabled={disabled ? "" : undefined}
className={cn(
valueSliderVariants({ size, variant }),
"group/value-slider data-[disabled]:opacity-50",
className,
)}
style={{ ...flowToneStyle(tone), ...style }}
>
{outside ? (
<div
data-slot="value-slider-labels"
className="flex items-baseline justify-between gap-2 px-0.5"
>
<span
data-slot="value-slider-label"
className="truncate font-medium text-muted-foreground transition-colors group-data-[modified]/value-slider:text-foreground"
>
{label}
</span>
{showValue ? (
<ValueSliderReadout
readout={readout}
editor={editor}
inside={false}
/>
) : null}
</div>
) : null}
<div className="flex w-full min-w-0 items-center gap-2">
{startIcon ? (
<motion.span
data-slot="value-slider-start-icon"
aria-hidden="true"
className="flex shrink-0 items-center text-muted-foreground [&_svg:not([class*='size-'])]:size-4"
style={{ x: elastic.startIconX, scale: elastic.startIconScale }}
>
{startIcon}
</motion.span>
) : null}
{/* Control 只负责把指针换算成数值,自己不缩放;缩放在它内部的视觉层上。
slim 的轨道只有几像素高,给 Control 一个 20px 的命中区,视觉层在其中垂直居中 */}
<SliderPrimitive.Control
data-slot="value-slider-control"
className={cn(
"relative w-full min-w-0 cursor-pointer touch-none select-none in-data-[disabled]:cursor-not-allowed has-[[data-slot=value-slider-thumb]_input:focus-visible]:ring-3 has-[[data-slot=value-slider-thumb]_input:focus-visible]:ring-ring/50",
slim && "flex min-h-5 items-center",
)}
onPointerDown={elastic.onPointerDown}
>
<motion.div
data-slot="value-slider-rail"
className="relative w-full origin-center"
style={{
x: elastic.x,
scaleX: elastic.scaleX,
scaleY: elastic.scaleY,
}}
>
<ValueSliderTrack
trackRef={trackRef}
labelRef={labelRef}
valueRef={valueRef}
variant={variant}
outside={outside}
label={label}
readout={readout}
showValue={showValue}
editor={editor}
bufferPercent={bufferPercent}
hasTone={hasTone}
ticks={innerTicks}
min={min}
max={max}
/>
{values.map((item, index) => (
<ValueSliderThumb
key={index}
index={index}
variant={variant}
near={isNear(valueToPercent(item, min, max))}
hasTone={hasTone}
outside={outside}
label={
isRange
? `${thumbName}(${index === 0 ? "起点" : "终点"})`
: thumbName
}
valueText={formatNumber(item)}
onKeyDown={(event) => handleThumbKeyDown(event, index)}
/>
))}
</motion.div>
</SliderPrimitive.Control>
{endIcon ? (
<motion.span
data-slot="value-slider-end-icon"
aria-hidden="true"
className="flex shrink-0 items-center text-muted-foreground [&_svg:not([class*='size-'])]:size-4"
style={{ x: elastic.endIconX, scale: elastic.endIconScale }}
>
{endIcon}
</motion.span>
) : null}
</div>
</SliderPrimitive.Root>
)
}
export {
ValueSlider,
ValueSliderTuningContext,
useValueSliderMuted,
valueSliderVariants,
}
export type {
ValueSliderLabelPlacement,
ValueSliderProps,
ValueSliderReport,
ValueSliderSize,
ValueSliderTuning,
ValueSliderValue,
ValueSliderVariant,
} from "./types"
"use client"
import * as React from "react"
import { Slider as SliderPrimitive } from "@base-ui/react/slider"
import { cn } from "@/lib/utils"
import type { ValueSliderVariant } from "./types"
import { valueToPercent } from "./utils"
type ValueSliderTrackProps = {
/** 观测轨道宽度,用于手柄让位 */
trackRef: React.RefObject<HTMLDivElement | null>
labelRef: React.RefObject<HTMLSpanElement | null>
valueRef: React.RefObject<HTMLSpanElement | null>
variant: ValueSliderVariant
/** 文字放在轨道上方时(`labelPlacement="outside"`,以及 `slim`)轨道里不再渲染它们 */
outside: boolean
label: React.ReactNode
readout: string
showValue: boolean
/** 读数的手动输入;`undefined` 表示不可输入 */
editor: ValueSliderEditor | undefined
/** 缓冲填充的百分比;`undefined` 表示不画 */
bufferPercent: number | undefined
hasTone: boolean
ticks: readonly number[]
min: number
max: number
}
/**
* 轨道与它内部的视觉层:缓冲填充、已选填充、刻度线、轨道内的标签与读数。
* 只负责画,交互全部交给上层的 `Slider.Control` 与手柄。
* 三种形态共用同一套方条语言(细边框、小圆角、淡色分层填充),只是高度不同;
* `slim` 只有几像素高,填充加深一档,否则淡色叠在淡色上看不出进度。
*/
function ValueSliderTrack({
trackRef,
labelRef,
valueRef,
variant,
outside,
label,
readout,
showValue,
editor,
bufferPercent,
hasTone,
ticks,
min,
max,
}: ValueSliderTrackProps) {
const slim = variant === "slim"
return (
<SliderPrimitive.Track
ref={trackRef}
data-slot="value-slider-track"
className={cn(
"group/track relative h-(--value-slider-track-height) w-full overflow-hidden border border-edge bg-muted/60",
slim ? "rounded-xs" : "rounded-md",
)}
>
{/* 缓冲段画在已选填充下面:先出现的是「已加载」,盖上去的是「已播放」 */}
{bufferPercent === undefined ? null : (
<span
data-slot="value-slider-buffer"
aria-hidden="true"
className={cn(
"absolute inset-y-0 start-0",
hasTone
? slim
? "bg-(--flow-tone)/20"
: "bg-(--flow-tone)/10"
: "bg-foreground/10 dark:bg-foreground/15",
)}
style={{ width: `${bufferPercent}%` }}
/>
)}
<SliderPrimitive.Indicator
data-slot="value-slider-indicator"
className={cn(
"h-full",
hasTone
? slim
? "bg-(--flow-tone)/45 dark:bg-(--flow-tone)/60"
: "bg-(--flow-tone)/20 dark:bg-(--flow-tone)/35"
: slim
? "bg-foreground/25 dark:bg-foreground/30"
: "bg-foreground/10 dark:bg-foreground/15",
)}
/>
{ticks.map((tick) => (
<span
key={tick}
data-slot="value-slider-tick"
aria-hidden="true"
className="pointer-events-none absolute top-1/2 h-2 w-px -translate-y-1/2 bg-foreground/15 transition-colors group-hover/track:bg-foreground/30 in-data-[dragging]:bg-foreground/30"
// 逻辑属性定位:LTR 从左边、RTL 从右边量;自己减掉半个线宽,两个方向都是居中的
style={{
insetInlineStart: `calc(${valueToPercent(tick, min, max)}% - 0.5px)`,
}}
/>
))}
{label !== undefined && !outside ? (
<span
ref={labelRef}
data-slot="value-slider-label"
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 font-medium whitespace-nowrap text-muted-foreground transition-colors group-data-[modified]/value-slider:text-foreground"
>
{label}
</span>
) : null}
{showValue && !outside ? (
<ValueSliderReadout
valueRef={valueRef}
readout={readout}
editor={editor}
inside
/>
) : null}
</SliderPrimitive.Track>
)
}
/** 读数手动输入的状态与动作,由根组件持有,读数在轨道里或轨道上方都用同一份。 */
type ValueSliderEditor = {
editing: boolean
draft: string
/** 输入框的可访问名称 */
label: string
start: () => void
change: (draft: string) => void
/** 提交草稿;`refocus` 为真时把焦点还给手柄(回车提交),失焦提交时不抢焦点 */
commit: (from: HTMLElement, refocus: boolean) => void
cancel: (from: HTMLElement) => void
}
type ValueSliderReadoutProps = {
valueRef?: React.RefObject<HTMLSpanElement | null>
readout: string
editor: ValueSliderEditor | undefined
/** 画在轨道里(绝对定位在右端)还是轨道上方的标签行里 */
inside: boolean
}
/**
* 读数。可输入时点一下(或在手柄上按回车)换成输入框:回车或失焦提交,Esc 放弃。
* 读数和输入框都拦下 `pointerdown`,否则点读数会被 `Slider.Control` 当成点轨道、把值跳过去。
* 读数本身对读屏隐藏(数值由手柄的 `aria-valuetext` 朗读),键盘用户从手柄按回车进入输入。
*/
function ValueSliderReadout({
valueRef,
readout,
editor,
inside,
}: ValueSliderReadoutProps) {
const stop = (event: React.PointerEvent) => event.stopPropagation()
if (editor?.editing) {
return (
<input
data-slot="value-slider-input"
aria-label={editor.label}
autoFocus
inputMode="decimal"
value={editor.draft}
onChange={(event) => editor.change(event.target.value)}
onPointerDown={stop}
// 进入编辑时整段选中,直接敲数字就是替换
onFocus={(event) => event.currentTarget.select()}
onBlur={(event) => editor.commit(event.currentTarget, false)}
onKeyDown={(event) => {
// 别让方向键、回车冒泡到 Slider 上去改值
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
editor.commit(event.currentTarget, true)
} else if (event.key === "Escape") {
event.preventDefault()
editor.cancel(event.currentTarget)
}
}}
className={cn(
// 嵌在读数原位的一小块:背景色底 + 色调细描边表示「正在编辑」,不再叠加粗焦点环
"w-[8ch] rounded-sm bg-background px-1.5 text-end font-mono text-foreground tabular-nums shadow-xs ring-1 ring-[color-mix(in_oklab,var(--flow-tone,var(--foreground))_55%,transparent)] outline-none",
inside
? "absolute top-1/2 right-1 h-[max(1rem,calc(100%-0.375rem))] -translate-y-1/2"
: "h-5",
)}
/>
)
}
return (
<span
ref={valueRef}
data-slot="value-slider-value"
aria-hidden="true"
onPointerDown={editor ? stop : undefined}
onClick={editor?.start}
className={cn(
"font-mono whitespace-nowrap text-muted-foreground tabular-nums transition-colors group-hover/value-slider:text-foreground group-data-[modified]/value-slider:text-foreground in-data-[dragging]:text-foreground",
inside && "absolute top-1/2 right-3 -translate-y-1/2",
editor
? "pointer-events-auto cursor-text decoration-dotted underline-offset-2 hover:underline"
: inside && "pointer-events-none",
)}
>
{readout}
</span>
)
}
type ValueSliderThumbProps = {
index: number
variant: ValueSliderVariant
/** 手柄压在标签或读数上:淡下去让文字可读 */
near: boolean
hasTone: boolean
/** 文字在轨道外时手柄常驻实色,不需要让位 */
outside: boolean
label: string
valueText: string
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
}
/**
* 手柄:一根 4px 的竖线,放在轨道外面一层,端点处不会被轨道的 overflow 裁掉。
* `bar` / `compact` 里竖线是轨道高度的一半;`slim` 的轨道太矮,竖线反过来比轨道高出一截
* (`--value-slider-thumb-height`),像一根插在轨道上的标尺指针。
* 内部是一个视觉隐藏的 `input`,键盘聚焦时由它来给手柄本身加出环(区间模式下 Control 上
* 的整体焦点环指不出是哪个手柄)。焦点把手柄从 4px 加粗到 6px,并撑满不透明度。
*/
function ValueSliderThumb({
index,
variant,
near,
hasTone,
outside,
label,
valueText,
onKeyDown,
}: ValueSliderThumbProps) {
return (
<SliderPrimitive.Thumb
index={index}
data-slot="value-slider-thumb"
data-near={near ? "true" : undefined}
aria-label={label}
aria-valuetext={valueText}
className={cn(
"w-1 rounded-full transition-[width,opacity]",
variant === "slim" ? "h-(--value-slider-thumb-height)" : "h-1/2",
"has-[:focus-visible]:w-1.5 has-[:focus-visible]:opacity-100 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50",
hasTone ? "bg-(--flow-tone)" : "bg-foreground",
outside
? "opacity-100"
: "opacity-40 group-hover/value-slider:opacity-100 in-data-[dragging]:opacity-100 data-[near=true]:not-has-[:focus-visible]:opacity-20!",
)}
onKeyDown={onKeyDown}
/>
)
}
export { ValueSliderReadout, ValueSliderThumb, ValueSliderTrack }
export type {
ValueSliderEditor,
ValueSliderReadoutProps,
ValueSliderThumbProps,
ValueSliderTrackProps,
}
import type { Slider as SliderPrimitive } from "@base-ui/react/slider"
import type * as React from "react"
import type { FlowTone } from "@/components/ui/flow-diagram/tone"
type ValueSliderValue = number | readonly number[]
type ValueSliderSize = "sm" | "default" | "lg"
type ValueSliderLabelPlacement = "inside" | "outside"
type ValueSliderVariant = "bar" | "compact" | "slim"
type ValueSliderProps<Value extends ValueSliderValue = number> = Omit<
SliderPrimitive.Root.Props<Value>,
| "aria-label"
| "children"
| "className"
| "defaultValue"
| "disabled"
| "format"
| "largeStep"
| "locale"
| "max"
| "min"
| "minStepsBetweenValues"
| "name"
| "onValueChange"
| "onValueCommitted"
| "orientation"
| "render"
| "step"
| "style"
| "thumbAlignment"
| "value"
> & {
/** 受控值。传数组即为区间,数组长度决定手柄数量 */
value?: Value
/** 非受控时的初始值;不传时取 `min` */
defaultValue?: Value
/**
* 最小值
* @default 0
*/
min?: number
/**
* 最大值
* @default 100
*/
max?: number
/**
* 步长,读数的默认小数位也随之变化
* @default 1
*/
step?: number
/**
* PageUp / PageDown 与 Shift + 方向键的步长
* @default 10
*/
largeStep?: number
/**
* 区间模式下两个手柄之间至少相隔几个 `step`,也参与磁吸后的合法性判断
* @default 0
*/
minStepsBetweenValues?: number
/**
* 是否禁用
* @default false
*/
disabled?: boolean
/** 提交到表单时使用的字段名;不传时不生成表单字段 */
name?: string
/** 值变化时回调(拖动、键盘、点击轨道都会触发) */
onValueChange?: (value: Value) => void
/** 值提交时回调(松手、键盘操作结束) */
onValueCommitted?: (value: Value) => void
className?: string
style?: React.CSSProperties
/** 数字格式化选项,交给 `Intl.NumberFormat`;不传时按 `locale` 与 `step` 推导 */
format?: Intl.NumberFormatOptions
/**
* 数字格式化的区域,`format` 与默认读数都用它
* @default "zh-CN"
*/
locale?: Intl.LocalesArgument
/** 读屏名称,优先于 `label`;都不传时手柄名称是「数值」 */
"aria-label"?: string
/** 标签;是字符串时同时作为手柄的读屏名称 */
label?: React.ReactNode
/**
* 标签与读数的位置:`inside` 放在轨道内两端,`outside` 放在轨道上方一行
* @default "inside"
*/
labelPlacement?: ValueSliderLabelPlacement
/**
* 已缓冲到的值(例如视频已加载到第 N 秒),在已选填充下面画一段次级填充;仅单值模式生效
*/
buffer?: number
/** 读数与 `aria-valuetext` 的格式化函数,优先于 `format` */
formatValue?: (value: number) => string
/**
* 是否显示读数。区间时显示为 `起点 – 终点`
* @default true
*/
showValue?: boolean
/** 轨道左端外的图标,随左侧过冲平移 */
startIcon?: React.ReactNode
/** 轨道右端外的图标,随右侧过冲平移 */
endIcon?: React.ReactNode
/**
* 轨道内的刻度线。数字表示等分段数(画 n - 1 条内部刻度线),数组表示显式刻度值;
* 同时决定 `snapToTicks` 的吸附目标。建议刻度落在 `step` 上,不在的会对齐到最近的 `step`
*/
ticks?: number | readonly number[]
/**
* 指针松手时(拖动或点击轨道)若与最近刻度的距离不超过范围的 3%,吸到该刻度并触发 `onValueChange`;
* 键盘逐步移动不吸附
* @default false
*/
snapToTicks?: boolean
/**
* 拖出轨道两端时的最大过冲像素,`0` 关闭弹性
* @default 12
*/
elasticity?: number
/**
* 尺寸档,与 `variant` 一起决定轨道高度:
* `bar` 为 sm 28px / default 32px / lg 36px,`compact` 为 18 / 22 / 24px,`slim` 为 6 / 8 / 10px
* @default "default"
*/
size?: ValueSliderSize
/**
* 轨道高度档,三档都是同一种方条:`bar` 高轨,标签与读数在轨道里;
* `compact` 中等高度,文字仍在轨道里、字号收到 xs;
* `slim` 矮轨,标签与读数移到轨道上方(此时忽略 `labelPlacement`),竖线手柄比轨道高出一截
* @default "bar"
*/
variant?: ValueSliderVariant
/**
* 色调:填充取 `--flow-tone` 的淡色(`slim` 加深一档),手柄取实色;不传时用前景色
*/
tone?: FlowTone
/**
* 读数可直接输入:点读数(或在手柄上按回车)换成输入框,回车 / 失焦提交,Esc 放弃。
* 输入值会夹在 `min`–`max` 之间并对齐到 `step`;区间模式下不可输入
* @default false
*/
editable?: boolean
/**
* 标记为「已改动」:标签与读数改用前景色,根元素带 `data-modified`。
* 控制面板里用来区分改过的参数与默认值
* @default false
*/
modified?: boolean
/**
* 拖动时是否有刻度与碰边的音效(尊重本地静音偏好)
* @default false
*/
sound?: boolean
}
type ValueSliderTuning = Partial<
Pick<
ValueSliderProps,
| "elasticity"
| "size"
| "variant"
| "ticks"
| "snapToTicks"
| "showValue"
| "sound"
>
> & {
/** `false` 表示去掉色调 */
tone?: FlowTone | false
/** 回报示例自己的实际取值(未被覆盖前),面板据此显示当前值 */
onReport?: (report: ValueSliderReport) => void
}
type ValueSliderReport = {
elasticity: number
size: ValueSliderSize
variant: ValueSliderVariant
tone: FlowTone | undefined
/** 示例用显式刻度数组时回报 `undefined`,面板显示默认的 0 */
ticks: number | undefined
snapToTicks: boolean
showValue: boolean
sound: boolean
}
export type {
ValueSliderLabelPlacement,
ValueSliderProps,
ValueSliderReport,
ValueSliderSize,
ValueSliderTuning,
ValueSliderValue,
ValueSliderVariant,
}
/** 把数值夹在 [0, 1] 之间。 */
function clamp01(value: number) {
if (!Number.isFinite(value)) return 0
return Math.min(1, Math.max(0, value))
}
/**
* 橡皮筋拉伸量:指针越过轨道边界 `distance` 像素时的视觉拉伸。
* 曲线是饱和的(越拉越难拉),结果永远小于 `max`,所以轨道不会被拉得没边。
*/
function rubberBand(distance: number, max: number) {
if (!Number.isFinite(max) || max <= 0 || distance <= 0) return 0
return max * (1 - 1 / (1 + (0.55 * distance) / max))
}
/** 值在轨道内的百分比(0–100)。 */
function valueToPercent(value: number, min: number, max: number) {
const span = max - min
if (!Number.isFinite(span) || span <= 0) return 0
return Math.min(100, Math.max(0, ((value - min) / span) * 100))
}
/** step 的小数位数:1 → 0,0.5 → 1,0.05 → 2,用于读数与读屏文本。 */
function stepDecimals(step: number) {
if (!Number.isFinite(step) || step <= 0) return 0
const text = String(step)
const exponential = text.split("e-")[1]
if (exponential) return Math.min(6, Number(exponential))
const dot = text.indexOf(".")
return dot === -1 ? 0 : Math.min(6, text.length - dot - 1)
}
/** 单值 / 区间统一取成升序数组,区间时报文的顺序与 Base UI 一致。 */
function toNumberList(value: number | readonly number[]) {
if (typeof value === "number") return [value]
return [...value].sort((a, b) => a - b)
}
/**
* 把 `ticks` 属性换算成刻度值数组(含两端)。
* 数字表示等分段数,返回 n + 1 个值,其中 n - 1 个落在轨道内部;数组则原样排序、去重并夹在范围内。
*/
function resolveTicks(
ticks: number | readonly number[] | undefined,
min: number,
max: number,
) {
if (ticks === undefined) return []
if (typeof ticks !== "number") {
const list = ticks
.filter((item) => Number.isFinite(item) && item >= min && item <= max)
.sort((a, b) => a - b)
return list.filter((item, index) => index === 0 || item !== list[index - 1])
}
const count = Math.floor(ticks)
if (!Number.isFinite(count) || count < 2) return []
return Array.from(
{ length: count + 1 },
(_, index) => min + ((max - min) * index) / count,
)
}
/** 离该值最近的刻度值;没有刻度时返回 undefined。 */
function nearestTick(value: number, ticks: readonly number[]) {
let nearest: number | undefined
let distance = Infinity
for (const tick of ticks) {
const next = Math.abs(tick - value)
if (next < distance) {
distance = next
nearest = tick
}
}
return nearest
}
/** 从 `from` 走到 `to` 的过程中是否越过了某个刻度(含 `to` 所在的刻度,不含 `from`)。 */
function crossedTick(from: number, to: number, ticks: readonly number[]) {
const low = Math.min(from, to)
const high = Math.max(from, to)
return ticks.some((tick) => tick > low && tick <= high)
}
/**
* 把值对齐到 `min + n * step` 并夹在 [min, max],值不是有限数时返回 `undefined`。
*
* 浮点误差靠「搬到整数域再算」消除:按 min / step / value 三者里最多的小数位放大成整数,
* 算出整数序号后再除回去,所以 0.1 这种二进制表示不精确的 step 也不会跑出 0.30000000000000004。
* 上界直接用 `max`(与 Base UI 一致:即使 `max` 不在步长网格上,键盘与指针也能取到它)。
*
* 例:min=0.05 step=0.1 tick=0.15 → 0.15;min=2700 step=100 tick=3650 → 3700。
*/
function alignToStep(value: number, min: number, max: number, step: number) {
if (!Number.isFinite(value)) return undefined
const clamped = Math.min(max, Math.max(min, value))
if (!Number.isFinite(step) || step <= 0) return clamped
const decimals = Math.max(
stepDecimals(min),
stepDecimals(step),
stepDecimals(clamped),
)
const factor = 10 ** decimals
const minInt = Math.round(min * factor)
const stepInt = Math.max(1, Math.round(step * factor))
const valueInt = Math.round(clamped * factor)
const alignedInt = minInt + Math.round((valueInt - minInt) / stepInt) * stepInt
// 除法得到的是该十进制值最接近的 double,等价于字面量,不需要再 toFixed
const aligned = alignedInt / factor
return Math.min(max, Math.max(min, aligned))
}
type SnapToTicksOptions = {
/** 吸附目标(含两端的刻度值) */
ticks: readonly number[]
min: number
max: number
/** 吸附后的值要对齐到 `min + n * step`,否则会提交不在步长上的值 */
step: number
/** 区间模式下两个值之间至少相隔几个 step */
minGapSteps?: number
/** 与刻度的最大距离,绝对值(由调用方按值域比例算好) */
tolerance: number
}
/**
* 磁吸:凡是离刻度不超过 `tolerance` 的值,吸到该刻度并按 step 对齐后的位置。
* 区间模式下吸附后的值必须保持顺序、并与相邻值至少相隔 `minGapSteps` 个 step,
* 不满足就放弃这个值的吸附(宁可不动,也不要产生倒序或贴合的两个手柄)。
*/
function snapValuesToTicks(
values: readonly number[],
options: SnapToTicksOptions,
) {
const { ticks, min, max, step, minGapSteps = 0, tolerance } = options
const gap = Math.max(0, minGapSteps) * (Number.isFinite(step) ? step : 0)
const result = [...values]
for (let index = 0; index < values.length; index += 1) {
const current = values[index]
const nearest = nearestTick(current, ticks)
if (nearest === undefined) continue
if (Math.abs(nearest - current) > tolerance) continue
const aligned = alignToStep(nearest, min, max, step)
if (aligned === undefined) continue
const before = result[index - 1]
const after = result[index + 1]
if (before !== undefined && aligned < before + gap) continue
if (after !== undefined && aligned > after - gap) continue
result[index] = aligned
}
return result
}
/** 单值与区间共用的相等判断,用于磁吸前后比较。 */
function sameValue(
a: number | readonly number[],
b: number | readonly number[],
) {
if (typeof a === "number" || typeof b === "number") return a === b
const left = a as readonly number[]
const right = b as readonly number[]
return (
left.length === right.length &&
left.every((item, index) => item === right[index])
)
}
export {
alignToStep,
clamp01,
crossedTick,
nearestTick,
resolveTicks,
rubberBand,
sameValue,
snapValuesToTicks,
stepDecimals,
toNumberList,
valueToPercent,
}
export type { SnapToTicksOptions }
"use client"
import * as React from "react"
/**
* 滑块的音效:用 Web Audio 现场合成,不需要音频文件。
* 刻度是一声很短的高频「嗒」,音高跟着值走;碰到轨道两端是一声低闷的「咚」。
* AudioContext 在第一次真正发声(用户手势里)时才创建,满足浏览器的自动播放限制。
*/
let context: AudioContext | null = null
function audio() {
if (typeof window === "undefined") return null
if (!context) {
const Ctor =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext
if (!Ctor) return null
context = new Ctor()
}
if (context.state === "suspended") void context.resume()
return context
}
const clamp = (value: number) => Math.min(1, Math.max(0, value))
/** 一段指数衰减的正弦:用来做「咚」的主体。 */
function tone(from: number, to: number, duration: number, volume: number) {
const ctx = audio()
if (!ctx) return
const now = ctx.currentTime
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = "sine"
osc.frequency.setValueAtTime(from, now)
osc.frequency.exponentialRampToValueAtTime(to, now + duration)
gain.gain.setValueAtTime(0.0001, now)
gain.gain.exponentialRampToValueAtTime(volume, now + 0.005)
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration)
osc.connect(gain).connect(ctx.destination)
osc.start(now)
osc.stop(now + duration + 0.02)
}
/** 一段带通滤过的噪声:构成「嗒」的颗粒感,长度只有十几毫秒。 */
function noise(frequency: number, duration: number, volume: number, q = 2.4) {
const ctx = audio()
if (!ctx) return
const now = ctx.currentTime
const length = Math.max(1, Math.ceil(ctx.sampleRate * duration))
const buffer = ctx.createBuffer(1, length, ctx.sampleRate)
const data = buffer.getChannelData(0)
for (let i = 0; i < length; i++)
data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 2
const source = ctx.createBufferSource()
source.buffer = buffer
const filter = ctx.createBiquadFilter()
filter.type = "bandpass"
filter.frequency.value = frequency
filter.Q.value = q
const gain = ctx.createGain()
gain.gain.value = volume
source.connect(filter).connect(gain).connect(ctx.destination)
source.start(now)
}
/**
* 一个刻度的滴答声。`pitch` 0–1 表示值在范围里的位置,越高越尖:
* 高频带通噪声给「嗒」的触感,叠一声极短的正弦给它音高。
*/
function playTick(pitch = 0.5) {
const t = clamp(pitch)
noise(2400 + 2600 * t, 0.011, 0.05 + 0.04 * t, 3)
tone(1100 + 1000 * t, 900 + 800 * t, 0.012, 0.028 + 0.012 * t)
}
/** 碰到轨道两端的一声低闷「咚」:低频下滑 + 一点点噪声让它不空。 */
function playEdge() {
tone(168, 62, 0.15, 0.17)
noise(420, 0.045, 0.06, 1.1)
}
/* ---------- 静音偏好:存在 localStorage,全站带音效的组件共用 ---------- */
const STORAGE_KEY = "travinsy:sound:muted"
/** 旧版本用过的键:读到新键没写过时按它迁移,保证升级后偏好不丢。 */
const LEGACY_KEYS = ["isometric-mark:muted"]
const WATCHED_KEYS = [STORAGE_KEY, ...LEGACY_KEYS]
/**
* 同页广播:`storage` 事件只发给「其他」标签页,本页写 localStorage 不会触发它。
* 带音效的组件各自有一份静音模块(互不 import),所以约定用同一个事件名互相通知。
*/
const MUTED_EVENT = "travinsy:sound-muted-change"
const listeners = new Set<() => void>()
function readMuted() {
try {
const current = window.localStorage.getItem(STORAGE_KEY)
if (current !== null) return current === "1"
return LEGACY_KEYS.some((key) => window.localStorage.getItem(key) === "1")
} catch {
return false
}
}
function setMuted(muted: boolean) {
try {
window.localStorage.setItem(STORAGE_KEY, muted ? "1" : "0")
} catch {
// 隐私模式等存不了时只在本页生效
}
listeners.forEach((listener) => listener())
// 通知同页其他带音效的组件
window.dispatchEvent(new Event(MUTED_EVENT))
}
function subscribe(listener: () => void) {
listeners.add(listener)
const notify = () => listener()
const onStorage = (event: StorageEvent) => {
if (event.key === null || WATCHED_KEYS.includes(event.key)) notify()
}
window.addEventListener("storage", onStorage)
window.addEventListener(MUTED_EVENT, notify)
return () => {
listeners.delete(listener)
window.removeEventListener("storage", onStorage)
window.removeEventListener(MUTED_EVENT, notify)
}
}
/** 读取 / 切换音效静音。服务端与水合阶段按未静音渲染,挂载后再读本地偏好。 */
function useValueSliderMuted() {
const muted = React.useSyncExternalStore(subscribe, readMuted, () => false)
return [muted, setMuted] as const
}
export { playEdge, playTick, useValueSliderMuted }
"use client"
import * as React from "react"
import {
animate,
useMotionValue,
useReducedMotion,
useTransform,
type AnimationPlaybackControls,
type MotionValue,
} from "motion/react"
import { clamp01, rubberBand } from "./utils"
/** 打开 / 关闭过冲的弹簧:`visualDuration` 是回到原位的时间,`bounce` 给它一点回弹。 */
const RECOIL_SPRING = {
type: "spring",
visualDuration: 0.4,
bounce: 0.45,
} as const
/** 轨道在过冲时的最大纵向压缩比例。 */
const SQUASH_RATIO = 0.12
type ElasticSide = "start" | "end"
type UseElasticPullOptions = {
/** 拖出两端的最大过冲像素,`0` 关闭弹性 */
elasticity: number
/** 禁用时立刻收手:移除监听、停止弹簧、过冲归零 */
disabled?: boolean
/** 某一侧被拉开时回调,一次手势里每侧只报一次(用来放碰边音效) */
onEdge?: (side: ElasticSide) => void
}
type ElasticPull = {
/** 有符号的拉伸像素:负数往左、正数往右 */
pull: MotionValue<number>
/** 视觉层的水平位移,和 `scaleX` 配合成「拉住一端」的样子 */
x: MotionValue<number>
scaleX: MotionValue<number>
scaleY: MotionValue<number>
startIconX: MotionValue<number>
endIconX: MotionValue<number>
startIconScale: MotionValue<number>
endIconScale: MotionValue<number>
/** 当前拉向哪一端,用于根元素的 `data-overshoot` */
side: ElasticSide | null
/** 挂在 `Slider.Control` 上:只量 Control 的矩形,视觉层自己缩放 */
onPointerDown: (event: React.PointerEvent<HTMLElement>) => void
/** 键盘在手柄已经位于端点时继续往同方向走,拉一下再弹回来 */
pulse: (side: ElasticSide) => void
}
/**
* 橡皮筋过冲:指针拖出轨道后把轨道往那一侧拉长,松手用弹簧收回。
* 全部走 MotionValue,拖动期间不触发 React 渲染;几何量自始至终只来自 Control 的矩形,
* 所以缩放视觉层不会影响 Base UI 把指针换算成数值。
*/
function useElasticPull({
elasticity,
disabled = false,
onEdge,
}: UseElasticPullOptions): ElasticPull {
const reduced = useReducedMotion() ?? false
const pull = useMotionValue(0)
// 按下那一刻量到的轨道宽度,只用来把拉伸像素换算成缩放比例
const width = useMotionValue(1)
const scaleX = useTransform(
() => 1 + Math.abs(pull.get()) / Math.max(1, width.get()),
)
const scaleY = useTransform(
() =>
1 -
SQUASH_RATIO *
clamp01(Math.abs(pull.get()) / Math.max(1, elasticity || 1)),
)
// 固定以中心为原点,再平移半个拉伸量:视觉上等于拉住一端,换边时不需要切换 transform-origin
const x = useTransform(pull, (current) => current / 2)
const startIconX = useTransform(pull, (current) => Math.min(0, current))
const endIconX = useTransform(pull, (current) => Math.max(0, current))
const startIconScale = useMotionValue(1)
const endIconScale = useMotionValue(1)
const [side, setSide] = React.useState<ElasticSide | null>(null)
const sideRef = React.useRef<ElasticSide | null>(null)
// 手势编号:每次按下 / 复位都 +1,弹簧完成时据此判断自己是不是已经被新手势取代
const gestureRef = React.useRef(0)
const springRef = React.useRef<AnimationPlaybackControls | null>(null)
const detachRef = React.useRef<(() => void) | null>(null)
// 窗口级监听里要读最新属性,用 ref 保存,免得每帧重建监听
const optionsRef = React.useRef({ elasticity, reduced, onEdge })
React.useEffect(() => {
optionsRef.current = { elasticity, reduced, onEdge }
})
const setSideSafe = React.useCallback((next: ElasticSide | null) => {
if (sideRef.current === next) return
sideRef.current = next
setSide(next)
}, [])
/** 停下正在跑的回弹、过冲归零,并让旧弹簧的完成回调失效。 */
const reset = React.useCallback(() => {
gestureRef.current += 1
springRef.current?.stop()
springRef.current = null
pull.set(0)
setSideSafe(null)
}, [pull, setSideSafe])
const recoil = React.useCallback(() => {
const id = gestureRef.current
springRef.current?.stop()
springRef.current = animate(pull, 0, {
...RECOIL_SPRING,
onComplete: () => {
// 旧手势的弹簧到期时才回来:不许它动新手势的状态
if (gestureRef.current !== id) return
springRef.current = null
setSideSafe(null)
},
})
}, [pull, setSideSafe])
const nudgeIcon = React.useCallback(
(target: ElasticSide) => {
if (reduced) return
animate(target === "start" ? startIconScale : endIconScale, [1, 1.25, 1], {
duration: 0.32,
ease: "easeOut",
})
},
[reduced, startIconScale, endIconScale],
)
const onPointerDown = React.useCallback(
(event: React.PointerEvent<HTMLElement>) => {
if (disabled || event.button !== 0) return
// 并发指针(另一根手指还按着)时只保留最新这一组:先把上一组的 window 监听解掉,
// 否则 detachRef 会被覆盖,旧手势的 move/release 再也解不掉
detachRef.current?.()
// 新的手势开始:上一段回弹(上一次拖动或键盘脉冲留下的)立刻作废
reset()
const state = optionsRef.current
if (state.reduced || state.elasticity <= 0) return
// 用按下这一刻的 Control 矩形量过冲;Control 自己不缩放,之后一直有效
const rect = event.currentTarget.getBoundingClientRect()
width.set(Math.max(1, rect.width))
const pointerId = event.pointerId
const nudged = { start: false, end: false }
const detach = () => {
window.removeEventListener("pointermove", move)
window.removeEventListener("pointerup", release)
window.removeEventListener("pointercancel", release)
window.removeEventListener("lostpointercapture", release)
window.removeEventListener("blur", release)
if (detachRef.current === detach) detachRef.current = null
}
// 松手、被系统取消、指针捕获丢失、窗口失焦都走这里;重复调用是安全的
const release = (native: Event) => {
if ("pointerId" in native) {
const stale = native as PointerEvent
if (stale.pointerId !== pointerId) return
}
detach()
if (pull.get() === 0) setSideSafe(null)
else recoil()
}
const move = (native: PointerEvent) => {
if (native.pointerId !== pointerId) return
const latest = optionsRef.current
if (latest.reduced || latest.elasticity <= 0) return
const beyondEnd = native.clientX - rect.right
const beyondStart = rect.left - native.clientX
const next = beyondEnd > 0 ? "end" : beyondStart > 0 ? "start" : null
if (next === null) {
setSideSafe(null)
if (pull.get() !== 0) pull.set(0)
return
}
// 拖动时别选中旁边的文字
native.preventDefault()
const stretch = rubberBand(
Math.max(beyondEnd, beyondStart),
latest.elasticity,
)
pull.set(next === "end" ? stretch : -stretch)
setSideSafe(next)
if (!nudged[next]) {
nudged[next] = true
nudgeIcon(next)
latest.onEdge?.(next)
}
}
window.addEventListener("pointermove", move, { passive: false })
window.addEventListener("pointerup", release)
window.addEventListener("pointercancel", release)
window.addEventListener("lostpointercapture", release)
window.addEventListener("blur", release)
detachRef.current = detach
},
[disabled, reset, width, nudgeIcon, pull, recoil, setSideSafe],
)
const pulse = React.useCallback(
(target: ElasticSide) => {
if (reduced || elasticity <= 0) return
reset()
setSideSafe(target)
nudgeIcon(target)
pull.set(target === "end" ? elasticity * 0.4 : elasticity * -0.4)
recoil()
},
[
reduced,
elasticity,
reset,
setSideSafe,
nudgeIcon,
pull,
recoil,
],
)
// 拖动途中被禁用:立刻收手,别让 window 监听和回弹继续跑
React.useEffect(() => {
if (!disabled) return
reset()
detachRef.current?.()
}, [disabled, reset])
React.useEffect(
() => () => {
gestureRef.current += 1
springRef.current?.stop()
detachRef.current?.()
},
[],
)
return {
pull,
x,
scaleX,
scaleY,
startIconX,
endIconX,
startIconScale,
endIconScale,
side,
onPointerDown,
pulse,
}
}
export { useElasticPull }
export type { ElasticPull, ElasticSide, UseElasticPullOptions }
"use client"
import * as React from "react"
/** 服务端渲染时没有布局,用 useEffect 代替 useLayoutEffect,避免 React 警告。 */
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect
/** 手柄让位时给文字两边留的余量(像素)。 */
const PADDING = 16
/** 还没量到轨道宽度时,按这个比例判断「手柄压在端点附近」。 */
const FIRST_FRAME_RATIO = 15
type HandleDodge = {
/** 挂在轨道上 */
trackRef: React.RefObject<HTMLDivElement | null>
/** 挂在轨道内的标签上 */
labelRef: React.RefObject<HTMLSpanElement | null>
/** 挂在轨道内的读数上 */
valueRef: React.RefObject<HTMLSpanElement | null>
/** 手柄在 `percent` 处时要不要让开文字 */
isNear: (percent: number) => boolean
}
/**
* 手柄让开轨道两端的标签与读数:量出文字宽度换算成百分比阈值。
* 首帧(还没量到宽度)先按「靠近端点」处理,量完再按真实阈值,
* 这样静态 HTML 与首屏也不会把手柄压在文字上。
*/
function useHandleDodge(enabled: boolean): HandleDodge {
const trackRef = React.useRef<HTMLDivElement>(null)
const labelRef = React.useRef<HTMLSpanElement>(null)
const valueRef = React.useRef<HTMLSpanElement>(null)
const [metrics, setMetrics] = React.useState({ track: 0, label: 0, value: 0 })
useIsomorphicLayoutEffect(() => {
if (!enabled) return
const track = trackRef.current
if (!track) return
const measure = () =>
setMetrics((previous) => {
const next = {
track: track.clientWidth,
label: labelRef.current?.offsetWidth ?? 0,
value: valueRef.current?.offsetWidth ?? 0,
}
return previous.track === next.track &&
previous.label === next.label &&
previous.value === next.value
? previous
: next
})
// 先同步量一次(在浏览器绘制之前就能拿到真实宽度),再由 ResizeObserver 跟进
measure()
const observer = new ResizeObserver(measure)
for (const element of [track, labelRef.current, valueRef.current])
if (element) observer.observe(element)
return () => observer.disconnect()
}, [enabled])
const ratio = (width: number) =>
metrics.track > 0 ? ((width + PADDING) / metrics.track) * 100 : 0
const startLimit = ratio(metrics.label)
const endLimit = ratio(metrics.value)
const isNear = (percent: number) => {
if (!enabled) return false
if (metrics.track === 0)
return percent < FIRST_FRAME_RATIO || percent > 100 - FIRST_FRAME_RATIO
return (
(startLimit > 0 && percent <= startLimit) ||
(endLimit > 0 && percent >= 100 - endLimit)
)
}
return { trackRef, labelRef, valueRef, isNear }
}
export { useHandleDodge }
export type { HandleDodge }
"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 }