Skip to main content

ns-ui

Time Picker Sundial

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.

Usage

registry/core/time-picker-sundial/demo.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { GnomonSet } from "./component";

// Self-driving loop through a handful of times of day so the screenshot
// pass catches both a short AM shadow and a long PM one.
const SCRIPT: { hour: number; minute: number }[] = [
  { hour: 9, minute: 0 },
  { hour: 11, minute: 30 },
  { hour: 14, minute: 15 },
  { hour: 19, minute: 45 },
];

export default function GnomonSetDemo() {
  const [step, setStep] = useState(0);
  const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const current = SCRIPT[step % SCRIPT.length]!;

  useEffect(() => {
    timerRef.current = setTimeout(() => setStep((s) => s + 1), 2600);
    return () => clearTimeout(timerRef.current);
  }, [step]);

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / time-picker-sundial
      </p>

      <div className="flex flex-col items-center rounded-[12px] border border-border bg-background p-8">
        <GnomonSet hour={current.hour} minute={current.minute} />
      </div>

      <p className="max-w-md text-center text-xs text-ns-muted">
        Drag the outer ring for the hour, the inner ring for 5-minute
        detents. Keep dragging past 12 and the shadow lengthens into PM.
      </p>
    </div>
  );
}

Component

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;
  /** uncontrolled initial hour, 0-23 */
  defaultHour?: number;
  /** uncontrolled initial minute, 0-59 */
  defaultMinute?: number;
  /** called with the new hour and minute on any change */
  onChange?: (hour: number, minute: number) => void;
  /** extra classes merged onto the rendered root element */
  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(--ns-muted)" strokeWidth={1} />
                <text x={nx} y={ny + 3} textAnchor="middle" className="font-mono" style={{ fontSize: 8 }} fill="var(--ns-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(--ns-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(--ns-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; }
}
`;

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/time-picker-sundial.tsx, so point the import there.

Use when a time of day set by dragging rather than typing, hour on the outer ring and 5-minute detents on the inner one, with the gnomon shadow lengthening past noon so AM and PM stay readable without a separate control; pick date-picker-moon instead when the value is a calendar date, or stepper-needle when the time is better entered as a bounded numeric field than found on a dial.

Install

npx shadcn add https://design.helpmarq.com/r/time-picker-sundial.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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.

Props

PropTypeDefaultDescription
hour?number—0-23, controlled.
minute?number—0-59, controlled — only 5-minute values are reachable via the UI.
defaultHour?number9uncontrolled initial hour, 0-23
defaultMinute?number0uncontrolled initial minute, 0-59
onChange?(hour: number, minute: number) => void—called with the new hour and minute on any change
className?string—extra classes merged onto the rendered root element