【JavaScript】カウントダウンタイマーの作り方|キャンペーン終了・イベントまでの残り時間表示・タイムゾーン対応・終了演出・完成形クラスまで解説

「セール終了まであと〇〇」「イベント開幕まで〇日〇時間」といったカウントダウンタイマーは、購買意欲を高める定番のUI要素です。JavaScriptと setInterval で数行で作れますが、実務で使うには「タイムゾーンの扱い」「終了時の処理」「スクリーンリーダー対応」「バックグラウンドタブでの節電」など考慮点があります。

この記事では基本実装から完成形のクラスまで、キャンペーンタイマーに必要な要素を体系的に解説します。

スポンサーリンク

残り時間を日・時・分・秒に分解する

カウントダウンの核心は「目標日時 – 現在時刻」のミリ秒差を、日・時・分・秒に変換することです。

残り時間の計算ロジック
/**
 * 目標日時までの残り時間を日・時・分・秒に分解して返す
 * @param {Date|string} targetDate - 目標日時
 * @returns {{ days, hours, minutes, seconds, expired }}
 */
function getRemainingTime(targetDate) {
  const diff = new Date(targetDate) - new Date();

  if (diff <= 0) {
    return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
  }

  const totalSeconds = Math.floor(diff / 1000);
  const days    = Math.floor(totalSeconds / 86400);
  const hours   = Math.floor((totalSeconds % 86400) / 3600);
  const minutes = Math.floor((totalSeconds % 3600) / 60);
  const seconds = totalSeconds % 60;

  return { days, hours, minutes, seconds, expired: false };
}

// 使用例
const target = '2026-06-30T23:59:59+09:00';  // 日本時間で指定
const t = getRemainingTime(target);
console.log(`残り ${t.days}日 ${t.hours}時間 ${t.minutes}分 ${t.seconds}秒`);
86400・3600・60 の意味:
1日 = 86,400秒(60秒 × 60分 × 24時間)、1時間 = 3,600秒。%(剰余演算)で上の単位に繰り上がった分を除いた残りを取り出しています。例:totalSeconds = 90000 のとき days = 1hours = 1(90000 % 86400 = 3600)となります。

setInterval でリアルタイム更新する基本実装

HTML
<div class="countdown" id="campaign-timer" aria-live="polite" aria-label="キャンペーン終了まで">
  <div class="countdown-unit">
    <span class="countdown-value" id="cd-days">--</span>
    <span class="countdown-label">日</span>
  </div>
  <div class="countdown-unit">
    <span class="countdown-value" id="cd-hours">--</span>
    <span class="countdown-label">時間</span>
  </div>
  <div class="countdown-unit">
    <span class="countdown-value" id="cd-minutes">--</span>
    <span class="countdown-label">分</span>
  </div>
  <div class="countdown-unit">
    <span class="countdown-value" id="cd-seconds">--</span>
    <span class="countdown-label">秒</span>
  </div>
</div>
<div id="campaign-expired" hidden>
  <p>キャンペーンは終了しました。</p>
</div>
setInterval で毎秒更新
const TARGET_DATE = '2026-06-30T23:59:59+09:00';

// ゼロパディング("3" → "03")
const pad = (n) => String(n).padStart(2, '0');

function updateTimer() {
  const { days, hours, minutes, seconds, expired } = getRemainingTime(TARGET_DATE);

  if (expired) {
    // タイマー停止 + 終了表示に切り替え
    clearInterval(timerId);
    document.getElementById('campaign-timer').hidden = true;
    document.getElementById('campaign-expired').hidden = false;
    return;
  }

  document.getElementById('cd-days').textContent    = days;
  document.getElementById('cd-hours').textContent   = pad(hours);
  document.getElementById('cd-minutes').textContent = pad(minutes);
  document.getElementById('cd-seconds').textContent = pad(seconds);
}

// 即時実行(setInterval は1秒後から始まるため)
updateTimer();
const timerId = setInterval(updateTimer, 1000);
終了後に setInterval を止めない場合の問題:
終了後も setInterval を止めないと、毎秒 getRemainingTime() が呼ばれ続けてCPUを無駄に使います。期限切れを検知したら clearInterval(timerId) で必ず止めましょう。setInterval の詳しい使い方はsetInterval完全ガイドを参照してください。

カウントダウンのCSS スタイリング

カウントダウンUI のCSS
.countdown {
  display: flex;
  gap: 16px;
  justify-content: center;
  flex-wrap: wrap;
  font-family: 'Helvetica Neue', Arial, sans-serif;
}

.countdown-unit {
  display: flex;
  flex-direction: column;
  align-items: center;
  background: #1e293b;
  color: #fff;
  border-radius: 8px;
  padding: 16px 20px;
  min-width: 72px;
}

.countdown-value {
  font-size: 2.5rem;
  font-weight: 700;
  line-height: 1;
  font-variant-numeric: tabular-nums; /* 数字の幅を固定して揺れを防ぐ */
}

.countdown-label {
  font-size: 0.75rem;
  color: #94a3b8;
  margin-top: 4px;
}

/* ========== 残り1時間を切ったら緊急演出 ========== */
.countdown.urgent .countdown-unit {
  background: #dc2626;
  animation: pulse 1s ease-in-out infinite;
}

@keyframes pulse {
  0%, 100% { transform: scale(1); }
  50%       { transform: scale(1.05); }
}

/* prefers-reduced-motion 対応 */
@media (prefers-reduced-motion: reduce) {
  .countdown.urgent .countdown-unit {
    animation: none;
  }
}

残り時間が少なくなったら緊急演出を加える

残り1時間を切ったら背景を赤くパルスアニメーションを加えるUX的な演出です。urgent クラスを付け外しするだけで実装できます。

残り1時間でurgentクラスを付与する
function updateTimer() {
  const { days, hours, minutes, seconds, expired } = getRemainingTime(TARGET_DATE);
  const timer = document.getElementById('campaign-timer');

  if (expired) {
    clearInterval(timerId);
    timer.hidden = true;
    document.getElementById('campaign-expired').hidden = false;
    return;
  }

  document.getElementById('cd-days').textContent    = days;
  document.getElementById('cd-hours').textContent   = pad(hours);
  document.getElementById('cd-minutes').textContent = pad(minutes);
  document.getElementById('cd-seconds').textContent = pad(seconds);

  // 残り1時間(3600秒)未満なら緊急演出
  const totalSecsLeft = days * 86400 + hours * 3600 + minutes * 60 + seconds;
  timer.classList.toggle('urgent', totalSecsLeft < 3600);
}

タイムゾーン対応:日本時間で締め切りを指定する

グローバルなキャンペーンでは「日本時間の何月何日23:59」を世界中のユーザーに正確に伝えることが重要です。ISO 8601形式でオフセットを明示するのが最もトラブルが少ない方法です。

ISO 8601 + タイムゾーンオフセットで日時を指定する
// NG: タイムゾーン不明。実行環境のローカル時間として解釈される
const bad = new Date('2026-06-30 23:59:59');

// OK: +09:00 を明示することで日本標準時(JST)と確定する
const good = new Date('2026-06-30T23:59:59+09:00');

// OK: UTC で指定することも多い(UTC+9 の場合は 14:59:59 UTC)
const utc  = new Date('2026-06-30T14:59:59Z');

console.log(good.getTime() === utc.getTime()); // true(同じ瞬間)
締め切りを見やすく管理する
// 複数のキャンペーンを一元管理
const CAMPAIGNS = {
  summer: {
    label:  '夏のビッグセール',
    target: '2026-08-31T23:59:59+09:00',
    timerId: null,
  },
  autumn: {
    label:  '秋の新作フェア',
    target: '2026-11-30T23:59:59+09:00',
    timerId: null,
  },
};
「2026-06-30」と「2026-06-30T00:00:00」の違い:
ISO 8601 の日付のみ形式(YYYY-MM-DD)はUTCとして解釈される仕様です(ECMAScript仕様)。new Date("2026-06-30") は UTC の0時 = 日本時間の9時になります。意図した時刻を確実に指定するには T23:59:59+09:00 のように時刻とオフセットを明示してください。

バックグラウンドタブでの最適化

ユーザーが別タブに移動している間もタイマーは動き続けますが、表示されていない間は更新しても意味がありません。visibilitychange イベントで一時停止・再開することでCPUを節約できます。

visibilitychange でタイマーを一時停止・再開
let timerId = null;

function startTimer() {
  if (timerId) return; // 二重起動防止
  updateTimer();       // 即時実行
  timerId = setInterval(updateTimer, 1000);
}

function stopTimer() {
  clearInterval(timerId);
  timerId = null;
}

// タブがアクティブになったとき再開、非表示になったとき停止
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    stopTimer();
  } else {
    // タブに戻ったとき即座に表示を更新してから再開
    updateTimer();
    startTimer();
  }
});

startTimer();

完成形:CountdownTimer クラス

これまでの実装をひとまとめにした汎用クラスです。コールバック・アクセシビリティ対応・visibilitychange・destroy まで含みます。

CountdownTimer クラス
class CountdownTimer {
  #targetDate;
  #elements;
  #onExpire;
  #urgentThreshold;
  #timerId = null;

  /**
   * @param {object} options
   * @param {string}   options.target        - ISO 8601 形式の目標日時
   * @param {object}   options.elements      - 各表示要素のセレクタ
   * @param {Function} [options.onExpire]    - 終了時コールバック
   * @param {number}   [options.urgentSecs]  - 緊急演出を開始する残り秒数(デフォルト 3600)
   */
  constructor({ target, elements, onExpire, urgentSecs = 3600 }) {
    this.#targetDate     = new Date(target);
    this.#elements       = elements;
    this.#onExpire       = onExpire ?? (() => {});
    this.#urgentThreshold = urgentSecs;

    this.#update();
    this.#start();
    this.#bindVisibility();
  }

  #getRemaining() {
    const diff = this.#targetDate - new Date();
    if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
    const s = Math.floor(diff / 1000);
    return {
      days:    Math.floor(s / 86400),
      hours:   Math.floor((s % 86400) / 3600),
      minutes: Math.floor((s % 3600) / 60),
      seconds: s % 60,
      total:   s,
      expired: false,
    };
  }

  #setText(selector, value) {
    const el = document.querySelector(selector);
    if (el) el.textContent = value;
  }

  #update() {
    const pad = (n) => String(n).padStart(2, '0');
    const t = this.#getRemaining();

    if (t.expired) {
      this.#stop();
      if (this.#elements.container) {
        document.querySelector(this.#elements.container)?.setAttribute('hidden', '');
      }
      if (this.#elements.expired) {
        document.querySelector(this.#elements.expired)?.removeAttribute('hidden');
      }
      this.#onExpire();
      return;
    }

    this.#setText(this.#elements.days,    t.days);
    this.#setText(this.#elements.hours,   pad(t.hours));
    this.#setText(this.#elements.minutes, pad(t.minutes));
    this.#setText(this.#elements.seconds, pad(t.seconds));

    // 緊急演出
    const container = document.querySelector(this.#elements.container);
    container?.classList.toggle('urgent', t.total < this.#urgentThreshold);
  }

  #start() {
    if (this.#timerId) return;
    this.#timerId = setInterval(() => this.#update(), 1000);
  }

  #stop() {
    clearInterval(this.#timerId);
    this.#timerId = null;
  }

  #bindVisibility() {
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) {
        this.#stop();
      } else {
        this.#update();
        this.#start();
      }
    });
  }

  /** タイマーを完全に破棄する */
  destroy() {
    this.#stop();
  }
}

// 使用例
const timer = new CountdownTimer({
  target: '2026-08-31T23:59:59+09:00',
  elements: {
    container: '#campaign-timer',
    days:      '#cd-days',
    hours:     '#cd-hours',
    minutes:   '#cd-minutes',
    seconds:   '#cd-seconds',
    expired:   '#campaign-expired',
  },
  urgentSecs: 3600, // 残り1時間で緊急演出
  onExpire: () => {
    console.log('キャンペーン終了!');
    // サーバーに通知を送る、ページをリロードするなど
  },
});
aria-live=”polite” を付ける理由:
カウントダウンは毎秒変化しますが、スクリーンリーダーが毎秒読み上げると利用者の邪魔になります。aria-live="polite" は「現在の読み上げが終わったら変更を通知する」モードで、毎秒ではなく読み上げに余裕があるときだけ更新を伝えます。数字の各 span 要素には aria-hidden="true" を付けず、タイマー全体のコンテナにのみ付けるのがベストです。

よくある質問(FAQ)

Q日本時間でキャンペーンを終了させるにはどう指定すればいいですか?
A2026-06-30T23:59:59+09:00 のようにISO 8601形式でタイムゾーンオフセット(+09:00)を明示します。これで実行環境のタイムゾーンに関係なく日本時間の23:59:59として解釈されます。new Date("2026-06-30") のような日付のみの形式はUTC 00:00:00(日本時間9:00)になるため、意図した時刻と9時間ずれます。
Qカウントダウンが終了した後にページをリロードさせたいのですが。
AonExpire コールバックの中で location.reload() を呼ぶと実現できます。ただし、ユーザーが入力途中だった場合のデータ消失リスクがあります。実務ではリロードよりも「終了バナーの表示」や「APIで最新情報を取得して表示を更新する」ほうがUX的に望ましいケースが多いです。
Qユーザーが端末の時計を変更してカウントダウンをスキップできませんか?
Aできます。フロントエンドのカウントダウンは常に端末の時計に依存するため、厳密な時刻制御はサーバーサイドで行う必要があります。カウントダウンUIはあくまで視覚的な演出として使い、実際のキャンペーン終了判定はAPIやサーバーサイドの判定に委ねるのが正しい設計です。
Qタイマーの数字が毎秒ガタつく(幅が変わる)のを防ぐには?
ACSSで font-variant-numeric: tabular-nums を指定すると、数字が等幅になり幅が変わりません。フォントが対応していない場合は min-width を固定するか、font-family に等幅フォント(monospace)を使う方法もあります。
QSPAでコンポーネントを破棄するときにタイマーが止まりません。
Atimer.destroy() を呼んでください。Reactなら useEffect のクリーンアップ、Vue 3 なら onUnmounted の中で destroy() を実行します。タイマーを止めずにコンポーネントを破棄するとメモリリークと意図しない setInterval の二重起動が起きます。

まとめ

要件 実装ポイント
残り時間の計算 差分ミリ秒を 86400 / 3600 / 60 で割り算 + 剰余
日本時間での締め切り指定 2026-06-30T23:59:59+09:00(オフセット明示)
終了時の処理 onExpire コールバック + clearInterval
緊急演出 残り秒数 < 閾値 で .urgent クラスを付与
バックグラウンド節電 visibilitychange で一時停止・再開
アクセシビリティ コンテナに aria-live="polite"
SPA でのクリーンアップ destroy()clearInterval

setInterval の詳しい使い方はsetInterval完全ガイドを、日付の計算(日付の足し算・引き算)についてはJavaScriptで日時の計算をマスターしようもあわせて参照してください。