← All components
Week 1 · 2026-09-30

Unit Converter Dial

Spin a rotary knob to switch between distance, temperature and weight, then type in either box to convert live.

Unit converterSpin the dial
=

1 km = 0.6214 mi

Source

Demo.tsx
import UnitDial from "./UnitDial";

export default function Demo() {
  return <UnitDial />;
}
UnitDial.module.css
.card {
  --surface: #fcfcfb;
  --ink: #0b0b0b;
  --ink-2: #52514e;
  --muted: #898781;
  --border: rgba(11, 11, 11, 0.1);
  --track: #e1e0d9;
  --field: #f4f3f0;
  --knob-hi: #ffffff;
  --knob-lo: #e6e5df;
  --knob-shine: rgba(255, 255, 255, 0.9);
  --distance: #2a78d6;
  --temperature: #eb6834;
  --weight: #1baf7a;

  width: 100%;
  max-width: 340px;
  padding: 20px 20px 18px;
  border: 1px solid var(--border);
  border-radius: 20px;
  background: var(--surface);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 12px 32px -12px rgba(0, 0, 0, 0.18);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}

@media (prefers-color-scheme: dark) {
  .card {
    --surface: #1a1a19;
    --ink: #ffffff;
    --ink-2: #c3c2b7;
    --border: rgba(255, 255, 255, 0.1);
    --track: #2c2c2a;
    --field: #232321;
    --knob-hi: #3a3a35;
    --knob-lo: #1f1f1d;
    --knob-shine: rgba(255, 255, 255, 0.08);
    --distance: #3987e5;
    --temperature: #d95926;
    --weight: #199e70;
    box-shadow: 0 12px 32px -12px rgba(0, 0, 0, 0.6);
  }
}

.head { display: flex; align-items: baseline; justify-content: space-between; }
.title { font-size: 14px; font-weight: 600; }
.tip { color: var(--muted); font-size: 12px; }

/* Dial */
.dial {
  position: relative;
  width: 100%;
  max-width: 260px;
  aspect-ratio: 260 / 196;
  margin: 8px auto 16px;
  user-select: none;
  -webkit-user-select: none;
}

.ticks { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.tick { stroke: var(--track); stroke-width: 1.5; stroke-linecap: round; }
.tickMajor { stroke: var(--ink-2); stroke-width: 2; stroke-linecap: round; }
.tickActive { stroke: var(--accent); stroke-width: 3; stroke-linecap: round; transition: stroke 0.2s ease; }

.option {
  position: absolute;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1px;
  padding: 4px 6px;
  border: 0;
  border-radius: 8px;
  background: none;
  color: var(--muted);
  font: inherit;
  white-space: nowrap;
  cursor: pointer;
  transform: translate(-50%, -50%);
  transition: color 0.2s ease;
}
.option:hover { color: var(--ink-2); }
.option[data-active="true"] { color: var(--ink); }
.option:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.optionName { font-size: 13px; font-weight: 600; }
.optionUnits { font-size: 11px; opacity: 0.8; }

.knob {
  position: absolute;
  left: 26.923%;
  top: 32.653%;
  width: 46.154%;
  aspect-ratio: 1;
  border-radius: 50%;
  background: radial-gradient(circle at 50% 30%, var(--knob-hi), var(--knob-lo) 70%);
  box-shadow:
    0 14px 24px -10px rgba(0, 0, 0, 0.35),
    0 0 0 1px var(--border),
    inset 0 1px 0 var(--knob-shine);
  cursor: grab;
  touch-action: none;
  /* Slight overshoot so the knob "clicks" into each detent. */
  transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Grip ridges around the rim. */
.knob::before {
  content: "";
  position: absolute;
  inset: 6%;
  border-radius: 50%;
  background: repeating-conic-gradient(color-mix(in srgb, var(--ink) 9%, transparent) 0deg 1.5deg, transparent 1.5deg 9deg);
  -webkit-mask: radial-gradient(circle, transparent 60%, #000 62%);
  mask: radial-gradient(circle, transparent 60%, #000 62%);
}
.knob[data-dragging="true"] { cursor: grabbing; transition: none; }
.knob:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }

.indicator {
  position: absolute;
  top: 7%;
  left: 50%;
  width: 4px;
  height: 15%;
  border-radius: 2px;
  background: var(--accent);
  box-shadow: 0 0 10px var(--accent);
  transform: translateX(-50%);
  transition: background 0.2s ease, box-shadow 0.2s ease;
}

.cap {
  position: absolute;
  left: 50%;
  top: 63.265%;
  display: grid;
  place-items: center;
  width: 24.6%;
  aspect-ratio: 1;
  border-radius: 50%;
  background: var(--surface);
  box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.12), 0 0 0 1px var(--border);
  color: var(--ink-2);
  font-size: 11px;
  font-weight: 600;
  white-space: nowrap;
  pointer-events: none;
  transform: translate(-50%, -50%);
}

/* Inputs */
.fields { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr); gap: 8px; }
.fieldGroup { display: flex; align-items: flex-end; gap: 8px; min-width: 0; }
.field { display: flex; flex: 1; flex-direction: column; gap: 6px; min-width: 0; }
.fieldLabel { color: var(--ink-2); font-size: 12px; }
.inputWrap { position: relative; display: block; }
.input {
  width: 100%;
  height: 48px;
  padding: 0 40px 0 12px;
  border: 1px solid var(--border);
  border-radius: 12px;
  outline: none;
  background: var(--field);
  color: var(--ink);
  font: inherit;
  font-size: 18px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.input:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}
.suffix {
  position: absolute;
  top: 50%;
  right: 12px;
  color: var(--muted);
  font-size: 14px;
  pointer-events: none;
  transform: translateY(-50%);
}
.equals { padding-bottom: 13px; color: var(--muted); font-size: 18px; }

.hint { margin: 14px 0 0; color: var(--muted); font-size: 12px; text-align: center; }

@media (prefers-reduced-motion: reduce) {
  .knob { transition: none; }
}
UnitDial.tsx
"use client";

import { useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import styles from "./UnitDial.module.css";

type Category = {
  key: string;
  label: string;
  units: [string, string];
  names: [string, string];
  toSecond: (value: number) => number;
  toFirst: (value: number) => number;
  hint: string;
};

const CATEGORIES: Category[] = [
  {
    key: "distance",
    label: "Distance",
    units: ["km", "mi"],
    names: ["Kilometres", "Miles"],
    toSecond: (v) => v / 1.609344,
    toFirst: (v) => v * 1.609344,
    hint: "1 km = 0.6214 mi",
  },
  {
    key: "temperature",
    label: "Temp",
    units: ["°C", "°F"],
    names: ["Celsius", "Fahrenheit"],
    toSecond: (v) => (v * 9) / 5 + 32,
    toFirst: (v) => ((v - 32) * 5) / 9,
    hint: "°F = °C × 9⁄5 + 32",
  },
  {
    key: "weight",
    label: "Weight",
    units: ["kg", "lb"],
    names: ["Kilograms", "Pounds"],
    toSecond: (v) => v / 0.45359237,
    toFirst: (v) => v * 0.45359237,
    hint: "1 kg = 2.2046 lb",
  },
];

// Knob angle for each category, in degrees clockwise from straight up.
const DETENTS = [-60, 0, 60];
const MAX_TURN = 80;
const TICKS = Array.from({ length: 17 }, (_, i) => -80 + i * 10);

// Dial layout in a 260×196 box; converted to percentages so the dial can shrink.
const BOX = { w: 260, h: 196 };
const CENTER = { x: 130, y: 124 };
const LABEL_RADIUS = 112;

const NUMBER = /^-?\d*\.?\d*$/;
const KEY_STEPS: Record<string, number> = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 };

function polar(angle: number, radius: number) {
  const rad = (angle * Math.PI) / 180;
  return { x: CENTER.x + radius * Math.sin(rad), y: CENTER.y - radius * Math.cos(rad) };
}

function nearestDetent(angle: number) {
  return DETENTS.reduce((best, d, i) => (Math.abs(angle - d) < Math.abs(angle - DETENTS[best]) ? i : best), 0);
}

// Pointer angle around the element's center: 0 = up, clockwise positive.
function pointerAngle(e: PointerEvent, el: HTMLElement) {
  const r = el.getBoundingClientRect();
  return (Math.atan2(e.clientX - (r.left + r.width / 2), r.top + r.height / 2 - e.clientY) * 180) / Math.PI;
}

function format(value: number) {
  return String(Math.round(value * 1e4) / 1e4);
}

export default function UnitDial() {
  const [index, setIndex] = useState(0);
  const [angle, setAngle] = useState(DETENTS[0]);
  const [dragging, setDragging] = useState(false);
  const [entry, setEntry] = useState<{ side: 0 | 1; text: string }>({ side: 0, text: "10" });
  const lastPointer = useRef(0);

  const category = CATEGORIES[index];

  function select(i: number) {
    const next = Math.max(0, Math.min(CATEGORIES.length - 1, i));
    setIndex(next);
    setAngle(DETENTS[next]);
  }

  function onPointerDown(e: PointerEvent<HTMLDivElement>) {
    e.currentTarget.setPointerCapture(e.pointerId);
    lastPointer.current = pointerAngle(e, e.currentTarget);
    setDragging(true);
  }

  function onPointerMove(e: PointerEvent<HTMLDivElement>) {
    if (!dragging) return;
    const now = pointerAngle(e, e.currentTarget);
    // Shortest way round, so crossing the bottom of the knob doesn't jump 360°.
    const delta = ((now - lastPointer.current + 540) % 360) - 180;
    lastPointer.current = now;
    const next = Math.max(-MAX_TURN, Math.min(MAX_TURN, angle + delta));
    setAngle(next);
    setIndex(nearestDetent(next));
  }

  function onPointerUp() {
    if (!dragging) return;
    setDragging(false);
    select(nearestDetent(angle));
  }

  function onKeyDown(e: KeyboardEvent) {
    const step = KEY_STEPS[e.key];
    if (step) select(index + step);
    else if (e.key === "Home") select(0);
    else if (e.key === "End") select(CATEGORIES.length - 1);
    else return;
    e.preventDefault();
  }

  const parsed = Number(entry.text);
  const hasValue = entry.text !== "" && !Number.isNaN(parsed);
  const convert = entry.side === 0 ? category.toSecond : category.toFirst;
  const converted = hasValue ? format(convert(parsed)) : "";
  const values = entry.side === 0 ? [entry.text, converted] : [converted, entry.text];

  const pct = (x: number, y: number) => ({ left: `${(x / BOX.w) * 100}%`, top: `${(y / BOX.h) * 100}%` });

  return (
    <div className={styles.card} style={{ "--accent": `var(--${category.key})` } as CSSProperties}>
      <div className={styles.head}>
        <span className={styles.title}>Unit converter</span>
        <span className={styles.tip}>Spin the dial</span>
      </div>

      <div className={styles.dial}>
        <svg className={styles.ticks} viewBox={`0 0 ${BOX.w} ${BOX.h}`} aria-hidden="true">
          {TICKS.map((t) => {
            const major = DETENTS.includes(t);
            const from = polar(t, major ? 66 : 69);
            const to = polar(t, major ? 78 : 74);
            const cls = t === DETENTS[index] ? styles.tickActive : major ? styles.tickMajor : styles.tick;
            return <line key={t} x1={from.x} y1={from.y} x2={to.x} y2={to.y} className={cls} />;
          })}
        </svg>

        {CATEGORIES.map((c, i) => {
          const p = polar(DETENTS[i], LABEL_RADIUS);
          return (
            <button
              key={c.key}
              type="button"
              className={styles.option}
              data-active={i === index}
              aria-pressed={i === index}
              style={pct(p.x, p.y)}
              onClick={() => select(i)}
            >
              <span className={styles.optionName}>{c.label}</span>
              <span className={styles.optionUnits}>
                {c.units[0]} · {c.units[1]}
              </span>
            </button>
          );
        })}

        <div
          className={styles.knob}
          role="slider"
          tabIndex={0}
          aria-label="Unit type"
          aria-valuemin={0}
          aria-valuemax={CATEGORIES.length - 1}
          aria-valuenow={index}
          aria-valuetext={category.label}
          data-dragging={dragging}
          style={{ transform: `rotate(${angle}deg)` }}
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={onPointerUp}
          onPointerCancel={onPointerUp}
          onKeyDown={onKeyDown}
        >
          <span className={styles.indicator} />
        </div>
        <div className={styles.cap} aria-hidden="true">
          {category.units[0]} ⇄ {category.units[1]}
        </div>
      </div>

      <div className={styles.fields}>
        {([0, 1] as const).map((side) => (
          <div key={side} className={styles.fieldGroup}>
            {side === 1 && <span className={styles.equals}>=</span>}
            <label className={styles.field}>
              <span className={styles.fieldLabel}>{category.names[side]}</span>
              <span className={styles.inputWrap}>
                <input
                  className={styles.input}
                  inputMode="decimal"
                  autoComplete="off"
                  value={values[side]}
                  onChange={(e) => {
                    if (NUMBER.test(e.target.value)) setEntry({ side, text: e.target.value });
                  }}
                />
                <span className={styles.suffix}>{category.units[side]}</span>
              </span>
            </label>
          </div>
        ))}
      </div>

      <p className={styles.hint}>{category.hint}</p>
    </div>
  );
}