Reactで、針が動くアナログ時計を作る方法

​​React​Playwright,SVG,TypeScript

アナログ時計は、Date から時・分・秒の針の角度を計算し、SVG の線を transform="rotate(角度 中心x 中心y)" で回せば描けます。針を動かすには、今の時刻を state に持ち、秒が変わるたびに更新します。このとき setInterval(…, 1000) で更新すると、表示が最大で1秒近く遅れます。次の秒の変わり目までの時間を計算して setTimeout で待つと、遅れはほとんどなくなります。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で動かしました。時刻を決めて試すところでは、Playwright の page.clock でブラウザの時計を止めたり進めたりしています。Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げ、バックグラウンドのタブでの動きは試していません。

白い文字盤に1から12の数字と60本の目盛りがあり、黒い時針と分針、ピンクの秒針が10時9分56秒を指している。初めは秒針が1秒ごとに目盛り1つ分ずつ飛んで動き、10時10分になる。カーソルが「秒針をなめらかに動かす」のチェックボックスを押すと、その文字が一瞬ピンクの太字になり、チェックが入る。その後は秒針が止まらずに少しずつ回り続ける
1秒ごとに動く秒針と、チェックボックスを押した後になめらかに動く秒針。下の黒い帯の時刻、カーソル、押したときの輪、押した瞬間の文字のピンクは GIF 用に重ねたもの

時刻から、針の角度を計算する

角度は、どの針も「12時の方向を 0 度として、時計回り」で数えます。計算は React から切り離して、次の関数にしました。

ts
export type HandAngles = { hour: number; minute: number; second: number };

// 針の角度(12時を 0 度として、時計回りの度)を返す
// smooth が true なら、秒針もミリ秒の分だけ進める
export function handAngles(date: Date, smooth = false): HandAngles {
  const s = date.getSeconds() + (smooth ? date.getMilliseconds() / 1000 : 0);
  const m = date.getMinutes() + s / 60;
  const h = (date.getHours() % 12) + m / 60;
  return { hour: h * 30, minute: m * 6, second: s * 6 };
}

秒針と分針は60で1周するので、1秒(1分)あたり6度です。時針は1周が12時間なので、1時間あたり30度です。

分針には秒を、時針には分を、それぞれ小数にして足しています。こうしないと、時針は1時間に1回だけ30度飛ぶことになり、9時45分でも9の目盛りを指したままになります。getHours() は0〜23を返すので、12で割った余りにして、15時を3時と同じ向きにしています。

Node.js で、いくつかの時刻の角度を出しました(TZ=Asia/Tokyo で実行)。

text
03:00:00.000 カチカチ: 時針 90.00度 / 分針 0.00度 / 秒針 0.00度
06:30:00.000 カチカチ: 時針 195.00度 / 分針 180.00度 / 秒針 0.00度
09:45:30.000 カチカチ: 時針 292.75度 / 分針 273.00度 / 秒針 180.00度
10:09:50.700 カチカチ: 時針 304.92度 / 分針 59.00度 / 秒針 300.00度
             なめらか: 時針 304.92度 / 分針 59.07度 / 秒針 304.20度
15:00:00.000 カチカチ: 時針 90.00度 / 分針 0.00度 / 秒針 0.00度

6時30分の時針は、6と7の間の195度です。15時は3時と同じ90度になりました。「カチカチ」は smooth が false、「なめらか」は true のときの値です。false のときは、ミリ秒を切り捨てるので、秒針はちょうど目盛りの上を指します。

文字盤と針を、SVG で描く

時計は、viewBox="0 0 200 200" の SVG に描きます。中心は (100, 100) です。針はどれも、12時の向きにまっすぐ立てた <line> として描き、transform 属性で中心のまわりに回します。

tsx
import { handAngles } from './clock';
import { useNow } from './useNow';

type Props = { smooth?: boolean; size?: number };

// 文字盤の数字の位置(中心 100,100 から半径 r、12時から時計回りに deg 度)
function polar(deg: number, r: number) {
  const rad = (deg * Math.PI) / 180;
  return { x: 100 + r * Math.sin(rad), y: 100 - r * Math.cos(rad) };
}

export function AnalogClock({ smooth = false, size = 200 }: Props) {
  const now = useNow(smooth);
  const { hour, minute, second } = handAngles(now, smooth);
  const label = `${now.getHours()}時${now.getMinutes()}分`;

  return (
    <svg viewBox="0 0 200 200" width={size} height={size} role="img" aria-label={label}>
      <circle cx="100" cy="100" r="96" fill="#fff" stroke="#1f2a30" strokeWidth="4" />
      {Array.from({ length: 60 }, (_, i) => (
        <line
          key={i}
          x1="100" y1="8" x2="100" y2={i % 5 === 0 ? 20 : 14}
          stroke="#1f2a30" strokeWidth={i % 5 === 0 ? 3 : 1}
          transform={`rotate(${i * 6} 100 100)`}
        />
      ))}
      {Array.from({ length: 12 }, (_, i) => {
        const { x, y } = polar((i + 1) * 30, 70);
        return (
          <text key={i} x={x} y={y} textAnchor="middle" dominantBaseline="central" fontSize="16" fill="#1f2a30">
            {i + 1}
          </text>
        );
      })}
      <line className="hour" x1="100" y1="112" x2="100" y2="50" stroke="#1f2a30" strokeWidth="6" strokeLinecap="round"
        transform={`rotate(${hour} 100 100)`} />
      <line className="minute" x1="100" y1="114" x2="100" y2="24" stroke="#1f2a30" strokeWidth="4" strokeLinecap="round"
        transform={`rotate(${minute} 100 100)`} />
      <line className="second" x1="100" y1="120" x2="100" y2="16" stroke="#d85878" strokeWidth="2"
        transform={`rotate(${second} 100 100)`} />
      <circle cx="100" cy="100" r="4" fill="#d85878" />
    </svg>
  );
}

MDN の SVG の transform 属性の説明にあるとおり、rotate(a x y) は点 (x, y) を軸にして a 度回します。x と y を省くと、ユーザー座標系の原点を軸に回ります。viewBox が 0 0 から始まるこの時計では、左上の角です。針の線は中心より少し下(y1 が 112〜120)から始めて、反対側に短くはみ出させています。

SVG の座標は下に行くほど y が大きいので、正の角度で時計回りに回ります。検証環境の Chromium で、針の先が文字盤の中心からどこにあるかを画面の座標で読みました(右と下が正。時計は 240px で表示しているので、SVG の 1 は 1.2px です)。

text
03:00:00 時針 90度 先 (60.0, 0.0) / 分針 0度 先 (0.0, -91.2) / 秒針 0度 先 (0.0, -100.8)
06:30:00 時針 195度 先 (-15.5, 58.0) / 分針 180度 先 (0.0, 91.2) / 秒針 0度 先 (0.0, -100.8)
09:45:30 時針 292.75度 先 (-55.3, -23.2) / 分針 273度 先 (-91.1, -4.8) / 秒針 180度 先 (0.0, 100.8)

3時の時針は真右、6時30分の分針は真下を指しています。目盛りも同じ書き方で、12時の位置に立てた短い線を6度ずつ回して60本並べました。5本ごとに太く長くしています。

数字の <text> は回すと文字まで傾くので、polar で座標を計算して置いています。12時から時計回りに deg 度、半径 r の点は、x が 100 + r × sin、y が 100 − r × cos です。

SVG 全体には role="img" と aria-label を付け、「10時9分」のような時刻を名前にしました。MDN の img ロールの説明には、埋め込んだ SVG では外側の <svg> に role="img" とラベルを付けると、スクリーンリーダーは子ノードを一つずつ読まずに、1つの画像としてラベルで説明する、と書かれています。名前は分までにしたので、属性の値が変わるのは1分に1回です。検証環境で、3時、6時30分、9時45分30秒に開くと、role="img" の要素の名前はそれぞれ「3時0分」「6時30分」「9時45分」でした。

秒の変わり目に合わせて、今の時刻を更新する

今の時刻は、カスタムフックの useNow で state に持ちます。smooth が false のときは、秒が変わるごとに更新します。

ts
import { useEffect, useState } from 'react';

// 今の時刻を返す。smooth なら画面の描き換えごと、そうでなければ秒が変わるごとに更新する
export function useNow(smooth: boolean): Date {
  const [now, setNow] = useState(() => new Date());
  useEffect(() => {
    let id = 0;
    if (smooth) {
      const loop = () => {
        setNow(new Date());
        id = requestAnimationFrame(loop);
      };
      id = requestAnimationFrame(loop);
      return () => cancelAnimationFrame(id);
    }
    // 次の秒の変わり目まで待ってから更新し、また次の変わり目を待つ
    const tick = () => {
      const d = new Date();
      setNow(d);
      id = window.setTimeout(tick, 1000 - d.getMilliseconds());
    };
    tick();
    return () => clearTimeout(id);
  }, [smooth]);
  return now;
}

tick は、今の時刻のミリ秒を見て、次の秒の変わり目までの 1000 - ミリ秒 だけ待ってから、もう一度自分を呼びます。毎回その時点の時刻から待ち時間を計算し直すので、呼び出しが遅れても、次の回で変わり目に戻ります。

これを、setInterval(() => setNow(new Date()), 1000) で更新する書き方と比べました。Playwright の page.clock で、ブラウザの時計を10時9分50.700秒で止めてページを開き、少しずつ進めながら、秒針が何秒を指しているかを読みました。

本当の時刻 変わり目に合わせる(記事のコード) setInterval で 1000ms ごと
10:09:50.700 50 50
10:09:51.000 51 50
10:09:51.699 51 50
10:09:51.700 51 51
10:09:52.000 52 51
10:09:53.000 53 52

setInterval の書き方では、ページを開いたのが .700 秒なので、更新もいつも .700 秒に来ます。そのため、本当の秒が変わってから 0.7 秒間、1つ前の秒を指したままです。ずれの大きさは、ページを開いたときのミリ秒しだいで、0 から 1 秒近くまで変わります。

時計を止めずに、本物の時刻のままでも10秒間試しました。秒針が更新されたときの時刻のミリ秒は、次のとおりでした。

text
記事のコード:
  更新 10回 / 更新したときのミリ秒: 1, 2, 1, 2, 1, 2, 1, 2, 1, 1
setInterval で 1000ms ごと:
  更新 10回 / 更新したときのミリ秒: 872, 871, 871, 870, 870, 871, 871, 871, 870, 870

記事のコードでは、秒が変わってから 1〜2ms で表示が変わりました。setInterval のほうは、毎回、秒が変わってから約 0.87 秒後でした。

setTimeout の待ち時間は、指定した時間ちょうどになるとは限りません。MDN の setTimeout() の説明には、待ち時間が指定より長くなる理由が挙げられています。たとえば、アクティブでないタブでは、Chrome とデスクトップ版の Firefox は、最小の待ち時間を1秒にします。この場合も、tick は呼ばれたときの時刻を読むので、針は呼ばれた時点の時刻を指します。

useEffect から返している関数は、タイマーを止めるためのものです。React の useEffect のリファレンスにあるとおり、依存配列の値(ここでは smooth)が変わったときと、コンポーネントが画面から消えたときに、React がこの関数を呼びます。

requestAnimationFrame で、秒針をなめらかに動かす

smooth が true のときは、requestAnimationFrame で画面を描き換えるたびに時刻を更新し、handAngles でミリ秒の分も秒針に足します。MDN の requestAnimationFrame() の説明によると、コールバックは次の再描画の前に呼ばれ、1秒あたりに呼ばれる回数は、ふつう画面のリフレッシュレートと同じです。続けて動かすには、コールバックの中でもう一度 requestAnimationFrame を呼びます。

検証環境で、チェックボックスで smooth を切り替えながら、1秒間に秒針の transform 属性が何回変わったかを数えました。

text
  オフ: 1秒間の秒針の更新回数 1
  オン: 1秒間の秒針の更新回数 60 / 最初の5回 rotate(24.576 100 100) rotate(24.678000000000004 100 100) rotate(24.78 100 100) rotate(24.882 100 100) rotate(24.978 100 100)
  オフに戻す: 1秒間の秒針の更新回数 1

オンにすると、秒針は1回に約 0.1 度ずつ、1秒に60回進みました。オフに戻すと1秒に1回に戻ったので、cancelAnimationFrame で描き換えのたびの更新が止まっています。

この書き方では、AnalogClock が1秒に60回再レンダーされ、目盛りや数字の JSX も毎回作り直されます。ただし、React の公式ドキュメントのレンダーとコミットの説明にあるとおり、React はレンダー間で違いがあった DOM ノードだけを変更します。

秒針に CSS の transition を付けると、0秒で逆に回る

1秒ごとに動く秒針を少しだけ滑らかに見せようとして、CSS で transition を付けると、59秒から0秒になるところで、秒針が反時計回りにほぼ1周戻ります。

css
.second { transition: transform 0.3s; }

検証環境の Chromium では、SVG の transform 属性を変えたときにも、この transition が働きました。59秒の秒針の角度は 354 度で、0秒になると角度は 0 度に戻ります。transition は 354 から 0 へ数を減らす向きにつなぐので、秒針は反時計回りに、ほぼ1周戻ります。途中の見た目の角度を読むと、次のようになりました(-180〜180度で表しています)。

text
  transition の途中の見た目の角度(-180〜180度で表示): 0ms -6.0度 / 75ms -150.6度 / 150ms 69.9度 / 225ms 14.0度 / 300ms 0.0度

0.3 秒のうちに、12時の少し手前(-6 度)から、反時計回りに6時の向きを過ぎて、12時に戻っています。分針と時針の角度も、360 度の手前から 0 度に戻るところがあります。分針は毎時0分に、時針は0時と12時に戻ります。Node.js で計算すると、23時59分59.999秒の分針は 359.90 度で、0時0分では 0 度です。

分針にも同じように .minute { transition: transform 0.3s; } を付けて、Playwright の page.clock で10時59分59秒から11時0分0秒に進めました。

text
  10:59:59: transform 属性 rotate(359.9 100 100) / 見た目の角度 -0.1
  11:00:00 になった直後: transform 属性 rotate(0 100 100) / 動いているアニメーション 1
  transition の途中の見た目の角度(-180〜180度で表示): 0ms -0.1度 / 75ms -147.1度 / 150ms 71.1度 / 225ms 14.2度 / 300ms 0.0度

分針も0分になるところで、秒針と同じように反時計回りにほぼ1周戻りました。

この記事のコードでは transition を付けず、秒ごとに飛ぶ動きと、requestAnimationFrame でのなめらかな動きの2つにしました。

​Posted by 86