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>
);
}