習慣トラッカー UI 9選 — 草グリッドから共有カードまで
習慣トラッカーは、今日やったことを押して記録し、その記録が積み上がった形を1画面で見せる UI です。ここに載せた9つはライブラリなしの素の JS と SCSS だけで組んであり、押せばその場で値が変わって localStorage に残ります。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 1年の草グリッド
- 02 連続日数の炎
- 03 習慣7日ドットカード
- 04 カレンダーはんこ
- 05 三重ゴールリング
- 06 曜日別バーサマリー
- 07 リマインダー時刻選び
- 08 バッジ解除
- 09 今日のまとめ共有カード
9つの並びは、記録が人に届くまでの距離の順です。最初の4つは押した跡をそのまま返す面で、1年分を371個の点に広げたグリッド(01)、連続日数をアイコンの大きさに置き換えた炎(02)、習慣3行を7日分のドットに畳んだカード(03)、日付のマスにはんこを押すカレンダー(04)です。真ん中の3つは跡を数字にまとめる面で、目標に対する比率を三重のリングに重ね(05)、曜日ごとの数をバーに立て(06)、次の通知までの残り時間を分単位で数えます(07)。最後の2つは記録を外へ出す面で、条件を越えると反転するバッジ(08)と、今日のまとめを画像ファイルにするカード(09)です。「習慣トラッカー アプリ」を新しく組むときに、画面へ先に置いていく順でもあります。舞台の色は紙3・インク3・黄2・橙1 の配分で巡らせています。同じデータを線と面で描く側はSVGチャートの描画9選に、日付のマスそのものの組み方はCSSカレンダーの作り方9選にあります。
011年の草グリッド
53週 × 7日の点371個が左の週から順に満ちていき、点に触れるとその日付と回数が吹き出しに出ます。各マスの濃さはシードを 20260912 に固定した mulberry32 の乱数が決めるので、再読み込みしても同じ草が出て、今日のマスだけ localStorage の値で上書きされ、ボタンを押すたびに0から4へ1段ずつ上がります。
.cg__cell {
box-sizing: border-box; width: 7px; height: 7px; flex: 0 0 auto;
border: 1px solid transparent; border-radius: 50%;
background: rgba($subject-cream, .10); background-clip: padding-box;
}
.cg.is-demo .cg__cell {
animation-name: cgWave; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--c) * 12ms);
}
@keyframes cgWave {
0% { transform: scale(.15); opacity: .3; }
14% { transform: scale(1); opacity: 1; }
86% { transform: scale(1); opacity: 1; }
100% { transform: scale(.15); opacity: .3; }
}
点と点のあいだを空ける1px は gap ではなく透明な border です。53列を424px に収めるとマスの間隔が4px の目盛りに合わなくなるのですが、枠線は間隔ではなくマス自身の一部なので box-sizing: border-box と一緒に使えば目盛りを崩さずに済みます。
02連続日数の炎
「今日チェック」を押すと連続日数が1増えて最長記録も更新され、「連続を切る」を押すと数字だけ0に戻り、最長記録は残ります。炎は日数をそのまま scale に渡さず、30・14・3・1日を境に4段階だけ大きくなるので、100日になっても画面を覆いません。
function stepOf(n) {
return n >= 30 ? 4 : n >= 14 ? 3 : n >= 3 ? 2 : n >= 1 ? 1 : 0;
}
function paint(broken) {
numEl.textContent = String(state.streak);
bestEl.textContent = BEST_TEXT.replace('{b}', String(state.best));
flame.setAttribute('class', 'sc__flame sc__flame--s' + stepOf(state.streak));
sayEl.textContent = broken
? SAY_OFF.replace('{b}', String(state.best))
: SAY_ON.replace('{n}', String(state.streak));
}
炎の段階を steps(1, end) で飛ばしているのは、大きさの変化が半透明の中間フレームを作らないようにするためで、その間のフレームは週7つのドットと後光が埋めます。おかげでこの面は移動フレーム 23/23 となり、9つの中でも最も密な部類に入ります。
03習慣7日ドットカード
習慣3行がそれぞれ7日分のドットを持ち、ドットを押すと埋まって右のリングが同じ分だけ巻かれます。ドットは本物の <button> なので aria-pressed で状態がそのまま読まれ、リングの円周は JS が 2 * Math.PI * 26 で計算して stroke-dasharray に入れます。
var CIRC = 2 * Math.PI * 26;
function paint() {
var n = total(), all = marks.length * DAYS;
var p = n / all;
ring.style.strokeDasharray = String(CIRC);
ring.style.strokeDashoffset = String(CIRC * (1 - p));
pctEl.textContent = Math.round(p * 100) + '%';
sayEl.textContent = SAY_TEXT.replace('{n}', String(n));
}
JS はインラインの style でオフセットを書き、プレビューは @keyframes で書きます。アニメーションの宣言はインラインスタイルより上のカスケード段にあるので両者はぶつからず、4つ目のドットを押すと表示は62%から67%へ、オフセットは 62.2335 から 54.4543 へ動きます。
04カレンダーはんこ
カレンダーのマスを押すと、はんこが rotate(-42deg) scale(.3) から rotate(-12deg) scale(1) へ斜めに降りて押されます。続いた日付はマス背景の角丸を片側ずつ0にすることで一本の帯にまとまり、行が変わればつなぎを切ります。
function paintCell(day) {
var b = cells[day];
var on = has(day);
var col = (day - 1) % COLS;
var left = on && col > 0 && has(day - 1);
var right = on && col < COLS - 1 && has(day + 1);
b.className = 'cs__cell' + (on ? ' is-on' : '') + (left ? ' is-left' : '') + (right ? ' is-right' : '');
b.setAttribute('aria-pressed', on ? 'true' : 'false');
}
マス同士の横の間隔は0です。数 px でも空けると帯がその隙間で切れて、続いているという事実そのものが見えなくなります。10日を押すと11日のマスが cs__cell is-on is-left is-right になり、両側ともつながります。
05三重ゴールリング
半径 50・38・26 の3つのリングがそれぞれの円周で巻かれ、3つとも閉じると破片が8つ、一度だけはじけます。リングごとに円周が違うので 2 * Math.PI * RADII[i] をリングごとに計算する必要があり、1つの値を使い回すと内側のリングが2周してしまいます。
function paint() {
var closed = 0;
for (var i = 0; i < rings.length; i++) {
var p = Math.min(done[i] / GOALS[i], 1);
var circ = 2 * Math.PI * RADII[i];
rings[i].style.strokeDasharray = String(circ);
rings[i].style.strokeDashoffset = String(circ * (1 - p));
pcts[i].textContent = Math.round(p * 100) + '%';
if (p >= 1) { closed++; }
}
sayEl.textContent = SAY_TEXT.replace('{n}', String(closed));
return closed === rings.length;
}
破片をもう一度出すには、クラスを外して void root.offsetWidth でリフローを強制してから付け直します。外してすぐ付けるとブラウザが2つの変更を1つにまとめてしまい、アニメーションが最初から再生されません。
06曜日別バーサマリー
7本のバーが下辺から伸びて曜日ごとの完了数を立て、3回の目標ラインを越えたバーだけ色が変わります。列は repeat(7, 1fr) で均等に分け、バーは transform-origin: bottom と scaleY(var(--v)) だけで伸ばすので高さには触れません。
.wb__cols { display: grid; grid-template-columns: repeat(7, 1fr); gap: $sp-2; }
.wb__slot { display: block; width: 100%; height: 96px; border-radius: $r-sm; background: rgba($subject-ink, .08); }
.wb__bar {
display: block; width: 100%; height: 100%; border-radius: $r-sm;
background: rgba($subject-ink, .3);
transform-origin: bottom; transform: scaleY(var(--v, 0));
}
@keyframes wbGrow {
0% { transform: scaleY(calc(var(--v, 0) * .2)); }
24% { transform: scaleY(var(--v, 0)); }
76% { transform: scaleY(var(--v, 0)); }
90% { transform: scaleY(calc(var(--v, 0) * .2)); }
100% { transform: scaleY(calc(var(--v, 0) * .2)); }
}
プレビューでバーを下端まで下げず20%残しているのは代表画像のためです。24枚のどの1枚が選ばれても、7本すべてが見えていなければチャートとして読めません。
07リマインダー時刻選び
時刻チップを選んでスイッチを入れると、次の通知までの残り時間が分単位で数えられます。差が0以下になる時刻、つまり今日すでに過ぎた時刻は1日足して翌日に回すので、夜10時に 06:00 を選ぶとマイナスではなく翌朝までの8時間として出ます。
function minutesLeft(hhmm) {
var parts = hhmm.split(':');
var now = new Date();
var at = new Date(now.getFullYear(), now.getMonth(), now.getDate(), Number(parts[0]), Number(parts[1]), 0, 0);
var diff = Math.round((at.getTime() - now.getTime()) / 60000);
return diff <= 0 ? diff + 24 * 60 : diff;
}
チップが1つずつ点くプレビューには animation-fill-mode をあえて付けていません。遅延の区間に見えるべき絵がチップの素の状態そのものなので、backwards を付けると順番の来ていないチップまで全部点いた状態で始まってしまいます。
08バッジ解除
7日・30日・100日のバッジがグレーでロックされ、日数がその線を越えると表裏が反転して色を取り戻します。表と裏は transform-style: preserve-3d で重ね、それぞれに backface-visibility: hidden を付け、ロック面は filter: grayscale(1) の上にロックを示す文字も重ねているので色がなくても読めます。
.bu__inner {
position: relative; display: block; width: 100%; height: 100%;
transform-style: preserve-3d; transition: transform $dur-base $easing;
}
.bu__badge.is-open .bu__inner { transform: rotateY(180deg); }
.bu__face {
position: absolute; left: 0; top: 0; width: 100%; height: 100%;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: $sp-1;
border-radius: $r-card; backface-visibility: hidden; overflow: hidden;
}
@keyframes buFlip {
0% { transform: rotateY(0deg); }
16% { transform: rotateY(180deg); }
78% { transform: rotateY(180deg); }
88% { transform: rotateY(0deg); }
100% { transform: rotateY(0deg); }
}
実際のクリックは 300ms の transition でなめらかに回り、steps(1, end) のカットで切り替えているのはプレビューの反転だけです。rotateY で回る面の幅はコサインで決まるので1フレームあたりの変化が90度付近で必ず最大になり、そのままだと代表画像が毎回1本の線のような真横のビューで選ばれてしまいます。
09今日のまとめ共有カード
今日の記録を canvas に描いてカード1枚にし、ボタン1回でその画像をクリップボードへ送ります。カードはページが開いたときに一度描いてあるのでボタンを押す前から何を共有するのかが分かり、400 × 224 で描いたものを 200 × 112 で表示するので高精細な画面でも文字がつぶれません。
function copyCard() {
if (window.ClipboardItem && navigator.clipboard && navigator.clipboard.write && canvas.toBlob) {
canvas.toBlob(function (blob) {
var item = new window.ClipboardItem({ 'image/png': blob });
navigator.clipboard.write([item]).then(function () { flash(COPIED_TEXT); }, download);
}, 'image/png');
return;
}
download();
}
function download() {
var a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = 'habit-card.png';
a.click();
flash(SAVED_TEXT);
}
クリップボードへの画像書き込みは、権限が拒否された環境や ClipboardItem のない環境ではそのまま失敗します。そこで toDataURL で作ったリンクを押して保存する道を並べて用意し、どちらの道を通ったかを状態の文に書き分けました。
どこで壊れるか — プレビューは通るのに手では動かない
プレビューと指は別のコードを走る
この9つの動きは2本立てです。グリッドに埋まるプレビューは .is-demo の中の CSS キーフレームで、人が押す経路は JS が状態を変えるコードです。ゲートが測るのは前者だけなので、後者が死んでいても計測値は平気な顔をします。実際に 02 の炎がそうでした。段階のクラスを flame.className に代入していたのですが、SVG 要素のこのプロパティは文字列ではなく SVGAnimatedString なので代入は黙って無視され、その間も累積面積 4.67%・強度 68.7・移動フレーム 23/23 がそのまま出ていました(修理前の値は run/297/_수리전실측.json にあります)。setAttribute に替えてはじめて、クリック後にクラスが sc__flame sc__flame--s2 へ、連続を切ると sc__flame sc__flame--s0 へ変わることを確認できました。
色は合成してから測る
そこで9つすべてを .is-demo を外した状態で開き、実際に押してみるスクリプトを別に回しました(run/297/_probe/interact.py)。色も同じやり方で測りました。計算済みの色を親までさかのぼって合成し対比を出したところ、8か所が WCAG AA に届いておらず、いちばん手強かったのが草グリッドの凡例でした。2段階目の色の上ではクリームが 4.06:1、インクが 4.21:1 で、どちらの文字色でも9px の基準を越えられず、数字をチップの外へ出すしかありませんでした。9つのファイルが入った圧縮ファイルのパスワードは e2eay99r で、同じ物差しで測り直した26か所は今どれも AA を越えています。
| 測った場所 | 文字 | 背景 | 対比 | 判定 |
|---|---|---|---|---|
| 01 凡例の数字 | #fff7e6 | #17141a | 17.11:1 | AA |
| 02 状態の文 | #17141a | #ff4d1f | 5.5:1 | AA |
| 04 押していない日付 | #4a3e22 | #ffd23f | 7.27:1 | AA |
| 06 チャートの説明 | #5d5b5f | #ffffff | 6.76:1 | AA |
| 08 施錠の文字 | #948f88 | #2a262a | 4.63:1 | AA |
3つの変形とアクセシビリティ
SCSS の先頭にある3つの変数を変えるだけで、9つすべてが一緒に変わります。
| 名前 | 変える値 | 感じ |
|---|---|---|
| duration | $duration 2s → 1s |
満ちていくリズムが2倍速くなります |
| easing | $easing $ease-spring → $ease-pop |
はんことバッジがもう一度跳ねます |
| color | $color $stage-yellow → $subject-mint |
草グリッドがさわやかな方へ寄ります |
prefers-reduced-motion: reduce では9つがそれぞれ違うものを残します。01 は波を止めて育ちきった草を1枚、02 は炎・後光・ドットを今の状態で止め、03 は埋まったドットと巻かれたリングを、04 ははんこの押されたカレンダーを、05 は破片を先に捨ててリングだけを、06 は今の値のまま立つバーを、07 は選んだ時刻を1枚、08 は今の解除状態を、09 はカード1枚を残します。そのブロックの中の宣言には1つずつ !important を付けてあります。.is-demo の付いたセレクタのほうが詳細度で勝ってしまうからです。
色だけで情報を伝えないという決まりも9つ全部に適用しました。01 は凡例に0から4までの数字を並べ、マスごとの aria-label に日付と回数を入れ、05 はリングの横に名前とパーセントを書き、06 はバーの上に個数を書き、08 はグレースケールの上に文字を重ねています。数字が変わる場所はすべて role="status" と aria-live="polite" の領域に文として書き直します。根拠はMDN の ARIA ライブリージョンの解説とWCAG 2.2 の色の使用に関する解説です。
FAQ
サーバーなしで記録を保存して大丈夫ですか
9つとも localStorage だけを使っています。読み書きを try/catch で包んであるので、プライベートウィンドウやストレージを塞いだブラウザでも画面が落ちず、既定値のまま動き続けます。ただしブラウザを変えると記録は引き継がれないので、複数の端末で使うサービスなら、同じ値をサーバーにも送る前提でこのコードを使ってください。
草グリッドに本物の1年分のデータを入れるにはどこを直しますか
シード乱数で埋めている部分だけを外し、日付ごとの回数を入れた配列をその場所に渡してください。マスの添字は週番号 × 7 + 曜日で、最後のマスが今日なので、受け取った配列も今日が末尾に来るように並べておけば、残りの計算はそのまま使えます。
ダッシュボードにはリングとバーのどちらが向いていますか
一目で達成できたかどうかだけ分かればよい場所はリング(05)が向いていて、曜日どうしの差を比べる必要がある場所はバー(06)が向いています。リングは角度なので10%と15%の差が読み取りにくく、バーは同じ軸に並んでいるため長さの差がそのまま比較になるからです。03 だけを外して使う道もあり、習慣の名前と行数を替えれば「習慣トラッカー 項目」を並べる部品としてそのまま使えます。円形のゲージをもっと扱いたい場合は、円形プログレスバーのウィジェット9選がその軸だけを掘り下げています。