Dial Moire

Moire

Weighted rotary knob tuned like a radio: counter-rotating line gratings shimmer with moire interference, and a hidden word only resolves at the detent.

Install
npx shadcn add https://design.helpmarq.com/r/dial-moire.json
Source
registry/core/dial-moire/component.tsx
"use client";

import { useEffect, useRef } from "react";

// MoireDial — a weighted rotary knob whose feedback channel is optical
// interference. Two fine-line gratings counter-rotate as you drag; grating A
// is fixed but carries a hidden message as a locally phase-inverted region
// (glyphs rasterized offscreen to an alpha mask, half-pitch phase offset
// inside), so the word only gains contrast when the overlay grating aligns
// and the dial clicks into its detent. Canvas 2D, DPR clamp 2, direct-DOM
// rAF is the sole writer, loop sleeps when settled. Zero deps.

const DEG = Math.PI / 180;
const DETENT_BAND = 4 * DEG; // rad — snap capture zone
const DETENT_K = 200; // s^-2
const DETENT_C = 2 * 0.9 * Math.sqrt(DETENT_K); // zeta = 0.9 → one tiny overshoot
const NEEDLE_K = 120; // s^-2
const NEEDLE_C = 2 * 0.75 * Math.sqrt(NEEDLE_K); // zeta = 0.75 → weighted lag
const SLEEP_OMEGA = 0.02; // rad/s
const SLEEP_NEEDLE = 0.05 * DEG; // rad
const SNAP_EPS = 0.0002; // rad — close enough to hard-snap the detent
const TICKS = Array.from({ length: 24 }, (_, i) => i * 15);

function wrapPi(a: number) {
  a = (a + Math.PI) % (2 * Math.PI);
  if (a < 0) a += 2 * Math.PI;
  return a - Math.PI;
}

export function MoireDial({
  message = "SIGNAL",
  pitch = 6,
  gear = 0.15,
  initialAngle = -132,
  friction = 3.5,
  fieldHeight = 224,
  className = "",
  "aria-label": ariaLabel = "Moire tuning dial",
}: {
  /** hidden word that resolves out of the interference at alignment */
  message?: string;
  /** grating pitch in px — one 1px line every `pitch` px */
  pitch?: number;
  /** grating radians per dial radian — radio-vernier ratio */
  gear?: number;
  /** starting dial angle in degrees (reduced motion forces 0 = aligned) */
  initialAngle?: number;
  /** angular velocity decay in s^-1 while coasting after a flick */
  friction?: number;
  /** interference field height in px */
  fieldHeight?: number;
  className?: string;
  "aria-label"?: string;
}) {
  const fieldRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const knobRef = useRef<HTMLDivElement>(null);
  const ringRef = useRef<HTMLDivElement>(null);
  const needleRef = useRef<HTMLDivElement>(null);
  const readoutRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    const field = fieldRef.current;
    const canvas = canvasRef.current;
    const knob = knobRef.current;
    const ring = ringRef.current;
    const needle = needleRef.current;
    const readout = readoutRef.current;
    const input = inputRef.current;
    if (!field || !canvas || !knob || !ring || !needle || !readout || !input)
      return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    // hot-path state — locals only, never React state
    let angle = reduced ? 0 : initialAngle * DEG; // ring angle, continuous rad
    let omega = 0; // rad/s
    let needleAngle = angle;
    let needleOmega = 0;
    let dragging = false;
    let raf = 0;
    let last = 0;
    let lastPA = 0; // pointer angle at last move
    let lastT = 0;
    let detentGraceUntil = 0; // keyboard steps suppress the detent briefly
    let disposed = false;

    let w = 0;
    let h = 0;
    let dpr = 1;
    let diag = 0;
    let offA: HTMLCanvasElement | null = null; // fixed grating + inverted word
    let offB: HTMLCanvasElement | null = null; // rotating overlay grating

    // grating ink derives from the --foreground token so the field is
    // legible in both themes — re-read whenever the theme class flips.
    let gratingFill = "rgba(237,237,237,0.5)";
    const deriveGratingFill = () => {
      const fg = getComputedStyle(document.documentElement)
        .getPropertyValue("--foreground")
        .trim();
      if (!fg) return;
      const probe = document.createElement("canvas").getContext("2d");
      if (!probe) return;
      probe.fillStyle = fg;
      const [r, g, b] = probe.fillStyle.match(/\d+/g)?.map(Number) ?? [];
      if (r === undefined) return;
      gratingFill = `rgba(${r},${g},${b},0.5)`;
    };
    deriveGratingFill();

    const build = () => {
      const rect = field.getBoundingClientRect();
      w = Math.round(rect.width);
      h = Math.round(rect.height);
      if (w < 2 || h < 2) return;
      dpr = Math.min(2, window.devicePixelRatio || 1);
      canvas.width = w * dpr;
      canvas.height = h * dpr;

      // message glyphs → offscreen alpha mask (hero-gravity-well pattern)
      const mask = document.createElement("canvas");
      mask.width = w * dpr;
      mask.height = h * dpr;
      const mctx = mask.getContext("2d");
      if (!mctx) return;
      mctx.scale(dpr, dpr);
      const size = Math.min(h * 0.58, (w * 1.5) / Math.max(1, message.length));
      mctx.font = `600 ${size}px GeistSans, "GeistSans Fallback", ui-sans-serif, system-ui, sans-serif`;
      mctx.textAlign = "center";
      mctx.textBaseline = "middle";
      mctx.fillStyle = "#ffffff";
      mctx.fillText(message, w / 2, h / 2);

      // grating A: fixed 1px lines at `pitch`, phase-inverted by half a pitch
      // inside the glyph mask — invisible until the overlay aligns
      offA = document.createElement("canvas");
      offA.width = w * dpr;
      offA.height = h * dpr;
      const actx = offA.getContext("2d");
      if (!actx) return;
      actx.scale(dpr, dpr);
      actx.fillStyle = gratingFill;
      for (let y = 0; y <= h; y += pitch) actx.fillRect(0, y, w, 1);
      actx.globalCompositeOperation = "destination-out";
      actx.drawImage(mask, 0, 0, w, h);
      const inv = document.createElement("canvas");
      inv.width = w * dpr;
      inv.height = h * dpr;
      const ictx = inv.getContext("2d");
      if (!ictx) return;
      ictx.scale(dpr, dpr);
      ictx.fillStyle = gratingFill;
      for (let y = pitch / 2; y <= h; y += pitch) ictx.fillRect(0, y, w, 1);
      ictx.globalCompositeOperation = "destination-in";
      ictx.drawImage(mask, 0, 0, w, h);
      actx.globalCompositeOperation = "source-over";
      actx.drawImage(inv, 0, 0, w, h);

      // grating B: oversized square so rotation never exposes an edge; phase
      // chosen so its lines coincide with A's background lines at zero rotation
      diag = Math.ceil(Math.hypot(w, h)) + pitch * 2;
      offB = document.createElement("canvas");
      offB.width = diag * dpr;
      offB.height = diag * dpr;
      const bctx = offB.getContext("2d");
      if (!bctx) return;
      bctx.scale(dpr, dpr);
      bctx.fillStyle = gratingFill;
      const phase = ((diag / 2 - h / 2) % pitch + pitch) % pitch;
      for (let y = phase; y <= diag; y += pitch) bctx.fillRect(0, y, diag, 1);
    };

    const render = () => {
      const wr = wrapPi(angle);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);
      if (offA) ctx.drawImage(offA, 0, 0, w, h);
      if (offB) {
        ctx.save();
        ctx.translate(w / 2, h / 2);
        // |wr| keeps the fringe field continuous across the ±180° wrap
        // (a line grating is 2-fold symmetric, so sign carries no extra info)
        ctx.rotate(gear * Math.abs(wr));
        ctx.drawImage(offB, -diag / 2, -diag / 2, diag, diag);
        ctx.restore();
      }
      ring.style.transform = `rotate(${angle / DEG}deg)`;
      needle.style.transform = `rotate(${needleAngle / DEG}deg)`;
      const pct =
        Math.abs(wr) < SNAP_EPS
          ? 100
          : Math.min(99, Math.floor(100 * (1 - Math.abs(wr) / Math.PI)));
      readout.textContent = `ALIGNMENT ${String(pct).padStart(3, "0")}%`;
      const deg = Math.max(
        -180,
        Math.min(180, Math.round(wr / DEG / 2) * 2)
      );
      if (input.valueAsNumber !== deg) input.value = String(deg);
      input.setAttribute("aria-valuetext", `${pct}% aligned`);
    };

    const loop = (now: number) => {
      const dt = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);
      last = now;

      if (!dragging) {
        const wr = wrapPi(angle);
        if (Math.abs(wr) < DETENT_BAND && now > detentGraceUntil) {
          // detent snap spring: k = 200 s^-2, zeta = 0.9 — one tiny overshoot
          omega += (-DETENT_K * wr - DETENT_C * omega) * dt;
          angle += omega * dt;
          const nwr = wrapPi(angle);
          if (Math.abs(nwr) < SNAP_EPS && Math.abs(omega) < SLEEP_OMEGA) {
            angle -= nwr; // exact zero
            omega = 0;
          }
        } else {
          // coast: friction decay 3.5 s^-1 — flick spins, coasts, settles
          omega *= Math.exp(-friction * dt);
          angle += omega * dt;
          if (Math.abs(omega) < SLEEP_OMEGA) omega = 0;
        }
      }

      // needle lags the ring through a second-order spring (weighted feel)
      needleOmega += (NEEDLE_K * (angle - needleAngle) - NEEDLE_C * needleOmega) * dt;
      needleAngle += needleOmega * dt;

      render();

      const wrNow = wrapPi(angle);
      const settled =
        !dragging &&
        Math.abs(omega) < SLEEP_OMEGA &&
        Math.abs(angle - needleAngle) < SLEEP_NEEDLE &&
        Math.abs(needleOmega) < 0.02 &&
        (Math.abs(wrNow) >= DETENT_BAND || Math.abs(wrNow) < SNAP_EPS);
      if (settled) {
        needleAngle = angle;
        needleOmega = 0;
        omega = 0;
        render();
        raf = 0;
        last = 0;
      } else {
        raf = requestAnimationFrame(loop);
      }
    };
    const wake = () => {
      if (!raf && !reduced) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    const pointerAngle = (e: PointerEvent) => {
      const r = knob.getBoundingClientRect();
      return Math.atan2(
        e.clientY - (r.top + r.height / 2),
        e.clientX - (r.left + r.width / 2)
      );
    };
    const onDown = (e: PointerEvent) => {
      if (e.pointerType === "mouse" && e.button !== 0) return;
      try {
        knob.setPointerCapture(e.pointerId);
      } catch {
        // synthetic events may carry an inactive pointerId — drag still works
      }
      dragging = true;
      omega = 0;
      lastPA = pointerAngle(e);
      lastT = performance.now();
      wake();
    };
    const onMove = (e: PointerEvent) => {
      if (!dragging) return;
      const pa = pointerAngle(e);
      const d = wrapPi(pa - lastPA);
      const t = performance.now();
      const mdt = Math.max(1e-3, (t - lastT) / 1000);
      angle += d;
      // smoothed, clamped flick velocity
      omega = Math.max(-24, Math.min(24, 0.7 * omega + 0.3 * (d / mdt)));
      lastPA = pa;
      lastT = t;
      if (reduced) {
        needleAngle = angle;
        render();
      } else {
        wake();
      }
    };
    const onUp = () => {
      if (!dragging) return;
      dragging = false;
      if (reduced) {
        // step instantly: snap into the detent if inside the band
        omega = 0;
        const wr = wrapPi(angle);
        if (Math.abs(wr) < DETENT_BAND) angle -= wr;
        needleAngle = angle;
        render();
      } else {
        wake(); // omega carries → flick coasts under friction
      }
    };

    const onInput = () => {
      const deg = input.valueAsNumber;
      if (Number.isNaN(deg)) return;
      // shortest path from the current wrapped position to the slider value
      angle += wrapPi(deg * DEG - wrapPi(angle));
      omega = 0;
      // let arrow keys step through the detent band instead of being recaptured
      detentGraceUntil = performance.now() + 400;
      if (reduced) {
        needleAngle = angle;
        render();
      } else {
        wake();
      }
    };

    build();
    render();
    // re-rasterize once Geist is loaded — a fallback-font mask is wrong
    document.fonts.ready.then(() => {
      if (disposed) return;
      build();
      render();
    });

    const ro = new ResizeObserver(() => {
      build();
      render();
    });
    ro.observe(field);

    // theme toggle flips the `dark` class on <html> — re-derive the grating
    // ink from --foreground and re-rasterize, or light mode ships blank
    const mo = new MutationObserver(() => {
      deriveGratingFill();
      build();
      render();
    });
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    knob.addEventListener("pointerdown", onDown);
    knob.addEventListener("pointermove", onMove);
    knob.addEventListener("pointerup", onUp);
    knob.addEventListener("pointercancel", onUp);
    input.addEventListener("input", onInput);

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      raf = 0;
      ro.disconnect();
      mo.disconnect();
      knob.removeEventListener("pointerdown", onDown);
      knob.removeEventListener("pointermove", onMove);
      knob.removeEventListener("pointerup", onUp);
      knob.removeEventListener("pointercancel", onUp);
      input.removeEventListener("input", onInput);
    };
  }, [message, pitch, gear, initialAngle, friction]);

  return (
    <div className={`flex w-full flex-col items-center gap-6 ${className}`}>
      <div
        ref={fieldRef}
        role="img"
        aria-label={`Interference field concealing the word ${message}`}
        className="relative w-full overflow-hidden rounded-md border border-border"
        style={{ height: fieldHeight }}
      >
        <canvas
          ref={canvasRef}
          aria-hidden
          className="absolute inset-0 h-full w-full"
        />
      </div>

      <div className="relative">
        {/* fixed target index the ring's zero tick lines up with */}
        <span
          aria-hidden
          className="absolute -top-3 left-1/2 h-2 w-px -translate-x-1/2 bg-muted"
        />
        <div
          ref={knobRef}
          className="relative h-40 w-40 cursor-grab touch-none select-none rounded-full border border-border bg-surface active:cursor-grabbing focus-within:outline focus-within:outline-2 focus-within:outline-offset-4 focus-within:outline-accent"
        >
          <input
            ref={inputRef}
            type="range"
            min={-180}
            max={180}
            step={2}
            defaultValue={Math.max(
              -180,
              Math.min(180, Math.round(wrapPi(initialAngle * DEG) / DEG / 2) * 2)
            )}
            aria-label={ariaLabel}
            className="sr-only"
          />
          {/* tick ring rotates rigidly with the dial angle */}
          <div ref={ringRef} aria-hidden className="absolute inset-0 will-change-transform">
            {TICKS.map((deg) => (
              <span
                key={deg}
                className={`absolute left-1/2 top-1/2 w-px ${
                  deg === 0 ? "h-3 bg-foreground" : "h-1.5 bg-border"
                }`}
                style={{
                  transform: `translate(-50%, -50%) rotate(${deg}deg) translateY(-66px)`,
                }}
              />
            ))}
          </div>
          {/* needle lags behind through the second-order spring */}
          <div
            ref={needleRef}
            aria-hidden
            className="absolute inset-0 will-change-transform"
          >
            <span className="absolute left-1/2 top-3.5 h-11 w-0.5 -translate-x-1/2 rounded-full bg-foreground" />
          </div>
          <span
            aria-hidden
            className="absolute left-1/2 top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-border bg-background"
          />
        </div>
      </div>

      <div
        ref={readoutRef}
        aria-hidden
        className="font-mono text-xs tracking-[0.2em] text-muted tabular-nums"
      >
        ALIGNMENT 000%
      </div>
    </div>
  );
}
Build spec

A weighted rotary knob whose feedback channel is optical interference, built on a DPR-clamped (max 2) Canvas 2D field. Two fine-line gratings (1px strokes at ~0.5 alpha derived live from the --foreground token, 6px pitch, re-derived via a MutationObserver on theme-class changes so both themes stay legible): grating A is fixed and carries a hidden message as a locally phase-inverted region — the message glyphs are rasterized offscreen to an alpha mask (hero-gravity-well sampling pattern) and inside the mask A's line phase is offset by half a pitch, so the word only gains contrast when the overlay aligns; grating B is an oversized pre-rendered square that rotates with the dial angle through a radio-vernier gear ratio (~0.15), its phase chosen so its lines coincide with A's background lines at zero rotation. Both gratings are pre-rendered to offscreen canvases and composited per frame. Knob: 160px disc, bg-surface with border-border ring, a 24-tick ring layer rotating rigidly with the dial, a needle that lags the ring through a second-order spring (k=120 s^-2, zeta=0.75, weighted Inertial-Dial feel), and a font-mono ALIGNMENT percent readout below. Interaction: pointer-capture drag maps pointer angle about the knob center to ring angle with smoothed angular velocity; on release omega integrates under friction decay 3.5 s^-1 (flick spins, coasts, settles). Detent: within +-4 deg of alignment a snap spring (k=200 s^-2, zeta=0.9) pulls to exact zero with one tiny overshoot and the readout ticks to 100%. A single direct-DOM rAF loop is the sole writer (canvas draw, ring/needle transforms, readout text, slider value — no React state on the hot path) and sleeps when |omega| < 0.02 rad/s and needle error < 0.05 deg. Keyboard a11y via a visually hidden range input (arrows step 2 deg, aria-valuetext reports percent aligned, short detent grace so steps can escape the band). Under prefers-reduced-motion the dial renders the aligned state statically with the word legible and steps instantly with no loop. Zero dependencies.

Tags
canvasmoiredialknobphysicsinterferencehidden-messagedetent