JavaScriptを学び始めたものの、「何を作ればいいかわからない」「作っても仕組みが理解できない」と感じていませんか。
この記事では、ブラウザだけで動く Mini Focus Timer(集中タイマー) を作りながら、JavaScriptの基礎となる時間管理・状態管理・保存処理を学びます。
※ 本記事のアプリは 静的HTML1枚で動作 します。
実際に動くデモは GitHub Pages app-001-focus-timerで公開しています。
この記事で解決できる悩み
- JavaScript初心者で、最初に作る題材が決められない
- setIntervalを使っているが、なぜ動くのか説明できない
- 作業中・休憩中といった状態の切り替えが理解できない
- LocalStorageの保存と復元の流れがつかめない
- 環境構築で詰まらず、ブラウザ完結でアプリを作りたい
なぜ最初に集中タイマーを作るのか
集中タイマーは、JavaScript初心者が学ぶべき重要な要素を、無理なく1つのアプリにまとめられる題材です。
- setIntervalによる時間管理を自然に理解できる
- 実行中/停止中、作業/休憩といった状態管理を学べる
- LocalStorageを使った設定保存を実践できる
- HTML1枚構成なので、そのままGitHub Pagesで公開できる
「完成させること」ではなく、「仕組みを説明できること」を目的に進めます。
完成アプリの仕様(できること一覧)
- アプリ名:Mini Focus Timer
- モード切替:25分作業/5分休憩、50分作業/10分休憩、カスタム
- 開始・一時停止・リセット操作
- SVGによる進捗リング表示
- 作業/休憩の状態表示
- 音による通知(ON/OFF切替)
- キーボード操作
- Space:開始/一時停止
- R:リセット
- LocalStorageによる設定保存と復元
- aria-liveによる読み上げ対応
仕組みを分解して理解する
setIntervalで1秒ずつ減らす考え方
タイマーは「分」ではなく「秒」で管理します。
1秒ごとに処理を実行し、残り秒数を減らしていくことで、計算と表示がシンプルになります。
分秒表示(mm:ss)の作り方
残り秒数を分と秒に分解し、padStart(2,'0') を使って2桁表示に整えます。
状態(focus / break)を持つ理由
タイマーは常に「作業」か「休憩」のどちらかです。
状態を変数として持つことで、時間設定・表示・切り替え処理が整理されます。
進捗リングの仕組み
SVGの円周を基準に、進捗率を stroke-dashoffset で表現します。
時間の進行を「割合」に変換し、視覚的に進捗を示しています。
二重スタート防止
開始ボタンの連打でsetIntervalが多重起動しないよう、running フラグと timerId を使って制御しています。
LocalStorageによる保存と復元
設定値をオブジェクトとしてまとめ、JSON形式で保存・復元します。
ページを再読み込みしても、前回の設定が維持されます。
キーボード操作の注意点
Spaceキーはブラウザのスクロール動作と競合するため、e.preventDefault() を使って既定動作を防いでいます。
音通知とブラウザ制約
WebAudioは、ユーザー操作後でないと再生できない場合があります。
チェックON時のみ音を鳴らす設計にしています。
aria-liveによる読み上げ
作業終了・休憩終了を音声でも伝えるため、aria-liveを使用しています。
実装(HTML / CSS / JavaScript)
以下が 完成版コード全文 です。
このコードは 表示用 です。実行する場合は、index.html として保存してください。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Day01 — Mini Focus Timer</title>
<meta name="description" content="1分で使える集中タイマー。25分/5分を切替、進捗リング、音でお知らせ。GitHub Pages/Vercelにそのままデプロイ可。" />
<style>
:root{
--bg:#0b1020; --panel:#121836; --text:#e9ecf1; --muted:#9aa3b2; --accent:#7aa2ff; --accent-2:#6efacc;
--ring:#2a3566; --ring-active:#7aa2ff; --shadow: 0 10px 30px rgba(0,0,0,.35);
}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,"Noto Sans JP",sans-serif;background:radial-gradient(1200px 600px at 80% -10%,#16204a,transparent), var(--bg); color:var(--text); display:grid; place-items:center;}
.app{width:min(94vw,560px); background:linear-gradient(180deg, rgba(255,255,255,.03), transparent), var(--panel); border:1px solid rgba(255,255,255,.06); border-radius:18px; box-shadow:var(--shadow); padding:20px 20px 28px;}
header{display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:12px}
h1{font-size:18px; margin:0; letter-spacing:.2px}
.sub{color:var(--muted); font-size:12px}
.controls{display:flex; gap:10px; flex-wrap:wrap}
.select, .input{appearance:none; background:#0f1430; color:var(--text); border:1px solid #232b55; border-radius:12px; padding:10px 14px; font-size:14px}
.btn{border:0; border-radius:14px; padding:12px 16px; font-weight:600; cursor:pointer; transition:transform .05s ease;}
.btn:active{transform:translateY(1px)}
.btn-primary{background:linear-gradient(180deg, var(--accent), #557aff); color:#071022}
.btn-ghost{background:#0d1330; color:var(--text); border:1px solid #2a3566}
.grid{display:grid; grid-template-columns:1fr; gap:18px;}
.ring{display:grid; place-items:center; aspect-ratio:1/1; width:min(80vw,420px); margin:8px auto 0; position:relative}
.time{position:absolute; text-align:center}
.time .mmss{font-variant-numeric:tabular-nums; font-size:46px; font-weight:700; letter-spacing:.5px}
.time .label{margin-top:6px; font-size:12px; color:var(--muted)}
footer{margin-top:16px; display:flex; align-items:center; justify-content:space-between; gap:12px; color:var(--muted); font-size:12px}
.kbd{font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background:#0f1430; border:1px solid #28305b; padding:2px 6px; border-radius:6px;}
.row{display:flex; gap:10px; flex-wrap:wrap; align-items:center}
.a11y{position:absolute; left:-9999px}
a{color:var(--accent-2)}
</style>
</head>
<body>
<main class="app" role="application" aria-labelledby="app-title">
<header>
<div>
<h1 id="app-title">Mini Focus Timer</h1>
<div class="sub">Day01 — 静的Webアプリ / キーボード <span class="kbd">Space</span> で開始/一時停止</div>
</div>
<div class="controls" aria-label="設定">
<select id="mode" class="select" aria-label="モード">
<option value="25-5">25分作業 / 5分休憩</option>
<option value="50-10">50分作業 / 10分休憩</option>
<option value="custom">カスタム</option>
</select>
<input id="focusMin" class="input" type="number" min="1" max="180" step="1" value="25" aria-label="作業分" title="作業分">
<input id="breakMin" class="input" type="number" min="1" max="60" step="1" value="5" aria-label="休憩分" title="休憩分">
</div>
</header>
<section class="grid">
<div class="ring" aria-live="polite" aria-atomic="true">
<svg viewBox="0 0 120 120" width="100%" height="100%" role="img" aria-label="進捗リング">
<circle cx="60" cy="60" r="54" fill="none" stroke="var(--ring)" stroke-width="8" />
<circle id="progress" cx="60" cy="60" r="54" fill="none" stroke="var(--ring-active)" stroke-width="8" stroke-linecap="round" stroke-dasharray="339.292" stroke-dashoffset="339.292" transform="rotate(-90 60 60)" />
</svg>
<div class="time">
<div class="mmss" id="mmss" aria-live="polite">25:00</div>
<div class="label" id="phaseLabel">作業</div>
</div>
</div>
<div class="row" aria-label="操作">
<button id="startPause" class="btn btn-primary" aria-pressed="false">開始</button>
<button id="reset" class="btn btn-ghost">リセット</button>
<label class="row" style="gap:6px; color:var(--muted)">
<input type="checkbox" id="soundToggle" /> 音でお知らせ
</label>
</div>
</section>
<footer>
<div>保存先: LocalStorage(前回の設定を保持)</div>
<div>ショートカット: <span class="kbd">Space</span> 開始/停止 ・ <span class="kbd">R</span> リセット</div>
</footer>
<div class="a11y" aria-live="assertive" id="a11y"></div>
</main>
<script>
(function(){
// ---- State ----
const $ = (s)=>document.querySelector(s);
const mmssEl = $('#mmss');
const phaseEl = $('#phaseLabel');
const progressEl = $('#progress');
const startBtn = $('#startPause');
const resetBtn = $('#reset');
const modeSel = $('#mode');
const focusInput = $('#focusMin');
const breakInput = $('#breakMin');
const soundToggle = $('#soundToggle');
const a11yLive = $('#a11y');
const CIRC = 2 * Math.PI * 54; // r=54
progressEl.setAttribute('stroke-dasharray', String(CIRC));
let timerId = null;
let running = false;
let phase = 'focus'; // 'focus' | 'break'
let remainSec = 25*60; // seconds
let totalSec = remainSec;
// ---- Persistence ----
const saved = JSON.parse(localStorage.getItem('mini-focus-timer')||'{}');
if(saved.focus){ focusInput.value = saved.focus }
if(saved.break){ breakInput.value = saved.break }
if(saved.mode){ modeSel.value = saved.mode }
if(saved.sound){ soundToggle.checked = !!saved.sound }
// Apply mode -> inputs
function applyMode(){
const v = modeSel.value;
if(v === '25-5'){ focusInput.value = 25; breakInput.value = 5; }
else if(v === '50-10'){ focusInput.value = 50; breakInput.value = 10; }
// custom: keep as is
saveState();
if(!running){ setPhase('focus'); }
}
function saveState(){
localStorage.setItem('mini-focus-timer', JSON.stringify({
focus: Number(focusInput.value),
break: Number(breakInput.value),
mode: modeSel.value,
sound: soundToggle.checked
}));
}
function clampInputs(){
const f = Math.min(180, Math.max(1, Number(focusInput.value||25)));
const b = Math.min(60, Math.max(1, Number(breakInput.value||5)));
focusInput.value = f; breakInput.value = b;
}
function setPhase(next){
phase = next;
const minutes = (phase === 'focus') ? Number(focusInput.value) : Number(breakInput.value);
totalSec = Math.round(minutes * 60);
remainSec = totalSec;
phaseEl.textContent = (phase === 'focus') ? '作業' : '休憩';
updateUI();
}
function updateUI(){
const m = Math.floor(remainSec/60).toString().padStart(2,'0');
const s = Math.floor(remainSec%60).toString().padStart(2,'0');
mmssEl.textContent = `${m}:${s}`;
const ratio = 1 - (remainSec/Math.max(1,totalSec));
progressEl.style.strokeDashoffset = String(CIRC * ratio);
startBtn.textContent = running ? '一時停止' : '開始';
startBtn.setAttribute('aria-pressed', String(running));
}
function beep(){
if(!soundToggle.checked) return;
try{
const ctx = new (window.AudioContext||window.webkitAudioContext)();
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(880, ctx.currentTime);
g.gain.setValueAtTime(0.001, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.2, ctx.currentTime + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.35);
o.connect(g).connect(ctx.destination);
o.start();
o.stop(ctx.currentTime + 0.4);
}catch(e){/* ignore */}
}
function announce(msg){ a11yLive.textContent = msg; }
function tick(){
if(!running) return;
remainSec -= 1;
if(remainSec <= 0){
beep();
// Switch phase
if(phase === 'focus'){
announce('作業終了。休憩を開始します');
setPhase('break');
}else{
announce('休憩終了。作業を開始します');
setPhase('focus');
}
}
updateUI();
}
function start(){
if(running) return;
running = true;
updateUI();
timerId = setInterval(tick, 1000);
}
function pause(){
running = false;
if(timerId) clearInterval(timerId);
updateUI();
}
function reset(){
pause();
setPhase('focus');
}
// ---- Events ----
startBtn.addEventListener('click', ()=>{ running ? pause() : start(); });
resetBtn.addEventListener('click', reset);
modeSel.addEventListener('change', ()=>{ applyMode(); });
[focusInput, breakInput, soundToggle].forEach(el=>{
el.addEventListener('change', ()=>{ clampInputs(); saveState(); if(!running) setPhase(phase); });
});
window.addEventListener('keydown', (e)=>{
if(e.code === 'Space'){ e.preventDefault(); running ? pause() : start(); }
if(e.key.toLowerCase() === 'r'){ reset(); }
});
// ---- Init ----
clampInputs();
applyMode();
setPhase('focus');
updateUI();
})();
</script>
</body>
</html>
実際に動くデモは GitHub Pages app-001-focus-timerで公開しています。
よくある失敗と対処法
開始を連打するとタイマーが加速する
原因:setIntervalの多重起動
対処:runningフラグで開始処理を制御する
一時停止しても裏で動いている
原因:clearIntervalを呼んでいない
対処:pause時に必ずclearIntervalを実行する
入力値がNaNになり表示が崩れる
原因:空文字や不正値
対処:最小値・最大値で入力を丸める
進捗リングが逆に動く
原因:進捗率の計算ミス
対処:残り時間と合計時間の式を確認する
Spaceキーで画面がスクロールする
原因:ブラウザの既定動作
対処:e.preventDefault() を使う
音が鳴らない
原因:AudioContextの再生制約
対処:ユーザー操作後に再生する
発展アイデア
- 通知音の種類切替
- ダーク/ライトテーマの切替
- 作業ログの保存
- タスク名の追加
- 集中回数の可視化
まとめ(学びの整理)
- setIntervalで時間を管理する基本
- 秒管理から分秒表示への変換
- 状態(running / focus / break)の考え方
- SVGで進捗を可視化する方法
- LocalStorageで設定を保存・復元する流れ
- キーボード操作とアクセシビリティへの配慮
次に読む記事
次回は マッチングゲーム(神経衰弱) を作ります。
今回学んだ「状態管理」「イベント処理」「LocalStorage保存」を、カード操作・ロック処理・ベストタイム保存へと発展させていきます。
