Forms
TimeDial
A brass pocket watch that is also a time field. Drag either hand; the minute hand carries the hour past twelve, and a painted sky turns behind the arch so the sun comes up and the moon comes out with the hours. Arrow keys move each hand.
Download
- React
- Tailwind CSS v4
- motion
components/ui/time-dial.tsx
"use client";
import { useEffect, useId, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";
import { useMotionValueEvent, useReducedMotion, useSpring } from "motion/react";
type TimeDialProps = {
/** Read out by screen readers. */
label?: string;
/** Time (controlled), "HH:MM" in 24 hours. */
value?: string;
/** Time at start (uncontrolled), "HH:MM" in 24 hours. */
defaultValue?: string;
/** Minutes moved by one drag step or arrow key. */
step?: number;
/** Called with "HH:MM" in 24 hours, like a time input. */
onChange?: (time: string) => void;
/** Form field name. Sends "HH:MM" in 24 hours. */
name?: string;
/** Set the width here. Everything scales with it. */
className?: string;
};
const CX = 180;
const CY = 192;
const DAY = 1440;
const ROMAN = ["XII", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI"];
const TICKS = Array.from({ length: 60 }, (_, i) => i);
const parse = (time: string) => {
const [h, m] = time.split(":").map(Number);
return ((h * 60 + m) % DAY + DAY) % DAY;
};
const pad = (n: number) => String(n).padStart(2, "0");
const to24 = (t: number) => `${pad(Math.floor(t / 60))}:${pad(t % 60)}`;
const to12 = (t: number) => `${Math.floor(t / 60) % 12 || 12}:${pad(t % 60)}`;
const half = (t: number) => (t < 720 ? "AM" : "PM");
const wrap = (d: number, span: number) => (d > span / 2 ? d - span : d < -span / 2 ? d + span : d);
const handTransform = (angle: number) => `rotate(${angle.toFixed(2)} ${CX} ${CY})`;
/* TimeDial: a brass pocket watch that is also a time field. Drag either hand; the minute hand carries the hour past twelve, and a painted sky turns behind the arch so the sun comes up and the moon comes out with the hours. */
export function TimeDial({ label = "Time", value, defaultValue = "10:10", step = 5, onChange, name, className = "w-full max-w-xs" }: TimeDialProps) {
const reduce = useReducedMotion();
const id = useId().replace(/:/g, "");
const svg = useRef<SVGSVGElement>(null);
const hourHand = useRef<SVGGElement>(null);
const minuteHand = useRef<SVGGElement>(null);
const hourGroup = useRef<SVGGElement>(null);
const minuteGroup = useRef<SVGGElement>(null);
const sky = useRef<SVGGElement>(null);
const [inner, setInner] = useState(() => parse(defaultValue));
const t = value !== undefined ? parse(value) : inner;
const latest = useRef(t);
const shown = useSpring(t, reduce ? { stiffness: 10000, damping: 500 } : { stiffness: 260, damping: 19 });
useEffect(() => {
latest.current = t;
// Go the short way round, so 23:55 to 00:00 steps forward and does not unwind the day.
const now = shown.get();
shown.set(now + wrap((((t - now) % DAY) + DAY) % DAY, DAY));
}, [t, shown]);
useMotionValueEvent(shown, "change", (v) => {
hourHand.current?.setAttribute("transform", handTransform(v * 0.5));
minuteHand.current?.setAttribute("transform", handTransform(v * 6));
sky.current?.setAttribute("transform", `rotate(${(v * 0.25 - 180).toFixed(2)} ${CX} 150)`);
});
const set = (next: number) => {
const fixed = ((next % DAY) + DAY) % DAY;
latest.current = fixed;
if (value === undefined) setInner(fixed);
onChange?.(to24(fixed));
};
const grab = (e: ReactPointerEvent<SVGSVGElement>) => {
if (e.button !== 0 || !svg.current) return;
const box = svg.current.getBoundingClientRect();
const scale = 360 / box.width;
const at = (x: number, y: number) => ({ dx: (x - box.left) * scale - CX, dy: (y - box.top) * scale - CY });
const start = at(e.clientX, e.clientY);
const distance = Math.hypot(start.dx, start.dy);
if (distance > 160) return;
const hour = distance < 82;
e.preventDefault();
(hour ? hourGroup : minuteGroup).current?.focus({ preventScroll: true });
const turn = (x: number, y: number) => {
const { dx, dy } = at(x, y);
const degrees = ((Math.atan2(dx, -dy) * 180) / Math.PI + 360) % 360;
const cur = latest.current;
const d = hour ? wrap(Math.round(degrees / 30 - (cur % 60) / 60) - (Math.floor(cur / 60) % 12), 12) * 60 : wrap((Math.round(degrees / 6 / step) * step) % 60 - cur % 60, 60);
if (d) set(cur + d);
};
const move = (ev: PointerEvent) => turn(ev.clientX, ev.clientY);
const stop = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
turn(e.clientX, e.clientY);
};
const keys = (e: KeyboardEvent, by: number) => {
const d = { ArrowRight: by, ArrowUp: by, ArrowLeft: -by, ArrowDown: -by, PageUp: 60, PageDown: -60 }[e.key];
if (!d) return;
e.preventDefault();
set(t + d);
};
const ring = "pointer-events-none opacity-0 group-focus-visible:opacity-100";
const handle = "group";
const text = `${to12(t)} ${half(t)}`;
return (
<div role="group" aria-label={label} className={`relative @container aspect-[360/384] ${className}`}>
<svg ref={svg} viewBox="0 0 360 384" onPointerDown={grab} className="absolute inset-0 size-full cursor-grab touch-none select-none active:cursor-grabbing">
<defs>
<linearGradient id={`${id}b`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#f3d68c" />
<stop offset="0.45" stopColor="#b9862f" />
<stop offset="1" stopColor="#6e4a14" />
</linearGradient>
<radialGradient id={`${id}f`} cx="0.42" cy="0.38" r="0.75">
<stop offset="0" stopColor="#fffdf6" />
<stop offset="1" stopColor="#ece2c9" />
</radialGradient>
<linearGradient id={`${id}s`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0.5" stopColor="#7fc8ec" />
<stop offset="0.5" stopColor="#0f1b3f" />
</linearGradient>
<clipPath id={`${id}a`}>
<path d="M124 150A56 56 0 0 1 236 150Z" />
</clipPath>
</defs>
<g aria-hidden>
<rect x="164" y="2" width="32" height="16" rx="3" fill={`url(#${id}b)`} />
<path d="M168 4v12m6-12v12m6-12v12m6-12v12m6-12v12" stroke="#5c3f12" strokeWidth="1.5" />
<rect x="172" y="17" width="16" height="10" fill="#8a6220" />
<circle cx={CX} cy={CY} r="172" fill={`url(#${id}b)`} />
<circle cx={CX} cy={CY} r="170" fill="none" stroke="#5c3f12" strokeWidth="4" strokeDasharray="2 3.4" />
<circle cx={CX} cy={CY} r="158" fill="#7a5418" />
<circle cx={CX} cy={CY} r="154" fill={`url(#${id}f)`} />
<g clipPath={`url(#${id}a)`}>
<g ref={sky} transform={`rotate(${t * 0.25 - 180} ${CX} 150)`}>
<circle cx={CX} cy="150" r="58" fill={`url(#${id}s)`} />
<circle cx={CX} cy="118" r="11" fill="#ffc83d" />
<g fill="#f2f0ff">
<path d="M186 171a11 11 0 1 0 0 18a9 9 0 1 1 0-18Z" />
<circle cx="150" cy="176" r="1.4" />
<circle cx="206" cy="190" r="1.4" />
<circle cx="160" cy="194" r="1.1" />
<circle cx="214" cy="170" r="1" />
</g>
</g>
</g>
<path d="M124 150A56 56 0 0 1 236 150Z" fill="none" stroke="#7a5418" strokeWidth="2" />
{TICKS.map((i) => (
<line
key={i}
x1={CX}
y1={CY - 150}
x2={CX}
y2={CY - (i % 5 ? 144 : 138)}
stroke="#3a3226"
strokeWidth={i % 5 ? 1 : 2.4}
transform={`rotate(${i * 6} ${CX} ${CY})`}
/>
))}
{ROMAN.map((numeral, i) => (
<text
key={numeral}
x={CX}
y={CY - 112}
textAnchor="middle"
fontFamily="Georgia, 'Times New Roman', serif"
fontSize="23"
fill="#2c261c"
transform={`rotate(${i * 30} ${CX} ${CY})`}
>
{numeral}
</text>
))}
<rect x="118" y="240" width="124" height="36" rx="4" fill="#efe5cc" stroke="#7a5418" strokeWidth="1.5" />
<text x="132" y="265" fontFamily="ui-monospace, monospace" fontSize="21" fontWeight="700" fill="#2c261c" style={{ fontVariantNumeric: "tabular-nums" }}>
{to12(t)}
</text>
<rect x="202" y="245" width="34" height="26" rx="3" fill="#2c261c" />
<text x="219" y="263" textAnchor="middle" fontFamily="ui-monospace, monospace" fontSize="14" fontWeight="700" fill="#f3d68c">
{half(t)}
</text>
</g>
<g
ref={hourGroup}
role="slider"
tabIndex={0}
aria-label={`${label}, hour hand`}
aria-valuemin={0}
aria-valuemax={23}
aria-valuenow={Math.floor(t / 60)}
aria-valuetext={text}
onKeyDown={(e) => keys(e, 60)}
style={{ outline: "none" }}
className={handle}
>
<g ref={hourHand} transform={handTransform(t * 0.5)}>
<path d={`M${CX - 3} ${CY + 18}L${CX - 2.4} ${CY - 54}H${CX + 2.4}L${CX + 3} ${CY + 18}Z`} fill="#1b2f6e" />
<circle cx={CX} cy={CY - 66} r="14" fill="none" stroke="#1b2f6e" strokeWidth="5" />
<circle className={ring} cx={CX} cy={CY - 66} r="22" fill="none" stroke="#c0392b" strokeWidth="2.5" />
</g>
</g>
<g
ref={minuteGroup}
role="slider"
tabIndex={0}
aria-label={`${label}, minute hand`}
aria-valuemin={0}
aria-valuemax={59}
aria-valuenow={t % 60}
aria-valuetext={text}
onKeyDown={(e) => keys(e, step)}
style={{ outline: "none" }}
className={handle}
>
<g ref={minuteHand} transform={handTransform(t * 6)}>
<path d={`M${CX - 2.4} ${CY + 26}L${CX - 1.6} ${CY - 108}H${CX + 1.6}L${CX + 2.4} ${CY + 26}Z`} fill="#1b2f6e" />
<circle cx={CX} cy={CY - 118} r="10" fill="none" stroke="#1b2f6e" strokeWidth="4" />
<circle className={ring} cx={CX} cy={CY - 118} r="17" fill="none" stroke="#c0392b" strokeWidth="2.5" />
</g>
</g>
<circle aria-hidden cx={CX} cy={CY} r="9" fill={`url(#${id}b)`} stroke="#5c3f12" strokeWidth="1" />
</svg>
<button
type="button"
onClick={() => set(t + 720)}
aria-label={`Switch to ${t < 720 ? "PM" : "AM"}, now ${text}`}
style={{ left: `${(198 / 360) * 100}%`, top: `${(241 / 384) * 100}%`, width: `${(42 / 360) * 100}%`, height: `${(34 / 384) * 100}%` }}
className="absolute cursor-pointer rounded focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#c0392b]"
/>
{name && <input type="hidden" name={name} value={to24(t)} />}
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/time-dial.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/time-dial-demo.tsx
import { TimeDial } from "@/components/ui/time-dial"; export default function TimeDialExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[radial-gradient(circle_at_50%_45%,#23423a,#0b1714_80%)] p-2"> <TimeDial className="w-[min(90cqh,92cqw,380px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| label | string | "Time" | Read out by screen readers. |
| value | string | - | Controlled time, "HH:MM" in 24 hours. |
| defaultValue | string | "10:10" | Uncontrolled start time, "HH:MM" in 24 hours. |
| step | number | 5 | Minutes moved by one drag step or arrow key. |
| onChange | (time: string) => void | - | Called with "HH:MM" in 24 hours, like a time input. |
| name | string | - | Form field name. Sends "HH:MM" in 24 hours. |
| className | string | "w-full max-w-xs" | Set the width here. Everything scales with it. |