Time Picker Sundial

Time picker

A time-of-day picker as a flat sundial — drag the outer ring to set the hour and the inner ring for 5-minute detents, while a soft blurred gnomon shadow points at the hour and lengthens from a short AM wedge into a long PM one as you keep dragging past noon.

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

import {
  useEffect,
  useId,
  useRef,
  useState,
  type PointerEvent as ReactPointerEvent,
} from "react";

// ---------------------------------------------------------------------------
// GnomonSet — a time picker as a flat sundial. Two concentric draggable
// rings (role="slider" each): the outer sets the hour, the inner sets
// minutes in 5-minute detents. A gnomon shadow (a soft blurred wedge)
// points at the current hour; its LENGTH is what tells AM from PM (short =
// high sun = AM, long = PM), not a separate control — dragging the hour
// ring continuously past the 12 mark rolls the hour past 11 -> 12 and the
// shadow lengthens, exactly like a real day turning into a real night.
//
// Dragging is the animation hot path: pointermove accumulates a float hour
// (or minute) in a ref and writes the wedge/tick/readout DOM directly —
// no React state per pointer event. The drag's FINAL value is the only
// thing that ever reaches React state (on pointerup), so a keyboard arrow
// press (which legitimately re-renders from state) can never fight a
// stale mid-drag DOM write.
// ---------------------------------------------------------------------------

export interface GnomonSetProps {
  /** 0-23, controlled. */
  hour?: number;
  /** 0-59, controlled — only 5-minute values are reachable via the UI. */
  minute?: number;
  defaultHour?: number;
  defaultMinute?: number;
  onChange?: (hour: number, minute: number) => void;
  className?: string;
}

const VIEW = 160;
const C = 80;
const R_OUT_TICK_OUT = 66;
const R_OUT_TICK_IN = 59;
const R_NUMERAL = 48;
const R_OUT_HIT = 62;
const OUT_BAND = 18;
const R_IN_TICK_OUT = 36;
const R_IN_TICK_IN = 31;
const R_IN_HIT = 34;
const IN_BAND = 12;
const SHADOW_AM = 32;
const SHADOW_PM = 58;
const WEDGE_HALF_DEG = 6;

function toRad(deg: number): number {
  return (deg * Math.PI) / 180;
}
// Rounded to a fixed precision: server (Node) and client (browser) trig can
// differ in the last bit or two, which is enough to fail hydration when the
// raw float is serialized into the SSR-ed markup. A few decimals of pixel
// precision is plenty for a 160-unit dial and makes both renders identical.
function round(n: number): number {
  return Math.round(n * 1000) / 1000;
}
function pointAt(angleDeg: number, r: number): [number, number] {
  const rad = toRad(angleDeg);
  return [round(C + r * Math.cos(rad)), round(C + r * Math.sin(rad))];
}
function hourAngle(hour24: number): number {
  return -90 + (hour24 % 12) * 30;
}
function minuteAngle(minute: number): number {
  return -90 + (minute / 5) * 30;
}
function shortestDelta(a: number, b: number): number {
  return ((((a - b + 540) % 360) + 360) % 360) - 180;
}
function wrap(n: number, mod: number): number {
  return ((n % mod) + mod) % mod;
}

function wedgePoints(angleDeg: number, length: number): string {
  const [tx, ty] = pointAt(angleDeg, length);
  const [lx, ly] = pointAt(angleDeg - WEDGE_HALF_DEG, length * 0.86);
  const [rx, ry] = pointAt(angleDeg + WEDGE_HALF_DEG, length * 0.86);
  return `${C},${C} ${lx},${ly} ${tx},${ty} ${rx},${ry}`;
}

function formatReadout(hour24: number, minute: number): string {
  const h12 = hour24 % 12 === 0 ? 12 : hour24 % 12;
  const mm = String(minute).padStart(2, "0");
  const meridiem = hour24 < 12 ? "AM" : "PM";
  return `${String(h12).padStart(2, "0")}:${mm} ${meridiem}`;
}

export function GnomonSet({
  hour,
  minute,
  defaultHour = 9,
  defaultMinute = 0,
  onChange,
  className = "",
}: GnomonSetProps) {
  const autoId = useId();
  const fid = `gs-blur-${autoId.replace(/:/g, "")}`;

  const isControlled = hour !== undefined && minute !== undefined;
  const [uHour, setUHour] = useState(defaultHour);
  const [uMinute, setUMinute] = useState(defaultMinute);
  const curHour = isControlled ? hour! : uHour;
  const curMinute = isControlled ? minute! : uMinute;

  const wedgeRef = useRef<SVGPolygonElement | null>(null);
  const meridiemRef = useRef<SVGTextElement | null>(null);
  const readoutRef = useRef<HTMLSpanElement | null>(null);
  const hourHandleRef = useRef<SVGCircleElement | null>(null);
  const minuteHandleRef = useRef<SVGCircleElement | null>(null);
  const outerRingRef = useRef<SVGCircleElement | null>(null);
  const innerRingRef = useRef<SVGCircleElement | null>(null);
  const hoverTickRef = useRef<SVGLineElement | null>(null);
  const svgRef = useRef<SVGSVGElement | null>(null);

  const dragHourFloat = useRef(curHour);
  const dragMinuteFloat = useRef(curMinute);
  const prevAngleRef = useRef(0);
  const draggingRef = useRef<"hour" | "minute" | null>(null);
  const [announce, setAnnounce] = useState("");

  const paint = (h: number, m: number) => {
    const hAngle = hourAngle(h);
    const length = h < 12 ? SHADOW_AM : SHADOW_PM;
    wedgeRef.current?.setAttribute("points", wedgePoints(hAngle, length));
    const [hx, hy] = pointAt(hAngle, R_OUT_HIT);
    hourHandleRef.current?.setAttribute("cx", String(hx));
    hourHandleRef.current?.setAttribute("cy", String(hy));
    const mAngle = minuteAngle(m);
    const [mx, my] = pointAt(mAngle, R_IN_HIT);
    minuteHandleRef.current?.setAttribute("cx", String(mx));
    minuteHandleRef.current?.setAttribute("cy", String(my));
    if (meridiemRef.current) meridiemRef.current.textContent = h < 12 ? "AM" : "PM";
    if (readoutRef.current) readoutRef.current.textContent = formatReadout(h, m);
    outerRingRef.current?.setAttribute("aria-valuenow", String(h));
    outerRingRef.current?.setAttribute(
      "aria-valuetext",
      `${h % 12 === 0 ? 12 : h % 12} o'clock ${h < 12 ? "AM" : "PM"}`
    );
    innerRingRef.current?.setAttribute("aria-valuenow", String(m));
    innerRingRef.current?.setAttribute("aria-valuetext", `${m} minutes`);
  };

  useEffect(() => paint(curHour, curMinute), [curHour, curMinute]);

  const commit = (h: number, m: number) => {
    if (!isControlled) {
      setUHour(h);
      setUMinute(m);
    }
    onChange?.(h, m);
    setAnnounce(formatReadout(h, m));
  };

  // One getBoundingClientRect() per event, in SVG-local (viewBox) coordinates.
  const toLocal = (clientX: number, clientY: number): { x: number; y: number } | null => {
    const svg = svgRef.current;
    if (!svg) return null;
    const rect = svg.getBoundingClientRect();
    const scale = VIEW / rect.width;
    return { x: (clientX - rect.left) * scale, y: (clientY - rect.top) * scale };
  };

  const angleFromEvent = (e: PointerEvent | ReactPointerEvent) => {
    const p = toLocal(e.clientX, e.clientY);
    if (!p) return 0;
    return (Math.atan2(p.y - C, p.x - C) * 180) / Math.PI;
  };

  const startDrag = (which: "hour" | "minute") => (e: ReactPointerEvent) => {
    (e.target as Element).setPointerCapture(e.pointerId);
    draggingRef.current = which;
    dragHourFloat.current = curHour;
    dragMinuteFloat.current = curMinute;
    prevAngleRef.current = angleFromEvent(e);
    e.preventDefault();
  };

  useEffect(() => {
    const onMove = (e: PointerEvent) => {
      const which = draggingRef.current;
      if (!which) return;
      const angle = angleFromEvent(e);
      const delta = shortestDelta(angle, prevAngleRef.current);
      prevAngleRef.current = angle;
      if (which === "hour") {
        dragHourFloat.current += delta / 30;
        const h = wrap(Math.round(dragHourFloat.current), 24);
        paint(h, Math.round(dragMinuteFloat.current / 5) * 5);
      } else {
        dragMinuteFloat.current += (delta / 30) * 5;
        const m = wrap(Math.round(dragMinuteFloat.current / 5) * 5, 60);
        paint(wrap(Math.round(dragHourFloat.current), 24), m);
      }
    };
    const onUp = () => {
      const which = draggingRef.current;
      if (!which) return;
      draggingRef.current = null;
      const h = wrap(Math.round(dragHourFloat.current), 24);
      const m = wrap(Math.round(dragMinuteFloat.current / 5) * 5, 60);
      commit(h, m);
    };
    window.addEventListener("pointermove", onMove);
    window.addEventListener("pointerup", onUp);
    return () => {
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerup", onUp);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isControlled]);

  const onHoverMove = (e: ReactPointerEvent) => {
    if (e.pointerType === "touch") return;
    const p = toLocal(e.clientX, e.clientY);
    if (!p) return;
    const angle = (Math.atan2(p.y - C, p.x - C) * 180) / Math.PI;
    const dist = Math.hypot(p.x - C, p.y - C);
    const [x1, y1] = pointAt(angle, dist - 4);
    const [x2, y2] = pointAt(angle, dist + 6);
    const tick = hoverTickRef.current;
    if (tick) {
      tick.setAttribute("x1", String(x1));
      tick.setAttribute("y1", String(y1));
      tick.setAttribute("x2", String(x2));
      tick.setAttribute("y2", String(y2));
      tick.style.opacity = "1";
    }
  };
  const onHoverLeave = () => {
    if (hoverTickRef.current) hoverTickRef.current.style.opacity = "0";
  };

  const stepHour = (delta: number) => {
    const h = wrap(curHour + delta, 24);
    commit(h, curMinute);
  };
  const stepMinute = (delta: number) => {
    const m = wrap(curMinute + delta * 5, 60);
    commit(curHour, m);
  };

  return (
    <div className={`inline-flex flex-col items-center gap-4 ${className}`}>
      <style>{CSS}</style>
      <span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
        {announce}
      </span>

      <svg
        ref={svgRef}
        viewBox={`0 0 ${VIEW} ${VIEW}`}
        width={VIEW}
        height={VIEW}
        className="ns-gs-dial"
        onPointerMove={onHoverMove}
        onPointerLeave={onHoverLeave}
      >
        <defs>
          <filter id={fid} x="-60%" y="-60%" width="220%" height="220%">
            <feGaussianBlur stdDeviation="1.6" />
          </filter>
        </defs>

        <circle cx={C} cy={C} r={R_OUT_TICK_OUT} fill="none" stroke="var(--border)" strokeWidth={1} />
        <circle cx={C} cy={C} r={R_IN_TICK_OUT} fill="none" stroke="var(--border)" strokeWidth={1} />

        <g aria-hidden="true">
          {Array.from({ length: 12 }, (_, i) => {
            const a = -90 + i * 30;
            const [x1, y1] = pointAt(a, R_OUT_TICK_IN);
            const [x2, y2] = pointAt(a, R_OUT_TICK_OUT);
            const [nx, ny] = pointAt(a, R_NUMERAL);
            return (
              <g key={i}>
                <line x1={x1} y1={y1} x2={x2} y2={y2} stroke="var(--muted)" strokeWidth={1} />
                <text x={nx} y={ny + 3} textAnchor="middle" className="font-mono" style={{ fontSize: 8 }} fill="var(--muted)">
                  {i === 0 ? 12 : i}
                </text>
              </g>
            );
          })}
          {Array.from({ length: 12 }, (_, i) => {
            const a = -90 + i * 30;
            const [x1, y1] = pointAt(a, R_IN_TICK_IN);
            const [x2, y2] = pointAt(a, R_IN_TICK_OUT);
            return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke="var(--muted)" strokeWidth={0.75} />;
          })}
          <line ref={hoverTickRef} stroke="var(--foreground)" strokeWidth={1} opacity={0} className="ns-gs-hovertick" />
        </g>

        <polygon
          ref={wedgeRef}
          points={wedgePoints(hourAngle(curHour), curHour < 12 ? SHADOW_AM : SHADOW_PM)}
          fill="var(--foreground)"
          opacity={0.3}
          filter={`url(#${fid})`}
          className="ns-gs-wedge"
          aria-hidden="true"
        />
        <circle cx={C} cy={C} r={2.5} fill="var(--foreground)" aria-hidden="true" />
        <text ref={meridiemRef} x={C} y={C + 16} textAnchor="middle" className="font-mono" style={{ fontSize: 7 }} fill="var(--foreground)" aria-hidden="true">
          {curHour < 12 ? "AM" : "PM"}
        </text>

        <circle
          ref={outerRingRef}
          role="slider"
          tabIndex={0}
          aria-label="Hour"
          aria-valuemin={0}
          aria-valuemax={23}
          aria-valuenow={curHour}
          aria-valuetext={`${curHour % 12 === 0 ? 12 : curHour % 12} o'clock ${curHour < 12 ? "AM" : "PM"}`}
          cx={C}
          cy={C}
          r={R_OUT_HIT}
          fill="none"
          stroke="transparent"
          strokeWidth={OUT_BAND}
          pointerEvents="stroke"
          className="ns-gs-ring"
          onPointerDown={startDrag("hour")}
          onKeyDown={(e) => {
            if (e.key === "ArrowRight" || e.key === "ArrowUp") {
              e.preventDefault();
              stepHour(1);
            } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
              e.preventDefault();
              stepHour(-1);
            } else if (e.key === "Home") {
              e.preventDefault();
              commit(0, curMinute);
            } else if (e.key === "End") {
              e.preventDefault();
              commit(12, curMinute);
            }
          }}
        />
        <circle
          ref={innerRingRef}
          role="slider"
          tabIndex={0}
          aria-label="Minute"
          aria-valuemin={0}
          aria-valuemax={55}
          aria-valuenow={curMinute}
          aria-valuetext={`${curMinute} minutes`}
          cx={C}
          cy={C}
          r={R_IN_HIT}
          fill="none"
          stroke="transparent"
          strokeWidth={IN_BAND}
          pointerEvents="stroke"
          className="ns-gs-ring"
          onPointerDown={startDrag("minute")}
          onKeyDown={(e) => {
            if (e.key === "ArrowRight" || e.key === "ArrowUp") {
              e.preventDefault();
              stepMinute(1);
            } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
              e.preventDefault();
              stepMinute(-1);
            } else if (e.key === "Home") {
              e.preventDefault();
              commit(curHour, 0);
            } else if (e.key === "End") {
              e.preventDefault();
              commit(curHour, 55);
            }
          }}
        />

        <circle ref={hourHandleRef} cx={pointAt(hourAngle(curHour), R_OUT_HIT)[0]} cy={pointAt(hourAngle(curHour), R_OUT_HIT)[1]} r={3.5} fill="var(--foreground)" aria-hidden="true" />
        <circle
          ref={minuteHandleRef}
          cx={pointAt(minuteAngle(curMinute), R_IN_HIT)[0]}
          cy={pointAt(minuteAngle(curMinute), R_IN_HIT)[1]}
          r={2.6}
          fill="var(--background)"
          stroke="var(--foreground)"
          strokeWidth={1.4}
          aria-hidden="true"
        />
      </svg>

      <span ref={readoutRef} className="font-mono text-3xl tabular-nums text-foreground">
        {formatReadout(curHour, curMinute)}
      </span>
    </div>
  );
}

const CSS = `
.ns-gs-ring { cursor: pointer; outline: none; touch-action: none; }
.ns-gs-ring:focus-visible { stroke: color-mix(in srgb, var(--accent) 22%, transparent); }
.ns-gs-wedge { transition: opacity 150ms ease-out; }
.ns-gs-dial:hover .ns-gs-wedge { opacity: 0.42; }
.ns-gs-hovertick { transition: opacity 150ms ease-out; }
@media (prefers-reduced-motion: reduce) {
  .ns-gs-wedge, .ns-gs-hovertick { transition: none; }
}
`;
Build spec

Build a time-of-day picker shaped like a flat sundial: a 160x160 SVG dial with two concentric draggable rings. The OUTER ring (radius ~62, a wide invisible hit band using stroke="transparent" strokeWidth=18 and pointer-events="stroke" so only the ring band — not the interior — captures pointer events) sets the HOUR as an internal 0-23 value; 12 hairline tick marks plus Geist Mono numerals (12 at top, 1-11 clockwise) sit just inside it, matching standard clock-face placement (angle = -90 + hourOf12*30 degrees, hourOf12 = hour24 % 12, so 0/12/24-equivalent sits at the top). The INNER ring (radius ~34, hit band width 12) sets MINUTES in 5-minute detents via 12 finer tick marks at the same angular spacing, no numerals. A gnomon shadow — a soft, blurred (feGaussianBlur stdDeviation ~1.6) triangular wedge polygon from the dial center out to the current hour's angle, var(--foreground) at ~0.3 opacity — is the picker's headline feature: its length is SHORT (32 of the 80-unit radius) when hour24 < 12 (AM, 'high sun') and LONG (58) when hour24 >= 12 (PM), so continuing to drag the hour ring clockwise past the 12-o'clock mark doesn't just wrap the numeral back to 1 — it visibly lengthens the shadow, reading as morning turning into afternoon. A small Geist Mono 'AM'/'PM' caption sits just below the dial's center hub, updating live with the same condition. Both rings are role="slider" (aria-valuemin/max/now/valuetext, e.g. "3 o'clock PM" / "35 minutes"), keyboard-operable with ArrowUp/ArrowRight to step +1 (hour or 5-minute unit) and ArrowLeft/ArrowDown to step -1, Home jumping to the reference minimum (hour ring: midnight, 00:00; minute ring: :00) and End jumping to the reference maximum (hour ring: noon, 12:00; minute ring: :55). Dragging is the animation hot path: pointerdown captures the pointer and starts accumulating a floating-point hour (or minute) value in a ref from the signed angular delta between successive pointermove events (delta/30 degrees per hour, or per 5 minutes for the inner ring) — NOT from an absolute snap-to-nearest-tick each event, which is what makes continuous multi-revolution dragging (and the AM/PM rollover) feel natural. Every pointermove writes the wedge polygon's `points`, both ring handles' cx/cy, the AM/PM caption's textContent, the big readout's textContent, and both rings' aria-valuenow/aria-valuetext directly via refs (setAttribute/textContent) — no React state during the drag. Only on pointerup does the final rounded value get committed through React state (or the controlled `onChange` callback) once. A separate hover affordance — independent of dragging, tracked via a lightweight pointermove-over-the-dial handler that also writes directly to a ref'd line element rather than React state — draws a faint short radial tick at the exact (unsnapped) angle under the pointer, and the shadow wedge's opacity rises slightly on hover via a plain CSS `:hover` rule (a legitimate CSS transition, not JS, since it's cosmetic and not per-drag-frame). The chosen time reads out large (text-3xl) in Geist Mono tabular-nums below the dial as 'HH:MM AM/PM'. A dedicated sr-only span (role="status", aria-live="polite", aria-atomic="true") announces the same readout string whenever a value is committed (drag release or keyboard step) — not during the live drag itself, to avoid spamming. prefers-reduced-motion doesn't change the interaction model (there was never a continuous tween on hour/minute changes to begin with — the wedge always jumps directly to its new angle, whether via drag-follow or a keyboard step) but does drop the hover-opacity and hover-tick CSS transitions to instant. Props: hour/minute (controlled, 0-23 / 0-59), defaultHour/defaultMinute (uncontrolled default), onChange(hour, minute), className. Zero dependencies.

Tags
time-pickersliderdialsvgdragkeyboard-navigationaccessibilitysundial