カレンダー css デザイン 9選 — 実際に月が動く
カレンダー css デザイン というと、ブラウザが用意する小さな日付ポップアップの色を変えるところで終わりがちです。ここで作る9つは1か月をまるごと広げる月間グリッドそのもので、どれも本当に動きます。1つは月を送るときに2枚のグリッドが一緒に滑り、別の1つは1年を365個の小さな四角で埋めます。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 2トラックで送る月移動
- 02 今日のリングと数字バッジ
- 03 予定チップと「他N件」
- 04 ドラッグで塗る期間選択
- 05 時間軸の上の週タイムライン
- 06 つまんで動かす予定カード
- 07 濃さで読むヒートマップ暦
- 08 横並びのミニ3か月
- 09 格子からアジェンダへ畳む切替
9つの並びは人気順ではなく、カレンダーが画面の中で実際にたどる順です。まず月が送られ(01)、今日が見つかり(02)、予定が1日に積み重なり(03)、数日をまとめて塗り(04)、重なる時間が見え(05)、予定をつまんで動かし(06)、1年を1枚で読み(07)、月をまたいで選び(08)、幅が狭くなるとアジェンダに畳まれます(09)。9つすべてに role="grid" が付き、日付を選ぶ5つには aria-selected も付いて、矢印キーでマスの間を実際に移動できます。
012トラックで送る月移動
矢印を押すと、出ていく月と入ってくる月が2枚のグリッドとして一緒に滑ります。数字だけを入れ替えると、どちらの方向へ動いたのか分からなくなるため、トラックを300%の幅で敷いて今の月を中央に置き、左右に押して送ります。切り替えが終わった瞬間、transition が切れている隙にだけトラックを元へ戻すので、このリセットは目につきません。
function go(dir) {
if (busy) return;
busy = true; cal.classList.remove('is-demo'); cal.classList.add('is-anim');
var to = BASE - dir * (100 / 3);
track.style.transform = strip.style.transform = 'translateX(' + to + '%)';
var done = function () { track.removeEventListener('transitionend', done); finish(dir); };
track.addEventListener('transitionend', done);
window.setTimeout(done, 700);
}
function finish(dir) {
busy = false; cal.classList.remove('is-anim');
cur = new Date(cur.getFullYear(), cur.getMonth() + dir, 1);
render();
track.style.transform = ''; strip.style.transform = '';
live.textContent = label(cur);
}
02今日のリングと数字バッジ
今日のマスにだけ青い円が敷かれ、その上でリングが一度広がって消えていきます。予定のある日は、その日の件数が小さなバッジとしてマスの角に乗ります。白い縁取りのおかげで青い円の上でも数字は読めます。リングはマスと同じ高さの正方形を border-radius: 50% で丸く切り出しているので、楕円につぶれません。
.cal__cell.is-today::after {
content: "";
position: absolute; left: 50%; top: 50%;
width: clamp(24px, 7.5vw, 32px); height: clamp(24px, 7.5vw, 32px);
border: 3px solid $color;
border-radius: 50%;
transform: translate(-50%, -50%) scale(.9);
opacity: 0;
pointer-events: none;
animation-name: today-ring;
animation-duration: $duration;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
03予定チップと「他N件」
1マスには色付きチップが2つまで積まれ、残りは「他N件」の1行に畳まれます。その行を押すと、grid-template-rows が 0fr から 1fr へ広がって残りが本当に開きます。開くリストは絶対配置のカードなのでグリッドの高さを押し上げず、320px のスマホでも隣の行が動きません。
.cal__morewrap {
position: absolute;
left: 0; right: 0; top: calc(100% + 2px);
display: grid;
grid-template-rows: 0fr;
background: #fff;
border-radius: $r-sm;
box-shadow: $shadow-press;
transition: grid-template-rows $duration $easing;
z-index: 2;
}
.cal__more { min-height: 0; overflow: hidden; }
.cal__cell.is-open .cal__morewrap { grid-template-rows: 1fr; }
04ドラッグで塗る期間選択
開始マスを押したまま引くと、通ったマスが順に塗られていきます。塗る判定は paint() という1つの関数にまとまっていて、両端の番号だけを受け取り、間のマスに is-in、両端に is-start と is-end を付ける作りなので、逆方向に引いても同じコードがそのまま動きます。キーボードでは、フォーカスしたマスから Shift と矢印で範囲を広げられます。
function paint(a, b) {
var lo = Math.min(a, b), hi = Math.max(a, b);
for (var i = 0; i < cells.length; i++) {
var on = i >= lo && i <= hi;
cells[i].classList.toggle('is-in', on);
cells[i].classList.toggle('is-start', on && i === lo);
cells[i].classList.toggle('is-end', on && i === hi);
cells[i].setAttribute('aria-selected', String(on));
}
live.textContent = cells[lo].getAttribute('data-d') + ' – ' + cells[hi].getAttribute('data-d');
}
05時間軸の上の週タイムライン
左の時間目盛りに合わせて、予定ブロックが開始時刻の位置に置かれます。重なる2つのブロックは幅を半分ずつ分け合って横に並ぶので、月間グリッドでは見えない衝突が一目で分かります。現在時刻の線は translateY で一定の速さで下りていき、両端でだけフェードします。
.wt.is-demo .wt__now {
animation-name: wt-now;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes wt-now {
0% { transform: translateY(calc(var(--rowh) * .4)); opacity: 0; }
10% { opacity: 0; }
22% { opacity: 1; }
78% { opacity: 1; }
90% { opacity: 0; }
100% { transform: translateY(calc(var(--rowh) * 4.7)); opacity: 0; }
}
06つまんで動かす予定カード
チップをつまむと、少し浮いて傾き、影が深くなります。カーソルが通るマスは破線の枠で受け入れ先であることを示し、手を離すと appendChild でそのマスに本当に移ります。Space でつまみ、矢印でマスを選び、もう一度 Space で置くキーボードの手順も用意してあるので、マウスなしでも予定を動かせます。
document.addEventListener('pointermove', function (e) {
if (!drag) return;
drag.chip.style.transform = 'translate(' + (e.clientX - drag.x) + 'px,' + (e.clientY - drag.y - 6) + 'px) scale(1.06) rotate(-4deg)';
var el = document.elementFromPoint(e.clientX, e.clientY);
var c = el && el.closest ? el.closest('.dd__c') : null;
if (c !== drag.over) {
if (drag.over) drag.over.classList.remove('is-over');
drag.over = c;
if (c) c.classList.add('is-over');
}
});
07濃さで読むヒートマップ暦
1年が7行53列の小さな四角に敷き詰められ、活動の多い日ほど濃くなります。色の段階はステージ色にインクを混ぜていき最後はインクそのものになる5段階で、1行が1つの曜日、1列がそのまま1週間なので、53列だけを指定して既定の行方向の自動配置に任せています。ループでは左の列から順にマスが満ちていきますが、マスごとに時間差を付けるところでは animation: の一括指定ではなく個別のプロパティを使っています。
.hm__board {
display: grid;
grid-template-columns: repeat(53, 1fr);
}
.hm__row { display: contents; }
.hm__cell { background: color.mix($color, $stage-yellow, 14%); }
.hm__cell.lv1 { background: color.mix($color, $stage-yellow, 30%); }
.hm__cell.lv2 { background: color.mix($color, $stage-yellow, 48%); }
.hm__cell.lv3 { background: color.mix($color, $stage-yellow, 70%); }
.hm__cell.lv4 { background: $color; }
08横並びのミニ3か月
先月・今月・来月が、縮んだ3つのグリッドとして横に並びます。文字の大きさは cqw 単位なので、箱が狭くなると3つが一緒に縮み、320px でも3つとも収まります。選択範囲の両端は、月が変わってもそれぞれのカードの中のマスに同じ状態で塗られるため、1つの範囲が3か月にまたがって続いて見えます。
.m3 { container-type: inline-size; width: min(100%, 436px); }
.m3__strip { display: flex; gap: $sp-2; }
.m3__card { flex: 1 1 0; min-width: 0; padding: $sp-1; border-radius: $r-sm; }
.m3__label { font-size: clamp(9px, 2.6cqw, 13px); }
.m3__cell { font-size: clamp(7px, 2.2cqw, 11px); }
.m3__card--side .m3__num { font-weight: 500; color: color.mix($color, transparent, 45%); }
09格子からアジェンダへ畳む切替
同じ9月のデータが、箱の幅に応じて7列のグリッドか日付順のリストのどちらかの形で表示されます。見た目を選ぶのは @container (max-width: 176px) のルールで、ResizeObserver がその瞬間を捉えて role を grid と list の間で本当に書き換えます。2つのアイコンは aria-pressed で押されている側を伝え、表示が変わると隠れたマスにあったフォーカスを新しい表示の最初のマスへ移します。
function setMode(narrow) {
if (narrow === isAgenda) return;
isAgenda = narrow;
root.classList.toggle('is-agenda', narrow);
view.setAttribute('role', narrow ? 'list' : 'grid');
bgrid.classList.toggle('is-on', !narrow);
blist.classList.toggle('is-on', narrow);
bgrid.setAttribute('aria-pressed', String(!narrow));
blist.setAttribute('aria-pressed', String(narrow));
var a = document.activeElement;
if (!a || a === document.body || !a.offsetParent) focusFirst();
}
new ResizeObserver(function () { setMode(frame.getBoundingClientRect().width <= 176); }).observe(frame);
どこで壊れるか — 落とし穴
1つ目は 04 番で測定に引っかかりました。時間差を付けたマスに animation-delay だけを設定すると、遅延が解ける前のフレームでは、マスが塗られたあとの既定の状態のまま見えているため、ループの先頭で表示が一度飛びました。フレームを重ねて比べると最初と最後のフレームが目で分かるほどずれており、animation-fill-mode: backwards を付けたところ差はほぼ消えました。時間差を付ける要素には、遅延の指定とセットで塗りのモードも必要です。
2つ目はステージの大きさが原因でした。320×200 のスマホ画面でページが画面より縦に長くならないようにするには、週の行が5行以下である必要がありますが、6週分必要な月があります。デモの格子は日曜始まりなので、土曜日に始まる2026年8月は最初の行が7月26日から8月1日までとなり、30日と31日が6週目に回って、5行固定のグリッドはその2日をまったく描きません。デモは9月を基準にしてこの問題を避けましたが、実際の画面に組み込むなら6行を前提にした高さの見積もりが必要です。こうして整えた9つのソースは、解凍パスワード 4zdx6kfa で開く zip の中にそのまま入っています。3つ目として、05 番の現在時刻の線は、透明な区間が3%しかなかったとき、録画の最初のフレームに線が半分残りました。ヘッドレスのキャプチャはコンポジターの遅延で最初の2フレームを取りこぼすことがあるため、透明な区間を10%と90%に広げました。
アクセシビリティ
9つすべてで prefers-reduced-motion: reduce のときは自動再生ループを切り、状態だけを残します。リングは広がらず今日の円だけが残り、月は滑らずに即座に切り替わり、ヒートマップは色を塗ったまま止まります。グリッドには role="grid"、週の行には role="row"、日付のマスには role="gridcell" が付き、選択のある5つは aria-selected で、今日を示す8つは aria-current="date" で状態を伝えます。マス間の移動は、チップ自体をつまんで運ぶ 06 番を除いてロービング tabindex が担い、月間グリッドでは矢印の ±1日・±7日と Home・End が実際に効き、週タイムラインとヒートマップでは上下がその列の並びに合わせて動きます。01 番の月名は aria-live="polite" の領域が読み上げます。07 番では四角の一つひとつに日付と件数を入れた aria-label が付きます。フォーカスを戻すときに focus({ preventScroll: true }) を使うのは、このデモが記事の中の iframe に埋まっていて、素の focus() を呼ぶと読んでいる人の画面が勝手にスクロールしてしまうからです。ここで使ったロールの定義は MDN の grid ロール文書にあります。
ダッシュボードに付けるカレンダー部品はダッシュボードカテゴリに、押すと反応する部品はクリックカテゴリにまとめてあります。
FAQ
ブラウザの日付ピッカーがあるのに、なぜカレンダーを自分で作るのですか?
1日を選ぶだけなら作る必要はありません。作る理由は、予約や予定の画面のように1か月全体を見せて予定チップを乗せる場合や、数日をまとめて選ばせる場合に生まれます。その場合でも、キーボード移動とロールは、ブラウザが肩代わりしてくれていた分を自分で埋めなければなりません。
スマホでは7列のグリッドは狭くなりすぎませんか?
320px ではマスの幅が40px前後まで縮むのは避けられないので、09 番は箱の幅に反応して日付順のリストに畳まれます。リストは同じデータを使い、矢印の移動もその軸に合わせて変わるため、何も入らなくなるまで縮めたグリッドより読み取れる情報が多くなります。
1日に予定が10件集中したらどうなりますか?
03 番のように、2つまでチップで見せて残りを「他N件」の1行に畳むのがグリッドの高さを守る方法です。開くのは絶対配置のカードなので、その日のマスの上に重なって開くだけで、隣の行はまったく動きません。