返回目录
彗星拨盘Comet Dial
components/CometDial.astro
一圈 320° 的弧,读数在正中,缺口朝下。弧上任意一处都能按:读数落到那儿,表冠回弹一下;拖起来读数贴着手走,松手按甩出的速度带一段惯性滑过头,再被弹簧拉回来,回弹多大看甩得多狠。彗尾跟在读数身后,长度和粗细都按速度算,停了就收干净。中间的读数区是盲区,按下去不响应。键盘也能到:方向键一格(按住 Shift 十格),PageUp / PageDown 十格,Home / End 到两头。
弧上按一下,读数落到那儿、表冠回弹;拖起来跟着手走,甩出去带惯性滑一段,彗尾多长看速度。中间是盲区。
硬性提示词
## Ori 的 <CometDial /> 组件
把这个组件接进你的项目里 —— Ori 自己的组件。
### Component: CometDial
### Variant: JavaScript + CSS
### Dependencies: motion
---
### Usage Example
```jsx
import CometDial from './CometDial';
<CometDial
defaultValue={62}
min={0}
max={100}
step={1}
unit="%"
label="Level"
accent="#f5f5f5"
ink="#fdfdfd"
size={250}
sweep={320}
thickness={5}
speed={25}
tapBounce={0.2}
flickBounce={0.1}
momentum={1}
cometReach={180}
cometWidth={12}
onChange={value => console.log(value)}
onChangeEnd={(value, { velocity, bounce }) => console.log('settling toward', value, velocity, bounce)}
/>
```
### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| value | number | undefined | Controlled value. A change from outside launches the reading on the tap spring. |
| defaultValue | number | 62 | Initial value when uncontrolled. |
| min | number | 0 | Lowest value. |
| max | number | 100 | Highest value. |
| step | number | 1 | Snapping grid, and the keyboard nudge. |
| unit | string | "%" | Suffix after the figure. Empty hides it. |
| label | string | "Level" | Accessible name of the slider. |
| accent | string | "#f5f5f5" | The lit arc, the bead and the comet. |
| ink | string | "#fdfdfd" | The track and the readout. |
| size | number | 250 | Diameter in pixels. Everything inside scales with it. |
| sweep | number | 320 | Degrees the arc covers, with the gap centred at the bottom. |
| thickness | number | 5 | Stroke width of the track and the arc. The bead follows it. |
| speed | number | 25 | How quickly the reading arrives, 0 to 100. |
| tapBounce | number | 0.2 | Ring-down after a tap. 0 stops dead. |
| flickBounce | number | 0.1 | Ring-down after a full-speed flick. A slower release lands between the two. |
| momentum | number | 1 | How far a flick carries the reading past where you let go. 0 drops it where released. |
| cometReach | number | 180 | Degrees of trail behind the bead at full speed. |
| cometWidth | number | 12 | Extra stroke width at the head of the trail at full speed. |
| disabled | boolean | false | Dims the dial and ignores input. |
| onChange | (value: number) => void | - | Called on every snapped change, including during a drag. |
| onChangeEnd | (value: number, detail: { velocity: number; bounce: number }) => void | - | Called on release and on a key, with the release velocity and the bounce it earned. |
| className | string | "" | Extra classes for the root. |
### Full Component Source
```jsx
'use client';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { animate, useMotionValue, useReducedMotion } from 'motion/react';
import './CometDial.css';
const R = 80;
const DEAD = 44;
const K = 12;
const V_FULL = 4;
const V_FLICK = 2;
const TAU_FOLLOW = 0.06;
const TAU_V = 0.05;
const DECEL = 0.99;
const DRAG_PX = 10;
const STALE_MS = 80;
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const velocityOf = hist => {
if (hist.length < 2) return 0;
const a = hist[0];
const b = hist[hist.length - 1];
return ((b[1] - a[1]) / Math.max(1, b[0] - a[0])) * 1000;
};
const decimalsOf = step => {
const s = String(step);
const i = s.indexOf('.');
return i === -1 ? 0 : s.length - i - 1;
};
const pointAt = deg => {
const a = (deg * Math.PI) / 180;
return [100 + Math.cos(a) * R, 100 + Math.sin(a) * R];
};
const arcPath = (a0, a1) => {
const [x0, y0] = pointAt(a0);
const [x1, y1] = pointAt(a1);
return `M ${x0.toFixed(3)} ${y0.toFixed(3)} A ${R} ${R} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(3)} ${y1.toFixed(3)}`;
};
export default function CometDial({
value,
defaultValue = 62,
min = 0,
max = 100,
step = 1,
unit = '%',
label = 'Level',
accent = '#f5f5f5',
ink = '#fdfdfd',
size = 250,
sweep = 320,
thickness = 5,
speed = 25,
tapBounce = 0.2,
flickBounce = 0.1,
momentum = 1,
cometReach = 180,
cometWidth = 12,
disabled = false,
onChange,
onChangeEnd,
className = ''
}) {
const reduce = useReducedMotion();
const [dragging, setDragging] = useState(false);
const svgRef = useRef(null);
const litRef = useRef(null);
const headRef = useRef(null);
const comet = useRef([]);
const figure = useRef(null);
const target = useRef(clamp(value ?? defaultValue, min, max));
const grip = useRef(null);
const unbind = useRef(null);
const loop = useRef({ raf: 0, last: 0, v: 0, rPrev: null, text: '', valueText: '' });
const live = useRef({});
const reading = useMotionValue(target.current);
const range = Math.max(1e-9, max - min);
const gap = 360 - sweep;
const start = 90 + gap / 2;
const end = start + sweep;
const decimals = decimalsOf(step);
const k = 200 + (clamp(speed, 0, 100) / 100) * 700;
const crit = 2 * Math.sqrt(k);
const snap = v => (step > 0 ? clamp(Math.round((v - min) / step) * step + min, min, max) : clamp(v, min, max));
const commit = (v, finished, detail) => {
const s = snap(v);
if (s !== target.current) {
target.current = s;
onChange?.(s);
}
if (finished) onChangeEnd?.(s, detail ?? { velocity: 0, bounce: tapBounce });
};
const paintRef = useRef(null);
const tick = useCallback(now => paintRef.current?.(now), []);
const wake = () => {
const L = loop.current;
if (L.raf) return;
L.last = performance.now();
L.rPrev = null;
L.raf = requestAnimationFrame(tick);
};
const launch = (to, bounce, velocity = reading.getVelocity()) => {
reading.stop();
if (reduce) {
reading.jump(to);
wake();
return;
}
animate(reading, to, {
type: 'spring',
stiffness: k,
damping: crit * (1 - clamp(bounce, 0, 0.9)),
mass: 1,
velocity
});
wake();
};
const paint = now => {
const L = loop.current;
const svg = svgRef.current;
if (!svg) {
L.raf = 0;
return;
}
const dt = clamp((now - L.last) / 1000, 0, 0.05);
L.last = now;
const g = grip.current;
if (g && g.at != null) {
if (reduce) reading.jump(g.at);
else reading.set(reading.get() + (g.at - reading.get()) * (1 - Math.exp(-dt / TAU_FOLLOW)));
}
const r = reading.get();
const vRaw = L.rPrev == null || dt === 0 || reduce ? 0 : (r - L.rPrev) / dt / range;
L.rPrev = r;
L.v += (vRaw - L.v) * (1 - Math.exp(-dt / TAU_V));
const v = L.v;
const s = clamp(Math.abs(v) / V_FULL, 0, 1);
const dir = Math.sign(v) || 1;
const f = clamp((r - min) / range, 0, 1);
const ang = start + f * sweep;
if (litRef.current) litRef.current.setAttribute('d', f > 0.0005 ? arcPath(start, ang) : '');
if (headRef.current) {
const [cx, cy] = pointAt(ang);
headRef.current.setAttribute('cx', cx.toFixed(3));
headRef.current.setAttribute('cy', cy.toFixed(3));
}
const seg = (cometReach * s) / K;
for (let j = 0; j < K; j++) {
const el = comet.current[j];
if (!el) continue;
let a0 = dir > 0 ? ang - (j + 1) * seg : ang + j * seg;
let a1 = dir > 0 ? ang - j * seg : ang + (j + 1) * seg;
a0 = clamp(a0, start, end);
a1 = clamp(a1, start, end);
if (seg < 0.01 || a1 - a0 < 0.01) {
if (el.style.opacity !== '0') el.style.opacity = '0';
continue;
}
const w = 1 - j / K;
el.setAttribute('d', arcPath(a0, a1));
el.setAttribute('stroke-width', (thickness + cometWidth * w * s).toFixed(2));
el.style.opacity = (w * s).toFixed(3);
}
const shown = clamp(r, min, max).toFixed(decimals);
if (shown !== L.text) {
L.text = shown;
if (figure.current) figure.current.textContent = shown;
svg.setAttribute('aria-valuenow', shown);
}
const valueText = `${target.current}${unit}`;
if (valueText !== L.valueText) {
L.valueText = valueText;
svg.setAttribute('aria-valuetext', valueText);
}
const moving = !!g || reading.isAnimating() || Math.abs(v) > 0.002;
if (!moving) {
for (const el of comet.current) if (el && el.style.opacity !== '0') el.style.opacity = '0';
}
L.raf = moving ? requestAnimationFrame(tick) : 0;
};
paintRef.current = paint;
const localPoint = (cx, cy) => {
const b = svgRef.current.getBoundingClientRect();
return [((cx - b.left) / b.width) * 200 - 100, ((cy - b.top) / b.height) * 200 - 100];
};
const angleAt = (cx, cy) => {
const [x, y] = localPoint(cx, cy);
let rel = ((Math.atan2(y, x) * 180) / Math.PI - start + 720) % 360;
const g = grip.current;
if (rel > sweep) rel = g && g.side ? (g.side === 'hi' ? sweep : 0) : rel < sweep + gap / 2 ? sweep : 0;
else if (g) g.side = rel > sweep / 2 ? 'hi' : 'lo';
return min + (rel / sweep) * range;
};
const move = e => {
const g = grip.current;
if (!g || g.id !== e.pointerId) return;
if (!g.moved) {
if (Math.hypot(e.clientX - g.x0, e.clientY - g.y0) < DRAG_PX) return;
g.moved = true;
reading.stop();
}
g.at = angleAt(e.clientX, e.clientY);
g.hist.push([performance.now(), g.at]);
if (g.hist.length > 4) g.hist.shift();
commit(g.at, false);
wake();
};
const up = e => {
const g = grip.current;
if (!g || g.id !== e.pointerId) return;
grip.current = null;
unbind.current?.();
unbind.current = null;
setDragging(false);
try {
svgRef.current?.releasePointerCapture(e.pointerId);
} catch {}
if (!g.moved) {
commit(target.current, true);
return;
}
const stale = performance.now() - g.hist[g.hist.length - 1][0] > STALE_MS;
const v = e.type === 'pointercancel' || stale ? 0 : velocityOf(g.hist);
const bounce = tapBounce + (flickBounce - tapBounce) * clamp(Math.abs(v) / range / V_FLICK, 0, 1);
const to = snap((g.at ?? target.current) + (v / 1000) * (DECEL / (1 - DECEL)) * momentum);
commit(to, true, { velocity: v, bounce });
launch(to, bounce, v);
};
live.current = { move, up };
const down = e => {
if (disabled || grip.current || e.button > 0) return;
const [x, y] = localPoint(e.clientX, e.clientY);
if (Math.hypot(x, y) < DEAD) return;
e.preventDefault();
const svg = svgRef.current;
try {
svg.setPointerCapture(e.pointerId);
} catch {}
svg.focus({ preventScroll: true });
grip.current = { id: e.pointerId, at: null, hist: [], side: null, moved: false, x0: e.clientX, y0: e.clientY };
const at = angleAt(e.clientX, e.clientY);
grip.current.hist.push([performance.now(), at]);
setDragging(true);
commit(at, false);
launch(snap(at), tapBounce);
const onMove = ev => {
if (ev.isTrusted) live.current.move(ev);
};
const onUp = ev => {
if (ev.isTrusted) live.current.up(ev);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
unbind.current = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
};
};
const key = e => {
if (disabled) return;
const t = target.current;
const big = e.shiftKey ? 10 : 1;
let to;
switch (e.key) {
case 'ArrowRight':
case 'ArrowUp':
to = t + step * big;
break;
case 'ArrowLeft':
case 'ArrowDown':
to = t - step * big;
break;
case 'PageUp':
to = t + step * 10;
break;
case 'PageDown':
to = t - step * 10;
break;
case 'Home':
to = min;
break;
case 'End':
to = max;
break;
default:
return;
}
e.preventDefault();
to = snap(to);
reading.stop();
reading.jump(to);
commit(to, true);
wake();
};
useLayoutEffect(() => {
const L = loop.current;
L.text = '';
L.valueText = '';
paintRef.current?.(performance.now());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [min, max, step, sweep, thickness, cometReach, cometWidth, unit]);
useEffect(() => {
if (value === undefined || grip.current) return;
const s = snap(value);
if (s === target.current) return;
target.current = s;
launch(s, tapBounce);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
useEffect(
() => () => {
cancelAnimationFrame(loop.current.raf);
unbind.current?.();
reading.stop();
},
[reading]
);
return (
<div
className={`comet-dial${className ? ` ${className}` : ''}`}
data-dragging={dragging ? '' : undefined}
data-disabled={disabled ? '' : undefined}
style={{
'--cd-accent': accent,
'--cd-ink': ink,
'--cd-size': `${size}px`,
'--cd-figure': `${Math.round(size * 0.16)}px`
}}
>
<svg
ref={svgRef}
className="comet-dial__ring"
viewBox="0 0 200 200"
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={label}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={target.current}
aria-valuetext={`${target.current}${unit}`}
aria-disabled={disabled || undefined}
onPointerDown={down}
onKeyDown={key}
>
<path className="comet-dial__track" d={arcPath(start, end)} strokeWidth={thickness} />
<path ref={litRef} className="comet-dial__lit" strokeWidth={thickness} />
<g className="comet-dial__comet">
{Array.from({ length: K }, (_, j) => (
<path
key={j}
ref={el => {
comet.current[j] = el;
}}
style={{ opacity: 0 }}
/>
))}
</g>
<circle ref={headRef} className="comet-dial__head" r={thickness * 1.8} />
</svg>
<div className="comet-dial__readout" aria-hidden="true">
<span className="comet-dial__value">
<span ref={figure} className="comet-dial__figure" />
{unit ? <span className="comet-dial__unit">{unit}</span> : null}
</span>
</div>
</div>
);
}
```
### Component CSS
```css
.comet-dial {
--cd-accent: #f5f5f5;
--cd-ink: #fdfdfd;
--cd-size: 250px;
--cd-figure: 40px;
position: relative;
width: var(--cd-size);
height: var(--cd-size);
color: var(--cd-ink);
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
.comet-dial__ring {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
outline: none;
cursor: grab;
touch-action: none;
}
.comet-dial[data-dragging] .comet-dial__ring {
cursor: grabbing;
}
.comet-dial__track,
.comet-dial__lit,
.comet-dial__comet path {
fill: none;
stroke-linecap: round;
}
.comet-dial__track {
stroke: var(--cd-ink);
opacity: 0.24;
}
.comet-dial__lit {
stroke: var(--cd-accent);
opacity: 0.7;
}
.comet-dial__comet path {
stroke: var(--cd-accent);
}
.comet-dial__head {
fill: var(--cd-accent);
}
.comet-dial__readout {
position: absolute;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;
}
.comet-dial__value {
display: inline-flex;
align-items: baseline;
gap: 0.06em;
font-size: var(--cd-figure);
font-weight: 500;
line-height: 1;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
}
.comet-dial__unit {
font-size: 0.5em;
opacity: 0.5;
}
.comet-dial[data-disabled] {
opacity: 0.55;
}
.comet-dial[data-disabled] .comet-dial__ring {
cursor: default;
pointer-events: none;
}
```
### 怎么接进项目
1. 装上上面列的依赖。
2. 把源码放进项目的组件目录。
3. 引进来,按上面的参数表调。
### More from Ori
更多组件在 Ori 的组件库里:<https://ottersnap.com/style/> —— 挑一个,复制提示词,接进你的项目。