Hasan Harman
Lab

Analog Clock

A Braun-inspired analog clock drawn in SVG. Dial, case, hand and marker colors, numerals, movement, time zone and brand text are all props — ticks live or freezes at a fixed time. No dependencies.

121234567891011BRAUNquartz
"use client";

import { useEffect, useId, useMemo, useRef, useState } from "react";

type Movement = "quartz" | "mechanical" | "sweep";
type Numerals = "arabic" | "roman" | "ticks" | "none";

const ROMAN = ["XII", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI"];

export type AnalogClockProps = {
  /** Freeze at a fixed time (Date or "HH:MM" / "HH:MM:SS"); omit to tick live. */
  time?: Date | string;
  /** Tick in real time. Ignored when a fixed `time` is provided. */
  live?: boolean;
  /** IANA time zone for live mode, e.g. "Europe/Istanbul". Defaults to local. */
  timeZone?: string;
  /** Second-hand motion: quartz steps each second, mechanical stutters ~8/s, sweep glides. */
  movement?: Movement;
  /** Diameter of the clock, in pixels. */
  size?: number;
  /** Hour markers style. */
  numerals?: Numerals;
  /** Show the thin minute ticks between the hour markers. */
  minuteTrack?: boolean;
  /** Show the second hand at all. */
  showSeconds?: boolean;

  /** Dial (face) background — any CSS color. */
  dialColor?: string;
  /** Case / bezel ring color. */
  caseColor?: string;
  /** Hour & minute hand color. */
  handColor?: string;
  /** Second hand color — the accent that pops against the dial. */
  secondColor?: string;
  /** Markers, numerals and minute-track color. */
  markerColor?: string;

  /** Brand name printed on the upper dial (empty string hides it). */
  brand?: string;
  /** Small line under the brand, e.g. "quartz". */
  caption?: string;
  /** Two-part footer near 6 o'clock, e.g. ["Made in", "Germany"]. */
  footer?: [string, string];

  /** A CSS color to cast a glowing halo around the case — the "space watch" look. */
  glow?: string;
  /** Extra classes on the outer wrapper. */
  className?: string;
};

/** Cartesian point on the 100×100 dial for a radius and clock angle (deg, 12 o'clock = 0). */
function point(radius: number, deg: number): [number, number] {
  const a = (deg * Math.PI) / 180;
  return [50 + radius * Math.sin(a), 50 - radius * Math.cos(a)];
}

/** Resolve the props into the h/m/s the hands should point at. */
function useClockTime(
  time: AnalogClockProps["time"],
  live: boolean,
  timeZone: string | undefined,
  movement: Movement
) {
  const [now, setNow] = useState(() => new Date(0));
  const frozen = useMemo(() => resolveFixed(time), [time]);
  const raf = useRef<number | undefined>(undefined);

  useEffect(() => {
    if (frozen || !live) return;
    // rAF every frame keeps sweep smooth and the second-crossing exact for quartz.
    const tick = () => {
      setNow(new Date());
      raf.current = requestAnimationFrame(tick);
    };
    raf.current = requestAnimationFrame(tick);
    return () => {
      if (raf.current) cancelAnimationFrame(raf.current);
    };
  }, [frozen, live]);

  if (frozen) return frozen;

  const parts = timeZone
    ? zonedParts(now, timeZone)
    : { h: now.getHours(), m: now.getMinutes(), s: now.getSeconds(), ms: now.getMilliseconds() };

  return angles(parts, movement);
}

/** Convert h/m/s(+ms) into hand rotation angles for the given movement. */
function angles(t: { h: number; m: number; s: number; ms: number }, movement: Movement) {
  const frac = t.s + t.ms / 1000;
  const sub =
    movement === "sweep"
      ? frac
      : movement === "mechanical"
        ? Math.floor(frac * 8) / 8 // ~8 beats/sec stutter
        : t.s; // quartz: whole-second steps

  const second = sub * 6; // 360 / 60
  const minute = (t.m + sub / 60) * 6;
  const hour = ((t.h % 12) + t.m / 60 + sub / 3600) * 30; // 360 / 12
  return { hour, minute, second };
}

/** Freeze the hands at a fixed time — snapped like a still photo. */
function resolveFixed(time: AnalogClockProps["time"]) {
  if (!time) return null;
  if (typeof time !== "string") {
    return angles({ h: time.getHours(), m: time.getMinutes(), s: time.getSeconds(), ms: 0 }, "quartz");
  }
  const parsed = parseTimeString(time);
  return parsed ? angles({ ...parsed, ms: 0 }, "quartz") : null;
}

function parseTimeString(value: string) {
  const match = value.trim().match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
  if (!match) return null;
  return { h: Number(match[1]), m: Number(match[2]), s: match[3] ? Number(match[3]) : 0 };
}

// Formatters are pricey to build, so cache one per zone — this runs every animation frame.
const zoneFormatters = new Map<string, Intl.DateTimeFormat>();
function zoneFormatter(timeZone: string) {
  let fmt = zoneFormatters.get(timeZone);
  if (!fmt) {
    fmt = new Intl.DateTimeFormat("en-US", {
      timeZone,
      hour12: false,
      hour: "2-digit",
      minute: "2-digit",
      second: "2-digit",
    });
    zoneFormatters.set(timeZone, fmt);
  }
  return fmt;
}

/** Extract h/m/s for an IANA time zone without pulling in a date library. */
function zonedParts(date: Date, timeZone: string) {
  try {
    const parts = Object.fromEntries(
      zoneFormatter(timeZone).formatToParts(date).map((p) => [p.type, p.value])
    );
    return {
      h: Number(parts.hour) % 24,
      m: Number(parts.minute),
      s: Number(parts.second),
      ms: date.getMilliseconds(),
    };
  } catch {
    return { h: date.getHours(), m: date.getMinutes(), s: date.getSeconds(), ms: date.getMilliseconds() };
  }
}

export default function AnalogClock({
  time,
  live = true,
  timeZone,
  movement = "quartz",
  size = 240,
  numerals = "arabic",
  minuteTrack = true,
  showSeconds = true,
  dialColor = "#0b0b0c",
  caseColor = "#1c1c1e",
  handColor = "#f4f4f5",
  secondColor = "#f5a623",
  markerColor = "#d4d4d8",
  brand = "BRAUN",
  caption = "quartz",
  footer,
  glow,
  className,
}: AnalogClockProps) {
  const { hour, minute, second } = useClockTime(time, live, timeZone, movement);
  // Unique ids per instance — shared ids would make every clock reuse the first's defs.
  const uid = useId();
  const caseId = `clk-case-${uid}`;
  const dialId = `clk-dial-${uid}`;
  const glassId = `clk-glass-${uid}`;
  // Only continuous sweep gets a transition; stepped movements must snap, not float.
  const secondEase = movement === "sweep" ? "transform 0.9s cubic-bezier(0.4,0,0.2,1)" : "none";

  const markerR = 42;
  const numberR = 35;

  return (
    <div
      className={className}
      style={{
        width: size,
        height: size,
        position: "relative",
        // drop-shadow follows the SVG's circular alpha, so the glow hugs the case.
        filter: glow
          ? `drop-shadow(0 0 ${size * 0.03}px ${glow}) drop-shadow(0 0 ${size * 0.08}px ${glow})`
          : undefined,
      }}
    >
      <svg viewBox="0 0 100 100" width={size} height={size} role="img" aria-label="Clock">
        <defs>
          {/* Case: brushed metal ring around the dial. */}
          <radialGradient id={caseId} cx="50%" cy="42%" r="60%">
            <stop offset="0%" stopColor={mix(caseColor, "#ffffff", 0.18)} />
            <stop offset="70%" stopColor={caseColor} />
            <stop offset="100%" stopColor={mix(caseColor, "#000000", 0.4)} />
          </radialGradient>
          {/* Dial: a soft vignette gives the face a little depth. */}
          <radialGradient id={dialId} cx="50%" cy="44%" r="62%">
            <stop offset="0%" stopColor={mix(dialColor, "#ffffff", 0.06)} />
            <stop offset="72%" stopColor={dialColor} />
            <stop offset="100%" stopColor={mix(dialColor, "#000000", 0.35)} />
          </radialGradient>
          {/* Glass: a single specular sweep across the crystal. */}
          <linearGradient id={glassId} x1="0" y1="0" x2="1" y2="1">
            <stop offset="0%" stopColor="rgba(255,255,255,0.22)" />
            <stop offset="42%" stopColor="rgba(255,255,255,0.04)" />
            <stop offset="60%" stopColor="rgba(255,255,255,0)" />
          </linearGradient>
        </defs>

        {/* Case + crown */}
        <circle cx="50" cy="50" r={50} fill={`url(#${caseId})`} />
        <rect x="98.5" y="46.5" width="3" height="7" rx="1" fill={mix(caseColor, "#000000", 0.2)} />
        {/* Dial */}
        <circle cx="50" cy="50" r={46} fill={`url(#${dialId})`} />

        {/* Minute track */}
        {minuteTrack &&
          Array.from({ length: 60 }).map((_, i) => {
            const isHour = i % 5 === 0;
            const [x1, y1] = point(markerR + 3 - (isHour ? 3.4 : 1.6), i * 6);
            const [x2, y2] = point(markerR + 3, i * 6);
            return (
              <line
                key={i}
                x1={x1}
                y1={y1}
                x2={x2}
                y2={y2}
                stroke={markerColor}
                strokeWidth={isHour ? 0.9 : 0.35}
                strokeLinecap="round"
                opacity={isHour ? 0.95 : 0.5}
              />
            );
          })}

        {/* Hour markers / numerals */}
        {numerals !== "none" &&
          Array.from({ length: 12 }).map((_, i) => {
            if (numerals === "ticks") {
              const [x1, y1] = point(markerR - 5, i * 30);
              const [x2, y2] = point(markerR - 1, i * 30);
              return (
                <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke={markerColor} strokeWidth={1.6} strokeLinecap="round" />
              );
            }
            const [x, y] = point(numberR, i * 30);
            const label = numerals === "roman" ? ROMAN[i] : String(i === 0 ? 12 : i);
            return (
              <text
                key={i}
                x={x}
                y={y}
                fill={markerColor}
                fontSize={numerals === "roman" ? 5.2 : 6.4}
                fontFamily="ui-sans-serif, system-ui, sans-serif"
                fontWeight={500}
                textAnchor="middle"
                dominantBaseline="central"
              >
                {label}
              </text>
            );
          })}

        {/* Brand + caption */}
        {brand ? (
          <text
            x="50"
            y="36"
            fill={markerColor}
            fontSize="6"
            fontFamily="ui-sans-serif, system-ui, sans-serif"
            fontWeight={700}
            letterSpacing="0.5"
            textAnchor="middle"
          >
            {brand}
          </text>
        ) : null}
        {caption ? (
          <text
            x="50"
            y="42"
            fill={markerColor}
            fontSize="3"
            fontFamily="ui-sans-serif, system-ui, sans-serif"
            textAnchor="middle"
            opacity={0.75}
          >
            {caption}
          </text>
        ) : null}
        {footer ? (
          <text
            x="50"
            y="68"
            fill={markerColor}
            fontSize="2.6"
            fontFamily="ui-sans-serif, system-ui, sans-serif"
            textAnchor="middle"
            opacity={0.7}
          >
            {footer[0]}
            {"  "}
            {footer[1]}
          </text>
        ) : null}

        {/* Hour hand */}
        <g transform={`rotate(${hour} 50 50)`}>
          <rect x="48.4" y="27" width="3.2" height="26" rx="1.6" fill={handColor} />
        </g>
        {/* Minute hand */}
        <g transform={`rotate(${minute} 50 50)`}>
          <rect x="48.9" y="16" width="2.2" height="37" rx="1.1" fill={handColor} />
        </g>
        {/* Second hand */}
        {showSeconds ? (
          <g transform={`rotate(${second} 50 50)`} style={{ transition: secondEase }}>
            <rect x="49.5" y="14" width="1" height="40" fill={secondColor} />
            {/* Counterweight tail */}
            <rect x="49" y="50" width="2" height="10" rx="1" fill={secondColor} />
            <circle cx="50" cy="50" r="2.4" fill={secondColor} />
          </g>
        ) : null}
        {/* Center pin */}
        <circle cx="50" cy="50" r="1.2" fill={handColor} />

        {/* Crystal glare — sits above everything, ignores pointer events. */}
        <circle cx="50" cy="50" r={46} fill={`url(#${glassId})`} pointerEvents="none" />
      </svg>
    </div>
  );
}

/** Blend two hex colors — tiny local helper so the component ships dependency-free. */
function mix(a: string, b: string, amount: number) {
  const pa = hex(a);
  const pb = hex(b);
  if (!pa || !pb) return a;
  const ch = (i: 0 | 1 | 2) => Math.round(pa[i] + (pb[i] - pa[i]) * amount);
  return `rgb(${ch(0)}, ${ch(1)}, ${ch(2)})`;
}

function hex(value: string): [number, number, number] | null {
  const m = value.trim().match(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i);
  if (!m) return null;
  const h = m[1].length === 3 ? m[1].replace(/./g, (c) => c + c) : m[1];
  return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}

Installation

$ pnpm dlx shadcn@latest add https://hasanharman.dev/r/analog-clock.json

Usage

import AnalogClock from "@/components/analog-clock";

export default function Example() {
  return (
    <AnalogClock
      size={240}
      movement="quartz"
      brand="BRAUN"
      caption="quartz"
    />
  );
}

Examples

White dial

The classic white Braun — a white dial, brushed-steel case, black hands and numerals, and the signature yellow second hand. Same props as the black dial, recolored.

121234567891011BRAUNquartz
<AnalogClock
  dialColor="#ffffff"
  caseColor="#c4c7ca"
  handColor="#111111"
  markerColor="#111111"
  secondColor="#e8a800"
/>

Mechanical sweep

The movement prop changes how the second hand advances. "mechanical" gives the high-frequency stutter of an automatic; "sweep" glides continuously; "quartz" steps once per second.

Watch the seconds
121234567891011BRAUNautomatic
<AnalogClock movement="mechanical" caption="automatic" />

Fixed time & minimal markers

Pass a time as a Date or "HH:MM" string to freeze the clock — handy for stills. Set numerals to "ticks" for a marker-only dial.

<AnalogClock time="10:09" live={false} numerals="ticks" />

Other time zone

Give it an IANA timeZone and it renders that city's live time — build a wall of them for a world clock.

121234567891011BRAUNTokyo
<AnalogClock timeZone="Asia/Tokyo" caption="Tokyo" />

Space watch

Give it a cosmic palette and a glow color and it turns into a space watch — the halo hugs the case. This is the one on the USS Enterprise line of my homepage.

STARFLEETstardate
<AnalogClock
  movement="sweep"
  numerals="ticks"
  dialColor="#0a0a1a"
  handColor="#e0e7ff"
  secondColor="#22d3ee"
  markerColor="#818cf8"
  glow="#22d3ee"
/>

API Reference

PropTypeDefaultDescription
timeDate | stringFreeze the clock at a fixed time (Date, or "HH:MM" / "HH:MM:SS"). Omit to tick live.
livebooleantrueTick in real time. Ignored when a fixed time is provided.
timeZonestringlocalIANA time zone for live mode, e.g. "Europe/Istanbul".
movement"quartz" | "mechanical" | "sweep""quartz"How the second hand advances — quartz steps each second, mechanical stutters at ~8 beats/s, sweep glides continuously.
sizenumber240Diameter of the clock, in pixels.
numerals"arabic" | "roman" | "ticks" | "none""arabic"Style of the hour markers around the dial.
minuteTrackbooleantrueShow the thin minute ticks between the hour markers.
showSecondsbooleantrueShow the second hand.
dialColorstring"#0b0b0c"Dial (face) background — any CSS color.
caseColorstring"#1c1c1e"Case / bezel ring color.
handColorstring"#f4f4f5"Hour & minute hand color.
secondColorstring"#f5a623"Second hand accent color.
markerColorstring"#d4d4d8"Markers, numerals and minute-track color.
brandstring"BRAUN"Brand name printed on the upper dial (empty string hides it).
captionstring"quartz"Small line under the brand, e.g. "quartz".
footer[string, string]Two-part footer near 6 o'clock, e.g. ["Made in", "Germany"].
glowstringA CSS color to cast a glowing halo around the case — the "space watch" look.
classNamestringExtra classes on the outer wrapper.