React時計

【React】アナログ時計を作成してみる

更新日:2026/01/15

以前、JavaScriptでアナログ時計を作成しました。

今回は以前作成したコードをReact化してみようと思う。

 

アナログ時計のデモ

今回作成したアナログ時計のデモページ。
↓ ↓ ↓

 

前準備

今回はviteで開発環境をセットアップします。

次のコマンドを入力してください。

npm create vite@latest react-analog-clock -- --template react-compiler

いつもはTypeScriptでコード作成しているのですが、今回は記事としてコードを掲載するので純粋なJavaScriptで作成します。
また、React Compilerを組み込んでいます。
React Compilerは、useMemoとuseCallbackを使用することで最適化できそうなコードを検知して、それぞれのフックを適用してくれます。
つまり、パフォーマンスが向上する可能性があります。

TypeScriptを選択したい、React Compilerはいらない等で異なる設定で環境構築するときは、次のページを参考にしてください。

 

完成後のファイル構成

完成後のファイル構成は次のようになります。

react-analog-clock
 ┣━ public
 ┣━ src
 ┃   ┣━ assets
 ┃   ┣━ clockface  ← 文字盤
 ┃   ┃   ┣━ center.jsx
 ┃   ┃   ┣━ face.jsx
 ┃   ┃   ┣━ frame.jsx
 ┃   ┃   ┣━ scale.jsx
 ┃   ┃   ┗━ text.jsx
 ┃   ┣━ clockhands ← 時計の針
 ┃   ┃   ┣━ hand.jsx
 ┃   ┃   ┗━ hands.jsx
 ┃   ┣━ info ← 情報パネル
 ┃   ┃   ┗━ info.jsx
 ┃   ┣━ App.css
 ┃   ┣━ App.jsx
 ┃   ┣━ index.css
 ┃   ┗━ main.jsx
 ┣━ .gitignore
 ┣━ README.md
 ┣━ eslint.config.js
 ┣━ index.html
 ┣━ package-lock.json
 ┣━ package.json
 ┗━ vite.config.js

赤色のフォルダが、新規作成。
緑色のファイルが、既存ファイルを変更しています。

 

index.html / main.jsx / index.css

index.htmlは、langを"en"から"ja"に変更。
linkタグでのアイコン指定を削除 vite.svg から、サイトで使用しているものに変更。

/index.html

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>react-analog-clock</title>
    <!-- アイコンの設定(省略)-->
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

main.jsxは変更なし。
App.jsxを呼び出していることを確認。
#rootをReactのルートとして指定していることも確認。

/src/main.jsx

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css';
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

index.cssはすべて消去後に、bodyとルート(#root)のみ設定しています。

/src/index.css

body {
  margin: 0;
  display: flex;
  place-items: center;
  min-width: 320px;
  min-height: 100vh;
}
#root {
  height: 100vh;
  width: 100%;
  position: relative;
}

index.cssは、Reactが関わらない範囲のスタイルを定義します。

Viteの初期構成では#rootのスタイルをApp.cssで行っていますが、#rootはReact管理外(Reactが管理するのは#rootの中身)なので、index.cssで定義します。

 

App.css / App.jsx

App.cssは、Reactが関わっている範囲のスタイルを定義します。
既存ファイルですが、全部消去して次のように変更。

/src/App.css

#clock-wrap,#clock-wrap div {
  box-sizing: border-box;
  padding: 0;
  margin: 0;
}
#clock-wrap{
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
  position: absolute;
}
/* 文字盤外周 */
#face-frame {
  border: 3px solid black;
  background-color: white;
  border-radius: 50%;
}
/* 文字盤目盛り */
.face-line1,
.face-line2 {
  position: absolute;
  left: 0;
  z-index: 1;
}
.face-line1 {
  width: 7px;
  height: 3px;
  background: black;
  top: calc(50% - 2px);
}
.face-line2 {
  width: 5px;
  height: 2px;
  background: black;
  top: calc(50% - 1px);
}
/* 文字盤テキスト */
.face-text {
  color: black;
  font-size: 2em;
  position: absolute;
  transform: translate(-50%, -50%);
  z-index: 2;
}
/* 文字盤中央の円 */
.face-center,
.face-center:after {
  border-radius: 50%;
  position: absolute;
}
.face-center {
  top: 50%;
  left: 50%;
  z-index: 15;
  background-color: #282828;
  height: 21px;
  width: 21px;
  transform: translate(-50%, -50%);
}
.face-center:after {
  content: "";
  background-color: silver;
  top: 2px;
  left: 2px;
  height: 17px;
  width: 17px;
}
/* 秒針 */
#hand-second {
  --react-analog-clock-handlengthper:85;
  --react-analog-clock-handgapper:20;
  background-color: red;
  width: 5px;
  position: absolute;
  z-index: 10;
  border-radius: 5px;
}
/* 分針 */
#hand-minute {
  --react-analog-clock-handlengthper:80;
  --react-analog-clock-handgapper:10;
  background-color: black;
  width: 14px;
  position: absolute;
  z-index: 9;
  border-radius: 5px;
}
/* 時針 */
#hand-hour {
  --react-analog-clock-handlengthper:55;
  --react-analog-clock-handgapper:10;
  background-color: black;
  width: 20px;
  position: absolute;
  z-index: 8;
  border-radius: 5px;
}
/* 情報パネル */
#clock-info{
  position: absolute;
  width: 120px;
  padding: 5px;
  font-size: 0.8em;
  top:0;
  right: 0;
  background-color: #282828;
  color: white;
}
#clock-info p{
  margin: 0;
}
#clock-info a{
  color: aqua;
}

App.jsxは、main.jsxから呼び出されています。
既存ファイルですが、全部消去して次のように変更。

/src/App.jsx

import { useState, useRef, useLayoutEffect } from 'react'
import ClockFace from './clockface/face';
import ClockHands from './clockhands/hands';
import ClockInfo from './info/info';
import './App.css'

function App() {
  const [frameRect, setFrameRect] = useState(null);
  const frameRef = useRef(null);

    // サイズ変更監視
  useLayoutEffect(() => {
    const observer = new ResizeObserver(entries => {
      const { width, height, left, top } = entries[0].contentRect;

      const diameter = width < height ? width : height;
      const radius = diameter / 2;
      const x = left + width / 2;
      const y = top + height / 2;

      setFrameRect({ diameter, radius, x, y });
    });

    observer.observe(frameRef.current);
    return () => observer.disconnect();

  }, []);
  
    // #clock-containerのスタイル定義
  const innerStyle = !frameRect ? {} :
    {
      width: frameRect.diameter + "px",
      height: frameRect.diameter + "px",
      position: "absolute",
      top: (frameRect.y - frameRect.radius) + "px",
      left: (frameRect.x - frameRect.radius) + "px",
    };

  return (
    <>
      <div id="clock-wrap" ref={frameRef} >
        {frameRect &&
          <div id="clock-container" style={innerStyle} >
            <ClockFace radius={frameRect.radius} />
            <ClockHands radius={frameRect.radius} />
          </div>
        }
      </div>
      <ClockInfo />
    </>
  )
}
export default App

Appコンポーネントは、二つのdiv要素を作成してその中に文字盤と針を描画しています。

<div id="clock-wrap">
 <div id="clock-container">
   <!-- 中身は省略 -->
 </div>
</div>

#clock-wrapは、次のスタイルでルートの範囲全体に広げています。

/src/App.cssの一部

#clock-wrap{
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
  position: absolute;
}

#clock-containerは、#clock-wrapの高さと幅のうち小さいほうの値で正方形になるように設定します。

しかし、Appコンポーネントの初回呼び出し時はDOM上に#clock-wrapが存在しません。
そこで、useLayoutEffect()を使用して、ブラウザが画面を再描画する前(サイズと位置が計算された後)にコールバック関数を呼び出します。
同時にサイズ変更監視をスタートします。

 

文字盤の描画

次は文字盤を作成します。
今回は、文字盤の外枠、目盛り、文字、中央の円の4つのパーツごとにコンポーネントを作成します。

face.jsxは、4つのコンポーネントを一つのコンポーネントにまとめています。

/src/clockface/face.jsx

import ClockFaceFrame from "./frame";
import ClockFaceScale from "./scale";
import ClockFaceText from "./text";
import ClockFaceCenter from "./center";

/**
 * 文字盤の描画
 */
function ClockFace({ radius }) {
    return (
        <>
            <ClockFaceFrame />
            <ClockFaceScale radius={radius} />
            <ClockFaceText radius={radius} />
            <ClockFaceCenter />
        </>
    )
}
export default ClockFace

文字盤の外枠

文字盤の外枠は、frame.jsxで生成しています。

/src/clockface/frame.jsx

/**
 * 文字盤の枠描画
 */
function ClockFaceFrame() {

    const style = {
        position:"absolute",
        top:"0",
        left:"0",
        right:"0",
        bottom:"0"
    };
  return (
    <>
      <div id="face-frame" style={style}></div>
    </>
  )
}

export default ClockFaceFrame

ここでは、#clock-container(正方形のコンテナ)の範囲全体にdiv要素を広げているだけです。
デザインは、App.cssで行っています。

/src/App.cssの一部

/* 文字盤外周 */
#face-frame {
  border: 3px solid black;
  background-color: white;
  border-radius: 50%;
}

文字盤の目盛り

文字盤の目盛りは、scale.jsxで生成しています。

/src/clockface/scale.jsx

const r60 = 360 / 60;
/**
 * 目盛りの描画
 */
function ClockFaceScale({ radius }) {

  const orginStyle = {
    transformOrigin: `${radius}px center`,
  }

  const scales = Array.from({ length: 60 }, (v, index) => {
    const deg = index * r60;
    const className = index % 5 === 0 ? "face-line1" : "face-line2";
    const style = Object.assign({}, orginStyle);
    if (index !== 0) style.transform = `rotate(${deg}deg)`;
    return <div key={index} className={className} style={style}></div>;
  });

  return (
    <>
      {scales}
    </>
  )
}
export default ClockFaceScale

目盛り配置の仕組みについては、次のリンク先を参照してください。

目盛りのデザインは、App.cssで行っています。

/src/App.cssの一部

/* 文字盤目盛り */
.face-line1,
.face-line2 {
  position: absolute;
  left: 0;
  z-index: 1;
}
.face-line1 {
  width: 7px;
  height: 3px;
  background: black;
  top: calc(50% - 2px);
}
.face-line2 {
  width: 5px;
  height: 2px;
  background: black;
  top: calc(50% - 1px);
}

ただし、コンポーネント内でtransformプロパティを設定しているため、App.cssでtransformプロパティを設定しても上書きされます。

文字盤の文字

文字盤の文字は、text.jsxで生成しています。

/src/clockface/text.jsx

const r12 = 360 / 12;
/**
 * 目盛り数字の描画
 */
function ClockFaceText({ radius }) {
    const moziPos = radius - 30;
    const MathPi = Math.PI / 180;
    const className = "face-text";

    const texts = Array.from({ length: 12 }, (v, index) => {
        const deg = index * r12;
        const mojiX = radius + moziPos * Math.sin(deg * MathPi);
        const mojiY = radius - moziPos * Math.cos(deg * MathPi);

        const style = { top: mojiY + "px", left: mojiX + "px" };
        const text = index === 0 ? "12" : index.toString();

        return <div key={index} className={className} style={style}>{text}</div>;
    });

    return (
        <>
            {texts}
        </>
    )
}
export default ClockFaceText

文字配置の仕組みについては、次のリンク先を参照してください。

こちらもデザインは、App.cssで行っています。

/src/App.cssの一部

/* 文字盤テキスト */
.face-text {
  color: black;
  font-size: 2em;
  position: absolute;
  transform: translate(-50%, -50%);
  z-index: 2;
}

コンポーネント内でtopとleftプロパティを設定しているため、App.cssでそれらを設定しても上書きされます。

文字盤の中央の円

文字盤の中央の円は、center.jsxで生成しています。

/src/clockface/center.jsx

/**
 * 中央の円の描画
 */
function ClockFaceCenter() {
  return (
    <>
        <div className="face-center"></div>
    </>
  )
}
export default ClockFaceCenter

こちらはdiv要素を配置しているだけでも。
デザインだけでなく位置決めも、App.cssで行っています。

/src/App.cssの一部

/* 文字盤中央の円 */
.face-center,
.face-center:after {
  border-radius: 50%;
  position: absolute;
}
.face-center {
  top: 50%;
  left: 50%;
  z-index: 15;
  background-color: #282828;
  height: 21px;
  width: 21px;
  transform: translate(-50%, -50%);
}
.face-center:after {
  content: "";
  background-color: silver;
  top: 2px;
  left: 2px;
  height: 17px;
  width: 17px;
}

 

針の描画

針の描画は、hands.jsxでタイマー監視を行い、hand.jsxで描画しています。

タイマー監視

useEffect()でタイマーをセットします。

/src/clockhands/hands.jsx

import { useState, useEffect } from 'react'
import ClockHand from './hand';

/**
 * 針の描画
 */
function ClockHands({ radius }) {
    const [hour, setHour] = useState(()=>new Date().getHours());
    const [minute, setMinute] = useState(()=>new Date().getMinutes());
    const [second, setSecond] = useState(()=>new Date().getSeconds());

    useEffect(() => {
        let timeoutId;

        const schedule = () => {
             // ズレ対策
            const delay = 1000 - (Date.now() % 1000);

            timeoutId = window.setTimeout(() => {
                const now = new Date();
                setHour(now.getHours());
                setMinute(now.getMinutes());
                setSecond(now.getSeconds());
                schedule();
            }, delay);
        };

        schedule();
        return () => clearTimeout(timeoutId);
    }, []);

    const hourValue = (hour % 12) * 60 + minute;

    return (
        <>
            <ClockHand id="hand-hour" radius={radius} value={ hourValue } divNum={12 * 60} />
            <ClockHand id="hand-minute" radius={radius} value={ minute } divNum={60} />
            <ClockHand id="hand-second" radius={radius} value={ second } divNum={60} />
        </>
    )
}
export default ClockHands

針の描画

時針、分針、秒針ともに一つのコンポーネントで描画します。

/src/clockhands/hand.jsx

import { useState, useMemo, useRef, useLayoutEffect } from 'react'
    // 針の先端から文字盤の中心までの長さ(半径のパーセント)
    // のカスタムプロパティ名
const HandLengthPerProp = "--react-analog-clock-handlengthper";
    // 針の終端から文字盤の中心までの長さ(半径のパーセント)
    // のカスタムプロパティ名
const HandGapPerProp = "--react-analog-clock-handgapper";
/**
 * 針の描画
 */
function ClockHand({ id, radius, value, divNum }) {

    const [width, setWidth] = useState(0);
    const [handLengthPer, setHandLengthPer] = useState(0);
    const [handGapPer, setHandGapPer] = useState(0);

    const handRef = useRef(null);

        // DOMからプロパティ取得
    useLayoutEffect(() => {
        const entity = handRef.current;
        const style = window.getComputedStyle(entity);
        const lengthPer = Number(style.getPropertyValue(HandLengthPerProp));
        const handGapPer = Number(style.getPropertyValue(HandGapPerProp));
        if( !isNaN(lengthPer) ) setHandLengthPer(lengthPer);
        if( !isNaN(handGapPer) ) setHandGapPer(handGapPer);
        setWidth(entity.clientWidth);
    }, []);

    const style = Object.assign({} , useMemo(
        () => {
            const handLength = radius * handLengthPer / 100;
            const handGap = radius * handGapPer / 100;
            return {
                height: (handLength + handGap) + "px",
                top: (radius - handLength) + "px",
                left: (radius - width / 2) + "px",
                transformOrigin: `center ${handLength}px `,
            }
        }
        , [radius, width , handLengthPer , handGapPer])
    );
    const angle = 360 / divNum;
    style.transform = `rotate(${angle * value}deg)`

    return (<>
        <div id={id} style={style} ref={handRef} ></div>
    </>);
}
export default ClockHand;

今回は、針のDOM要素から幅(clientWidth)と、二つのカスタムプロパティを取得するために、useLayoutEffect()を呼び出しています。

また針のスタイルで呼び出しごとに変更されるのはtransformプロパティだけなので、他のプロパティはuseMemo()でメモ化しています。
今回はReact Compileを使用しているので、useMemo()を使用しなくても自動で適用してくれる可能性がありますが、確実にメモ化するためにuseMemo()を呼んでいます。

該当箇所のApp.cssは次のようになっています。

/src/App.cssの一部

/* 秒針 */
#hand-second {
  --react-analog-clock-handlengthper:85;
  --react-analog-clock-handgapper:20;
  background-color: red;
  width: 5px;
  position: absolute;
  z-index: 10;
  border-radius: 5px;
}
/* 分針 */
#hand-minute {
  --react-analog-clock-handlengthper:80;
  --react-analog-clock-handgapper:10;
  background-color: black;
  width: 14px;
  position: absolute;
  z-index: 9;
  border-radius: 5px;
}
/* 時針 */
#hand-hour {
  --react-analog-clock-handlengthper:55;
  --react-analog-clock-handgapper:10;
  background-color: black;
  width: 20px;
  position: absolute;
  z-index: 8;
  border-radius: 5px;
}

 

情報パネルの描画

アナログ時の機能としては必要ないのですが、デモに設置しているので掲載します。

/src/info/info.jsx

function ClockInfo(){
    return (<>
        <div id="clock-info">
            <p>Reactによるアナログ時計</p>
            <p>アプリページ:<a href="/react-analog-clock.php" target="_blank">【React】アナログ時計を作成してみる</a></p>
        </div>
    </>)
}
export default ClockInfo;

デザイン部分は、次のようになっています。

/src/App.cssの一部

/* 情報パネル */
#clock-info{
  position: absolute;
  width: 120px;
  padding: 5px;
  font-size: 0.8em;
  top:0;
  right: 0;
  background-color: #282828;
  color: white;
}
#clock-info p{
  margin: 0;
}
#clock-info a{
  color: aqua;
}

 

テスト

コード作成が終了したら、次のコマンドを入力します。

npm run dev

コードにエラーがなければ、Viteの開発サーバーが起動します。

 VITE v7.3.0  ready in 152 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

ブラウザでhttp://localhost:5173/にアクセスすると、作成したコードをブラウザで実行できます。

 

ビルド

テストが終わったら、コードをビルドして本番用の実行環境を生成します。
次のコマンドを入力しましょう。

npm run build

少し待つと、ビルド後のファイルがdistフォルダに出力され、結果が表示されます。

> react-analog-clock@0.0.0 build
> vite build

vite v7.3.0 building client environment for production...
43 modules transformed.
dist/index.html                   3.65 kB │ gzip:  0.69 kB
dist/assets/index-DuFFgOQ7.css    1.54 kB │ gzip:  0.56 kB
dist/assets/index-Bof-Wruw.js   198.81 kB │ gzip: 62.95 kB
✓ built in 1.08s

distフォルダの中身をサーバーにアップロードして完了です。

更新日:2026/01/15

書いた人(管理人):けーちゃん

スポンサーリンク

記事の内容について

null

こんにちはけーちゃんです。
説明するのって難しいですね。

「なんか言ってることおかしくない?」
たぶん、こんなご意見あると思います。

裏付けを取りながら記事を作成していますが、僕の勘違いだったり、そもそも情報源の内容が間違えていたりで、正確でないことが多いと思います。
そんなときは、ご意見もらえたら嬉しいです。

掲載コードについては事前に動作確認をしていますが、貼り付け後に体裁を整えるなどをした結果動作しないものになっていることがあります。
生暖かい視線でスルーするか、ご指摘ください。

ご意見、ご指摘はこちら。
https://note.affi-sapo-sv.com/info.php

 

このサイトは、リンクフリーです。大歓迎です。