Vue時計

【Vue.js】アナログ時計を作成してみる

更新日:2026/01/15

今回はアナログ時計を作ろうシリーズの第三弾です。
第一弾と二弾は、こちら。

今回は、Vue.jsでアナログ時計を作成します。

 

アナログ時計(Vue.js)のデモ

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

 

開発環境をセットアップ

前回と同様にviteで開発環境をセットアップします。

npm create vite@latest vue-analog-clock  -- --template vue

今回も記事としてコードを掲載するのでTypeScriptではなく純粋なJavaScriptで作成します。

TypeScriptで開発する場合は、次のページを参考にしてください。

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

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

【React】アナログ時計を作成してみるのコードを流用しているため、ファイル構成は似たものになっています。

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

 

index.html / style.css

index.htmlは変更しなくても問題ありませんが、langを"en"から"ja"に変更しました。

/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="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

style.cssはすべて消去後に、Vueの管轄外の要素(bodyと#app)のみ設定しています。

/src/style.css

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

 

App.css / App.vue

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

/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.vueは次のように変更しました。

/src/App.vue

<script setup>
import { ref, reactive, onMounted, onBeforeUnmount, computed } from "vue";
import ClockFace from "./components/clockface/face.vue";
import ClockHands from "./components/clockhands/hands.vue";
import ClockInfo from "./components/info/info.vue";
import "./App.css";

const frameRef = ref(null);
const frameRect = reactive({
  diameter: 0,
  radius: 0,
  x: 0,
  y: 0,
});

const innerStyle = computed(() => ({
  width: frameRect.diameter + "px",
  height: frameRect.diameter + "px",
  position: "absolute",
  top: frameRect.y - frameRect.radius + "px",
  left: frameRect.x - frameRect.radius + "px",
}));

let observe = null;

onMounted(() => {
  observe = new ResizeObserver(([entry]) => {
    const { width, height, left, top } = frameRef.value.getBoundingClientRect();

    frameRect.diameter = width < height ? width : height;
    frameRect.radius = frameRect.diameter / 2;
    frameRect.x = left + width / 2;
    frameRect.y = top + height / 2;
  });

  observe.observe(frameRef.value);
});

onBeforeUnmount(() => {
  observe?.disconnect();
});
</script>

<template>
  <div id="clock-wrap" ref="frameRef">
    <div v-if="frameRect.diameter" id="clock-container" :style="innerStyle">
      <ClockFace :radius="frameRect.radius" />
      <ClockHands :radius="frameRect.radius" />
    </div>
  </div>
  <ClockInfo />
</template>

Vue.jsはVue3が現行ですが、Vue2での記述方法も継承しているため、スクリプト部分にはいろいろな書き方があります。
調べてみると<script setup></script>が推奨されているようなので採用しました。

ここでは最上位の親として子コンポーネントの呼び出しと、ブラウザのサイズ変更監視をおこなっています。

Reactはリアクティブな値を変更するとコンポーネントが再呼び出しされますが、Vue.jsの<script setup></script>は一度だけしか呼び出されない点に注意。

Ref()等で管理しているリアクティブ値が変更されたときの処理は、computed()関数等でおこなう必要があります。

 

文字盤の描画

次は文字盤を作成します。

face.vueは親からradiusを受け取って、各パーツ(コンポーネント)に渡しています。

/src/components/clockface/face.vue

/** * 文字盤の描画 */
<script setup>
import ClockFaceFrame from "./frame.vue";
import ClockFaceScale from "./scale.vue";
import ClockFaceText from "./text.vue";
import ClockFaceCenter from "./center.vue";

const props = defineProps(["radius"]);

</script>

<template>
  <ClockFaceFrame />
  <ClockFaceScale :radius="props.radius" />
  <ClockFaceText :radius="props.radius" />
  <ClockFaceCenter />
</template>

文字盤の外枠

frame.vueは、文字盤の外周(円)を描画しています。

/src/components/clockface/frame.vue

/**
* 文字盤の枠描画
*/
<template>
    <div id="face-frame" ></div>
</template>

<style scoped>
#face-frame{
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
}
</style>

ここでのスタイル設定は位置のみです。

線種とうの外観は、App.ccsで設定しています。

文字盤の目盛り

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

/src/components/clockface/scale.vue

/** * 目盛りの描画 */
<script setup>
import { reactive, computed } from "vue";

const props = defineProps(["radius"]);

const r60 = 360 / 60;

const scales = computed(() => {
  const transformOrigin = `${props.radius}px center`;
  return Array.from({ length: 60 }, (v, index) => {
    const deg = index * r60;
    const className = index % 5 === 0 ? "face-line1" : "face-line2";
    const style = { transformOrigin };
    if (index !== 0) style.transform = `rotate(${deg}deg)`;
    return { index, className, style };
  });
});
</script>

<template>
  <div v-for="s in scales" :key="s.index" :class="s.className" :style="s.style"></div>
</template>

computed()で各目盛りの情報配列を作成して、<template>で、v-for ディレクティブを使ってリストレンダリングしています。

目盛りの位置設定については、次のリンク先を参考にしてください。

文字盤の文字

文字盤の文字は、text.vueで動的に生成しています。

/src/components/clockface/text.vue

/**
* 目盛り数字の描画
*/
<script setup>
import { reactive, computed } from "vue";

const props = defineProps(["radius"]);
const r12 = 360 / 12;

const MathPi = Math.PI / 180;
const className = "face-text";

const texts = computed(() => {
  const radius = props.radius;
  const moziPos = radius - 30;

  return 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 { index, className, style, text };
  });
});
</script>

<template>
  <div v-for="t in texts" :key="t.index" :class="t.className" :style="t.style">
    {{ t.text }}
  </div>
</template>

文字盤の目盛りと同じように、computed()で各文字の情報配列を作成して、<template>で、v-for ディレクティブを使ってリストレンダリングしています。

文字の位置設定については、次のリンク先を参考にしてください。

文字盤の中央の円

中央の円はcenter.vueで、div要素を設置しています。

/src/components/clockface/center.vue

/**
* 中央の円の描画
*/
<template>
    <div class="face-center"></div>
</template>

 

針の描画

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

タイマー監視

タイマー監視はマウント後にsetTimeout()で行っています。

/src/components/clockhands/hands.vue

/** * 針の描画 */
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from "vue";
import ClockHand from "./hand.vue";

const props = defineProps(["radius"]);

const d = new Date();
const hour = ref(d.getHours());
const minute = ref(d.getMinutes());
const second = ref(d.getSeconds());

const hourValue = computed(() => (hour.value % 12) * 60 + minute.value);

let timeoutId;

onMounted(() => {
  const schedule = () => {
    const delay = 1000 - (Date.now() % 1000);

    timeoutId = window.setTimeout(() => {
      const now = new Date();
      hour.value = now.getHours();
      minute.value = now.getMinutes();
      second.value = now.getSeconds();
      schedule();
    }, delay);
  };

  schedule();
});

onBeforeUnmount(() => {
  clearTimeout(timeoutId);
});
</script>
<template>
  <ClockHand id="hand-hour" :radius="props.radius" :value="hourValue" :divNum="720" />
  <ClockHand id="hand-minute" :radius="props.radius" :value="minute" :divNum="60" />
  <ClockHand id="hand-second" :radius="props.radius" :value="second" :divNum="60" />
</template>

<template></template>の、:divNum="720"は数値の720を渡しています。
divNum="720"と記述すると文字列が渡されます。

針の描画

針は時針(短針)、分針(長針)、秒針の3つです。
全ての針を一つのコンポーネントで生成しています。

/src/components/clockhands/hand.vue

/**
* 針の描画
*/
<script setup>
import { ref, onMounted, computed } from "vue";

const HandLengthPerProp = "--react-analog-clock-handlengthper";
const HandGapPerProp = "--react-analog-clock-handgapper";

const props = defineProps(["id", "radius", "value", "divNum"]);
const width = ref(0);
const handLengthPer = ref(0);
const handGapPer = ref(0);

const handRef = ref(null);

onMounted(() => {
  const style = window.getComputedStyle(handRef.value);
  const lengthPer = Number(style.getPropertyValue(HandLengthPerProp));
  const gapPer = Number(style.getPropertyValue(HandGapPerProp));
  if (!isNaN(lengthPer)) handLengthPer.value = lengthPer;
  if (!isNaN(gapPer)) handGapPer.value = gapPer;
  width.value = handRef.value.clientWidth;
});

const style = computed(() => {
  const radius = props.radius;
  const handLength = (radius * handLengthPer.value) / 100;
  const handGap = (radius * handGapPer.value) / 100;
  const angle = 360 / props.divNum;

  return {
    height: handLength + handGap + "px",
    top: radius - handLength + "px",
    left: radius - width.value / 2 + "px",
    transformOrigin: `center ${handLength}px `,
    transform: `rotate(${angle * props.value}deg)`
  };
});

</script>
<template>
  <div :id="props.id" :style="style" ref="handRef"></div>
</template>

各針はマウント後にApp.cssで設定したプロパティを取得しています。
そうすることでcssでのデザイン性を確保しています。

 

情報パネルの描画

画面右上のあるパネルを描画します。

p class="codebox_white">/src/components/info/info.vue

<template>
  <div id="clock-info">
    <p>Vueによるアナログ時計</p>
    <p>
      アプリページ:<a href="/vue-analog-clock.php" target="_blank">【Vue.js】アナログ時計を作成してみる</a>
    </p>
  </div>
</template>

 

テスト/ビルド

コード作成が終了したら、次のコマンドで開発サーバーを起動します。

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フォルダに出力され、結果が表示されます。

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

vite v7.3.1 building client environment for production...
21 modules transformed.
dist/index.html                  3.64 kB │ gzip:  0.69 kB
dist/assets/index-DoPbKpU7.css   1.60 kB │ gzip:  0.58 kB
dist/assets/index-BRq1Gbtb.js   63.74 kB │ gzip: 25.44 kB
✓ built in 346ms

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

更新日:2026/01/15

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

スポンサーリンク

記事の内容について

null

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

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

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

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

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

 

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