滑块气泡
数值气泡跟随滑块圆钮移动,拖动时放大并朝移动方向倾斜。
在原生 <input type="range">(0–100)上方为 React 滑块添加数值气泡,这样键盘和屏幕阅读器支持都无需额外实现。把数值按百分比写入外层容器的 --pct,用它通过 linear-gradient 填充轨道,并用 left: calc(var(--pct) * 1% + 偏移量) 把气泡定位在圆钮正上方。拖动期间(从 pointerdown 到 pointerup),气泡从 0.85 放大到 1,并朝移动方向最多倾斜 12 度,停止移动 120ms 后缓回 0 度。过渡使用 180ms 的 cubic-bezier(0.16, 1, 0.3, 1)。数值用 tabular-nums 显示,例如“64k”,输入框的标签为“预算”。在 prefers-reduced-motion 下气泡保持竖直,不做缩放。
源代码
'use client'
import { useId, useRef, useState } from 'react'
import s from './demo.module.css'
export default function RangeBubble({ labels }: { labels: Record<string, string> }) {
const [value, setValue] = useState(64)
const [dragging, setDragging] = useState(false)
const [tilt, setTilt] = useState(0)
const settle = useRef<ReturnType<typeof setTimeout>>(undefined)
const id = useId()
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const next = Number(e.target.value)
if (dragging) setTilt(Math.max(-12, Math.min(12, (next - value) * 4)))
clearTimeout(settle.current)
settle.current = setTimeout(() => setTilt(0), 120)
setValue(next)
}
return (
<div className={s.wrap} style={{ '--pct': value } as React.CSSProperties}>
<label htmlFor={id} className={s.label}>
{labels.label}
</label>
<div className={s.track}>
<output
htmlFor={id}
className={s.bubble}
data-dragging={dragging || undefined}
style={{ '--tilt': `${tilt}deg` } as React.CSSProperties}
>
{value}
{labels.unit}
</output>
<input
id={id}
type="range"
min={0}
max={100}
value={value}
className={s.input}
onChange={onChange}
onPointerDown={() => setDragging(true)}
onPointerUp={() => setDragging(false)}
onBlur={() => setDragging(false)}
/>
</div>
</div>
)
}
.wrap { display: grid; gap: 8px; width: min(280px, 100%); }
.label { color: var(--stage-muted); font: 500 var(--text-sm) / 1 var(--font-body); }
.track { position: relative; padding-top: 48px; }
.bubble {
--tilt: 0deg;
position: absolute;
top: 0;
/* the thumb is 22px wide, so its centre travels from 11px to (100% - 11px) */
left: calc(11px + (100% - 22px) * var(--pct) / 100);
padding: 6px 10px;
border-radius: 10px;
background: var(--stage-accent);
color: var(--stage-accent-ink);
font: 600 var(--text-sm) / 1 var(--font-body);
font-variant-numeric: tabular-nums;
white-space: nowrap;
transform: translateX(-50%) scale(0.85) rotate(var(--tilt));
transform-origin: 50% 130%;
transition: transform 180ms var(--ease-out);
}
.bubble::after {
content: '';
position: absolute;
left: 50%;
bottom: -5px;
width: 10px;
height: 10px;
background: inherit;
transform: translateX(-50%) rotate(45deg);
z-index: -1;
}
.bubble[data-dragging] { transform: translateX(-50%) scale(1) rotate(var(--tilt)); }
.input {
width: 100%;
height: 22px;
margin: 0;
background: transparent;
appearance: none;
cursor: pointer;
}
.input::-webkit-slider-runnable-track {
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, var(--stage-accent) calc(var(--pct) * 1%), var(--stage-rule) 0);
}
.input::-moz-range-track {
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, var(--stage-accent) calc(var(--pct) * 1%), var(--stage-rule) 0);
}
.input::-webkit-slider-thumb {
width: 22px;
height: 22px;
margin-top: -8px;
border: 3px solid var(--stage-accent);
border-radius: 50%;
background: var(--stage-ink);
appearance: none;
}
.input::-moz-range-thumb {
width: 16px;
height: 16px;
border: 3px solid var(--stage-accent);
border-radius: 50%;
background: var(--stage-ink);
}
.input:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 4px; border-radius: 6px; }
@media (prefers-reduced-motion: reduce) {
.bubble, .bubble[data-dragging] { transform: translateX(-50%); transition: none; }
}