ESC

Type to search the knowledge base.

Analog Clock

CSS rotate hands from Date, tick every second, and keep 12-hour geometry honest. rAF vs setInterval, and reduced-motion for a clock that still tells time.

beginner6 min read
  • machine-coding
  • interview
  • css
  • javascript

Two failure modes show up in the first five minutes. The hands rotate from the top-left corner of a tall rectangle, so 3 o’clock points at the floor. Or setInterval increments a seconds counter, the tab sleeps, and the clock is four minutes late. An analog clock reads Date, converts hours, minutes, and seconds into degrees, and applies transform: rotate. The tick is a scheduler. The time source is the system clock.

Reduced motion does not mean a frozen clock. It means you skip sweeping animation and still jump the hands to the current time.

Key takeaways

  • Seconds and minutes: 360 / 60 = 6 degrees per unit. Hours: 360 / 12 = 30.
  • Hour hand includes minutes. Minute hand includes seconds if you want honest geometry.
  • transform-origin is the center of the clock, the pivot of the hand.
  • Read new Date() every tick. Do not count.
  • Honor prefers-reduced-motion: discrete jumps, no 1s CSS transition, still update.

Degrees from hours, minutes, seconds

Face is 360 degrees. 12 is 0. 3 is 90. You rotate clockwise from 12.

function hands(d: Date) {
  const ms = d.getMilliseconds();
  const s = d.getSeconds() + ms / 1000;
  const m = d.getMinutes() + s / 60;
  const h = (d.getHours() % 12) + m / 60;

  return {
    second: s * 6,
    minute: m * 6,
    hour: h * 30,
  };
}

Why the fractions: at 3:30 the hour hand is halfway to 4, not sitting on 3. At 3:30:45 the minute hand is not glued to 30. Interviewers notice a hour hand that only moves on getHours(). % 12 matters at 00:00 and 12:00. getHours() is 0–23.

A second tick can ignore milliseconds and use integer seconds: getSeconds() * 6. That is a stepped second hand. A sweep uses requestAnimationFrame and the ms term. Ask which they want. Default to stepped; it matches wall clocks people know and is cheaper.

Problem statement

Render a 12-hour analog clock: hour, minute, and second hands, driven by the current time. Hands update at least every second. Geometry must be correct. Respect reduced motion without stopping the clock.

Requirements

Must have

  • Circular face, 12-hour layout
  • Three hands with correct angles from Date
  • Tick at least once per second
  • Visible current time as text as well (for a11y and for you while debugging)

Should have

  • Align the next tick to the second boundary (1000 - Date.now() % 1000)
  • prefers-reduced-motion: no sweep, no CSS transition on hands
  • transform only (compositor-friendly)

Nice to have

  • Minute marks
  • Sweep second hand via rAF
  • Timezone prop

Planning (5 minutes out loud)

  1. Math first on a whiteboard: 6° and 30°, hour includes minutes
  2. CSS hands as absolutely positioned strips, origin at the hub
  3. Scheduler: setTimeout chained to the next whole second, or rAF
  4. MVP: three hands + <time> text
  5. Then reduced motion

Do not draw 12 number divs before the angles work.

Architecture

AnalogClock
├── Face
│   ├── HourHand
│   ├── MinuteHand
│   └── SecondHand
└── <time> (sr-only or visible)

Implementation sketch

function AnalogClock() {
  const [now, setNow] = useState(() => new Date());
  const reduced = usePrefersReducedMotion();

  useEffect(() => {
    let timer: number;

    const tick = () => {
      setNow(new Date());
      const delay = reduced ? 1000 : 1000 - (Date.now() % 1000);
      timer = window.setTimeout(tick, delay);
    };

    tick();
    return () => window.clearTimeout(timer);
  }, [reduced]);

  const { hour, minute, second } = hands(now);
  const transition = reduced ? "none" : "transform 0.2s linear";

  return (
    <div>
      <div className="clock" role="img" aria-hidden="true">
        <span
          className="hand hour"
          style={{ transform: `rotate(${hour}deg)`, transition }}
        />
        <span
          className="hand minute"
          style={{ transform: `rotate(${minute}deg)`, transition }}
        />
        <span
          className="hand second"
          style={{ transform: `rotate(${second}deg)`, transition }}
        />
      </div>
      <time dateTime={now.toISOString()}>
        {now.toLocaleTimeString()}
      </time>
    </div>
  );
}

function usePrefersReducedMotion() {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const on = () => setReduced(mq.matches);
    on();
    mq.addEventListener("change", on);
    return () => mq.removeEventListener("change", on);
  }, []);
  return reduced;
}
.clock {
  position: relative;
  width: 16rem;
  height: 16rem;
  border: 4px solid currentColor;
  border-radius: 50%;
}
.hand {
  position: absolute;
  left: 50%;
  bottom: 50%;
  width: 4px;
  background: currentColor;
  transform-origin: 50% 100%;
  translate: -50% 0;
}
.hour { height: 28%; }
.minute { height: 38%; }
.second { height: 42%; width: 2px; }

transform-origin: 50% 100% plus bottom: 50% pins the tail of the hand on the center. If you origin at 50% 50% you must draw the hand across the hub (half the length up, half down) or it will look like a propeller.

setInterval(fn, 1000) drifts and bunches after backgrounding. Chaining setTimeout to the remainder of the current second stays aligned with wall time because you still read Date each run. rAF is right for a sweep: write CSS variables on a ref and skip React renders if they care about 60 updates a second.

A 0.2s transition on transform is optional polish. Across 59s → 0s it will spin the long way backward unless you disable the transition on wrap, or always use the unwrapped angle (seconds * 6 can go past 360 if you include minutes-as-seconds). Integer getSeconds() * 6 jumps 354 → 0. With reduced motion, transition: none so that jump is instant, which is what you want.

Accessibility essentials

Piece Approach
Time Visible or sr-only <time datetime> with a textual clock
Face Decorative: aria-hidden if the text is present
Live Do not aria-live every second. Polite once per minute is already a lot
Motion prefers-reduced-motion: reduce disables sweep/transition, not the tick

A clock that only exists as rotating bars is a pretty picture. Screen readers need the digits.

Interview scoring

Signal What they want
Math 6° and 30°, hour hand moves with minutes
Origin Hands pivot on the center
Time source Date, not a counter
Tick Interval or rAF with a cleanup
Motion Clock still works under reduced motion
A11y Textual time

They may ask rAF vs setInterval out loud. rAF syncs to frames and pauses in background tabs in a way that is friendly to a sweep. A one-hertz clock does not need it. setTimeout aligned to the next second is the better default.

Footguns

  1. getHours() without % 12
  2. Hour hand ignores minutes
  3. transform-origin at the CSS default, hands orbit the corner
  4. Counting seconds in state so a background tab desyncs
  5. aria-live="polite" on the ticking <time>
  6. Stopping the clock when reduced motion is on

Interview out-loud answer

I would compute angles from Date: seconds and minutes times 6 degrees, hours modulo 12 times 30, with the hour hand offset by minutes. Hands are positioned from the hub with transform-origin at the base. Each tick reads the clock again. I would chain setTimeout to the next whole second rather than increment a counter. For reduced motion I drop CSS transitions and any rAF sweep, and I still update the hands and a <time> element so the clock tells time.

Further reading