◆

CometDial

Tick-ring dial you flick by angle: the reading launches on a spring and a velocity-driven comet streaks behind the lit head, trailing the direction of travel and vanishing at rest.

Original docs ↗
Live preview
Loading demo…

Installation

npx shadcn@latest add https://reactbits.dev/r/CometDial-TS-TW.json

Dependencies: motion

Source

'use client';

import React, { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
import { animate, useMotionValue, useReducedMotion } from 'motion/react';

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;

export interface CometDialChangeDetail {
  velocity: number;
  bounce: number;
}

export interface CometDialProps {
  value?: number;
  defaultValue?: number;
  min?: number;
  max?: number;
  step?: number;
  unit?: string;
  label?: string;
  accent?: string;
  ink?: string;
  size?: number;
  sweep?: number;
  thickness?: number;
  speed?: number;
  tapBounce?: number;
  flickBounce?: number;
  momentum?: number;
  cometReach?: number;
  cometWidth?: number;
  disabled?: boolean;
  onChange?: (value: number) => void;
  onChangeEnd?: (value: number, detail: CometDialChangeDetail) => void;
  className?: string;
}

type Sample = [number, number];

interface Grip {
  id: number;
  at: number | null;
  hist: Sample[];
  side: 'hi' | 'lo' | null;
  moved: boolean;
  x0: number;
  y0: number;
}

interface Loop {
  raf: number;
  last: number;
  v: number;
  rPrev: number | null;
  text: string;
  valueText: string;
}

interface Live {
  move: (e: PointerEvent) => void;
  up: (e: PointerEvent) => void;
}

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const velocityOf = (hist: Sample[]) => {
  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: number) => {
  const s = String(step);
  const i = s.indexOf('.');
  return i === -1 ? 0 : s.length - i - 1;
};
const pointAt = (deg: number): [number, number] => {
  const a = (deg * Math.PI) / 180;
  return [100 + Math.cos(a) * R, 100 + Math.sin(a) * R];
};
const arcPath = (a0: number, a1: number) => {
  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)}`;
};

const CometDial: React.FC<CometDialProps> = ({
  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<SVGSVGElement>(null);
  const litRef = useRef<SVGPathElement>(null);
  const headRef = useRef<SVGCircleElement>(null);
  const comet = useRef<(SVGPathElement | null)[]>([]);
  const figure = useRef<HTMLSpanElement>(null);
  const target = useRef(clamp(value ?? defaultValue, min, max));
  const grip = useRef<Grip | null>(null);
  const unbind = useRef<(() => void) | null>(null);
  const loop = useRef<Loop>({ raf: 0, last: 0, v: 0, rPrev: null, text: '', valueText: '' });
  const live = useRef<Live>({} as Live);
  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: number) =>
    step > 0 ? clamp(Math.round((v - min) / step) * step + min, min, max) : clamp(v, min, max);

  const commit = (v: number, finished: boolean, detail?: CometDialChangeDetail) => {
    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<((now: number) => void) | null>(null);
  const tick = useCallback((now: number) => 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: number, bounce: number, 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: number) => {
    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: number, cy: number): [number, number] => {
    const b = (svgRef.current as SVGSVGElement).getBoundingClientRect();
    return [((cx - b.left) / b.width) * 200 - 100, ((cy - b.top) / b.height) * 200 - 100];
  };
  const angleAt = (cx: number, cy: number) => {
    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: PointerEvent) => {
    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: PointerEvent) => {
    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: React.PointerEvent<SVGSVGElement>) => {
    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 as SVGSVGElement;
    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: PointerEvent) => {
      if (ev.isTrusted) live.current.move(ev);
    };
    const onUp = (ev: PointerEvent) => {
      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: React.KeyboardEvent<SVGSVGElement>) => {
    if (disabled) return;
    const t = target.current;
    const big = e.shiftKey ? 10 : 1;
    let to: number;
    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={`group relative select-none [-webkit-touch-callout:none] [-webkit-tap-highlight-color:transparent] [width:var(--cd-size)] [height:var(--cd-size)] [color:var(--cd-ink)] data-[disabled]:opacity-55${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`
        } as CSSProperties
      }
    >
      <svg
        ref={svgRef}
        className="absolute inset-0 block h-full w-full cursor-grab touch-none rounded-full outline-none group-data-[dragging]:cursor-grabbing group-data-[disabled]:pointer-events-none group-data-[disabled]:cursor-default"
        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="fill-none opacity-[0.24] [stroke:var(--cd-ink)] [stroke-linecap:round]"
          d={arcPath(start, end)}
          strokeWidth={thickness}
        />
        <path
          ref={litRef}
          className="fill-none opacity-70 [stroke:var(--cd-accent)] [stroke-linecap:round]"
          strokeWidth={thickness}
        />
        <g className="[&_path]:fill-none [&_path]:[stroke:var(--cd-accent)] [&_path]:[stroke-linecap:round]">
          {Array.from({ length: K }, (_, j) => (
            <path
              key={j}
              ref={el => {
                comet.current[j] = el;
              }}
              style={{ opacity: 0 }}
            />
          ))}
        </g>
        <circle ref={headRef} className="[fill:var(--cd-accent)]" r={thickness * 1.8} />
      </svg>
      <div className="pointer-events-none absolute inset-0 grid place-items-center" aria-hidden="true">
        <span className="inline-flex items-baseline gap-[0.06em] leading-none font-medium tracking-[-0.04em] tabular-nums [font-size:var(--cd-figure)]">
          <span ref={figure} className="comet-dial__figure" />
          {unit ? <span className="text-[0.5em] opacity-50">{unit}</span> : null}
        </span>
      </div>
    </div>
  );
};

export default CometDial;