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.
- 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 = 6degrees per unit. Hours:360 / 12 = 30.- Hour hand includes minutes. Minute hand includes seconds if you want honest geometry.
transform-originis 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 handstransformonly (compositor-friendly)
Nice to have
- Minute marks
- Sweep second hand via rAF
- Timezone prop
Planning (5 minutes out loud)
- Math first on a whiteboard: 6° and 30°, hour includes minutes
- CSS hands as absolutely positioned strips, origin at the hub
- Scheduler:
setTimeoutchained to the next whole second, or rAF - MVP: three hands +
<time>text - 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
getHours()without% 12- Hour hand ignores minutes
transform-originat the CSS default, hands orbit the corner- Counting seconds in state so a background tab desyncs
aria-live="polite"on the ticking<time>- 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 withtransform-originat the base. Each tick reads the clock again. I would chainsetTimeoutto 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.
Related on this site
- Reduced Motion Preferences
- CSS transforms and opacity
- prefers-reduced-motion
- The Event Loop
- Offline Status Indicator