返回目录
唤醒滑杆Wake Slider
components/WakeSlider.astro
竖条排成的轨道,手柄附近的条被「唤醒」抬起来,拖得越快抬得越高;手柄松手后按 glide 落定,尾迹按 skew 往身后偏。键盘也能操作:方向键走一格,PageUp / PageDown 走十格,Home / End 到两头。
拖一下 —— 条跟着手柄跑,甩得越快尾迹抬得越高。
硬性提示词
## Ori 的 <WakeSlider /> 组件
把这个组件接进你的项目里 —— Ori 自己的组件。
### Component: WakeSlider
### Variant: JavaScript + CSS
### Dependencies: motion
---
### Usage Example
```jsx
import WakeSlider from './WakeSlider';
<div style={{ width: 320 }}>
<WakeSlider
defaultValue={50}
min={0}
max={100}
step={1}
bars={32}
height={56}
restHeight={12}
gap={4}
fillColor="#f5f5f5"
trackColor="#27272a"
sensitivity={1}
reach={6}
skew={0.6}
glide={0.3}
smoothing={100}
showValue
onChange={value => console.log('value', value)}
/>
</div>
```
### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| value | number | undefined | Controlled value. |
| defaultValue | number | 50 | Initial value when uncontrolled. |
| onChange | (value: number) => void | - | Called on every committed change, from the pointer or the keyboard. |
| min | number | 0 | Lowest value. |
| max | number | 100 | Highest value. |
| step | number | 1 | Snapping grid. Large steps move the handle in notches, and the wake pulses per notch. |
| bars | number | 32 | How many bars draw the track. |
| height | number | 56 | Full bar height in pixels, the ceiling of the wake. |
| restHeight | number | 12 | Bar height at rest in pixels. |
| gap | number | 4 | Space between bars in pixels. |
| fillColor | string | "#f5f5f5" | Lit bars, up to the value. |
| trackColor | string | "#27272a" | Unlit bars. |
| crestColor | string | "" | Optional tint the raised bars take on by their lift. Empty renders no tint layer. |
| sensitivity | number | 1 | How easily speed raises the wake. Low needs a flick; high lifts it on a stroll. |
| reach | number | 6 | Half-width of the wake at full speed, in bars. |
| skew | number | 0.6 | How much wider the wake is behind the handle than ahead. 0 is symmetric. |
| glide | number | 0.3 | Seconds the handle takes to settle. Short hugs the finger; long floats behind it. |
| smoothing | number | 100 | Milliseconds of velocity lag. Low twitches with every change of speed; high lingers. |
| showValue | boolean | false | Shows the value beside the track. |
| formatValue | (value: number) => string | - | Formats the readout and the announced value. |
| disabled | boolean | false | Fades the slider and ignores input. |
| ariaLabel | string | "Value" | Accessible name of the slider. |
| className | string | "" | Extra classes for the root. |
### Full Component Source
```jsx
'use client';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { frame, useMotionValue, useMotionValueEvent, useReducedMotion, useSpring, useVelocity } from 'motion/react';
import './WakeSlider.css';
const SETTLE = 9.23;
const FULL_SPEED = 320;
const MIN_REACH = 1.5;
const FLAT = 0.002;
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const smoothstep = s => s * s * (3 - 2 * s);
const toPct = (v, min, max) => (max > min ? ((v - min) / (max - min)) * 100 : 0);
const snap = (v, min, max, step) => {
if (!(max > min)) return min;
if (!(step > 0)) return clamp(v, min, max);
const lastWhole = min + Math.floor(+((max - min) / step).toFixed(6)) * step;
const grid = clamp(Math.round((v - min) / step) * step + min, min, lastWhole);
return +(lastWhole < max && Math.abs(v - max) <= Math.abs(v - grid) ? max : grid).toFixed(6);
};
export default function WakeSlider({
value: valueProp,
defaultValue = 50,
onChange,
min = 0,
max = 100,
step = 1,
bars = 32,
height = 56,
restHeight = 12,
gap = 4,
fillColor = '#f5f5f5',
trackColor = '#27272a',
crestColor = '',
sensitivity = 1,
reach = 6,
skew = 0.6,
glide = 0.3,
smoothing = 100,
showValue = false,
formatValue,
disabled = false,
ariaLabel = 'Value',
className = ''
}) {
const [inner, setInner] = useState(defaultValue);
const value = clamp(valueProp ?? inner, min, max);
const pct = toPct(value, min, max);
const reduce = useReducedMotion();
const format = formatValue ?? String;
const rest = Math.min(restHeight, height - 1) / height;
const rootRef = useRef(null);
const trackRef = useRef(null);
const handleRef = useRef(null);
const barEls = useRef([]);
const crestEls = useRef([]);
const pointerId = useRef(null);
const lastAmp = useRef(0);
const latest = useRef(value);
latest.current = value;
const target = useMotionValue(pct);
const w0 = SETTLE / glide;
const head = useSpring(target, { stiffness: w0 * w0, damping: 2 * w0, mass: 1 });
const rawSpeed = useVelocity(head);
const wv = 2000 / smoothing;
const speed = useSpring(rawSpeed, { stiffness: wv * wv, damping: 2.5 * wv, mass: 1 });
useEffect(() => {
target.set(pct);
}, [pct, target]);
const paint = (force = false) => {
const h = ((reduce ? target.get() : head.get()) / 100) * (bars - 1);
const v = reduce ? 0 : speed.get();
const amp = smoothstep(clamp((Math.abs(v) * sensitivity) / FULL_SPEED, 0, 1));
const dir = Math.sign(v) || 1;
const r = MIN_REACH + (reach - MIN_REACH) * amp;
const behind = r * (1 + skew);
const ahead = r * (1 - 0.5 * skew);
const lit = Math.round(h);
const flat = !force && amp < FLAT && lastAmp.current < FLAT;
for (let i = 0; i < bars; i++) {
const el = barEls.current[i];
if (!el) continue;
const on = i <= lit ? 'true' : 'false';
if (el.dataset.on !== on) el.dataset.on = on;
if (flat) continue;
const d = i - h;
const R = d * dir < 0 ? behind : ahead;
const lift = Math.abs(d) < R ? amp * Math.cos((Math.PI * d) / (2 * R)) ** 2 : 0;
el.style.transform = `scaleY(${rest + lift * (1 - rest)})`;
const crest = crestEls.current[i];
if (crest) crest.style.opacity = String(lift);
}
lastAmp.current = amp;
};
const paintRef = useRef(paint);
paintRef.current = paint;
const run = useCallback(() => paintRef.current(), []);
const schedule = useCallback(() => frame.render(run, false, true), [run]);
useMotionValueEvent(head, 'change', schedule);
useMotionValueEvent(speed, 'change', schedule);
useLayoutEffect(() => {
paintRef.current(true);
});
const commit = next => {
const clean = snap(next, min, max, step);
if (clean === latest.current) return;
latest.current = clean;
if (valueProp === undefined) setInner(clean);
onChange?.(clean);
};
const commitFromX = x => {
const track = trackRef.current;
if (!track) return;
const rect = track.getBoundingClientRect();
if (!rect.width) return;
let ratio = clamp((x - rect.left) / rect.width, 0, 1);
if (getComputedStyle(track).direction === 'rtl') ratio = 1 - ratio;
commit(min + ratio * (max - min));
};
const onPointerDown = e => {
if (disabled || pointerId.current !== null) return;
pointerId.current = e.pointerId;
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {}
rootRef.current?.removeAttribute('data-instant');
handleRef.current?.focus({ preventScroll: true });
commitFromX(e.clientX);
};
const onPointerMove = e => {
if (e.pointerId === pointerId.current) commitFromX(e.clientX);
};
const endDrag = e => {
if (e.pointerId !== pointerId.current) return;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {}
pointerId.current = null;
};
const onKeyDown = e => {
if (disabled) return;
const jumps = {
ArrowRight: value + step,
ArrowUp: value + step,
ArrowLeft: value - step,
ArrowDown: value - step,
PageUp: value + step * 10,
PageDown: value - step * 10,
Home: min,
End: max
};
if (!(e.key in jumps)) return;
e.preventDefault();
const clean = snap(jumps[e.key], min, max, step);
const p = toPct(clean, min, max);
rootRef.current?.setAttribute('data-instant', 'true');
head.jump(p);
speed.jump(0);
target.jump(p);
commit(clean);
};
const barNodes = useMemo(() => {
barEls.current.length = bars;
crestEls.current.length = bars;
return Array.from({ length: bars }, (_, i) => (
<span
key={i}
ref={el => {
barEls.current[i] = el;
}}
className="wake-slider__bar"
>
{crestColor ? (
<span
ref={el => {
crestEls.current[i] = el;
}}
className="wake-slider__crest"
/>
) : null}
</span>
));
}, [bars, crestColor]);
return (
<div
ref={rootRef}
className={`wake-slider${className ? ` ${className}` : ''}`}
aria-disabled={disabled || undefined}
style={{
'--ws-fill': fillColor,
'--ws-track': trackColor,
'--ws-crest': crestColor || fillColor,
'--ws-height': `${height}px`,
'--ws-gap': `${gap}px`,
'--ws-rest': rest
}}
>
<div
ref={trackRef}
className="wake-slider__track"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={endDrag}
>
{barNodes}
<button
ref={handleRef}
type="button"
role="slider"
className="wake-slider__handle"
tabIndex={disabled ? -1 : 0}
aria-label={ariaLabel}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={format(value)}
aria-disabled={disabled || undefined}
onKeyDown={onKeyDown}
/>
</div>
{showValue ? (
<span className="wake-slider__value" aria-hidden="true">
{format(value)}
</span>
) : null}
</div>
);
}
```
### Component CSS
```css
.wake-slider {
--ws-fill: #f5f5f5;
--ws-track: #27272a;
--ws-crest: #f5f5f5;
--ws-height: 56px;
--ws-gap: 4px;
--ws-rest: 0.214;
display: inline-flex;
align-items: center;
gap: 12px;
width: 100%;
color: inherit;
}
.wake-slider[aria-disabled='true'] {
opacity: 0.5;
pointer-events: none;
}
.wake-slider__track {
position: relative;
display: flex;
flex: 1 1 auto;
align-items: center;
gap: var(--ws-gap);
height: var(--ws-height);
min-height: 44px;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
@media (hover: hover) and (pointer: fine) {
.wake-slider__track {
cursor: grab;
}
.wake-slider__track:active {
cursor: grabbing;
}
}
.wake-slider__bar {
position: relative;
flex: 1 1 0;
min-width: 0;
height: var(--ws-height);
border-radius: 9999px;
background: var(--ws-track);
transform: scaleY(var(--ws-rest));
transform-origin: 50% 50%;
transition: background-color 120ms ease;
}
.wake-slider__bar[data-on='true'] {
background: var(--ws-fill);
}
.wake-slider[data-instant='true'] .wake-slider__bar {
transition-duration: 0ms;
}
.wake-slider__crest {
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--ws-crest);
opacity: 0;
}
.wake-slider__handle {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
border: 0;
border-radius: 12px;
background: transparent;
outline: none;
cursor: inherit;
touch-action: none;
-webkit-tap-highlight-color: transparent;
}
.wake-slider__value {
min-width: 3ch;
font-size: 13px;
font-variant-numeric: tabular-nums;
text-align: right;
opacity: 0.6;
}
@media (prefers-reduced-motion: reduce) {
.wake-slider__bar {
transform: scaleY(var(--ws-rest)) !important;
}
}
```
### 怎么接进项目
1. 装上上面列的依赖。
2. 把源码放进项目的组件目录。
3. 引进来,按上面的参数表调。
### More from Ori
更多组件在 Ori 的组件库里:<https://ottersnap.com/style/> —— 挑一个,复制提示词,接进你的项目。