08横並びのミニ3か月
先月・今月・来月が縮小した3つの格子として並び、中央だけ文字が濃く、選択範囲は3か月をまたいで続きます。
使いどころ出発日と到着日が月をまたぎ、1か月分の格子では最後まで見えない予約画面に付けるとき
container-typearia-selectedclamp()

<main class="stage">
<section class="m3 is-demo" id="m3" aria-label="3か月カレンダー">
<div class="m3__strip" id="m3-strip"></div>
<span class="m3__live" role="status" id="m3-live"></span>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@use "sass:color";
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-cream; // 오렌지 무대 위 크림 피사체
@include stage($stage-orange);
// 칩: 컨테이너 쿼리 — 폭이 줄면 셋이 함께 줄어든다 (320px 에도 셋 다 들어간다)
.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;
box-sizing: border-box;
padding: $sp-1;
border-radius: $r-sm;
background: color.mix($color, transparent, 6%);
}
.m3__card--mid { background: color.mix($color, transparent, 12%); }
.m3__label {
display: block;
margin: 0 0 $sp-1;
font-size: clamp(9px, 2.6cqw, 13px);
line-height: 1;
font-weight: 800;
color: $subject-ink; // 주황 카드 위 글자 — AA 4.5 (흐린 크림은 1.67:1)
}
.m3__card--mid .m3__label { color: $subject-ink; } // 6.13:1
.m3__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.m3__row { display: contents; }
.m3__cell {
position: relative;
aspect-ratio: 1;
display: grid;
place-items: center;
border-radius: 0;
font-size: clamp(7px, 2.2cqw, 11px);
line-height: 1;
}
.m3__cell--blank { visibility: hidden; }
.m3__num {
font-weight: 600;
color: $subject-ink; // AA 4.5 (흐린 크림은 1.58:1)
}
// 가운데 달만 진하다 — 양옆은 흐리다
.m3__card--side .m3__num {
font-weight: 500;
color: rgba($subject-ink, .9); // 양옆 달은 한 단계 흐리게 두되 AA 4.5 (5.16:1)
}
// 선택 칸 — 크림 칠 + 오렌지 글자. 스케일로 뿌리/회수된다
.m3__sel {
position: absolute;
inset: -1px;
display: grid;
place-items: center;
background: $color;
color: $subject-orange-deep; // 크림 칠 위 주황 글자 — 5.04:1
font-weight: 800;
border-radius: $r-xs;
transform: scale(0);
pointer-events: none;
}
.m3__cell.is-sel .m3__sel { transform: scale(1); }
.m3__cell.is-today .m3__sel { box-shadow: inset 0 0 0 2px $stage-orange; }
.m3__cell:focus-visible {
outline: 2px solid $color;
outline-offset: 1px;
z-index: 1;
}
@container (max-width: 330px) {
.m3__sel { inset: 0; }
.m3__cell { font-size: 7px; }
}
.m3__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
// ── 자동 루프: 범위의 끝이 9월 29→30일 을 지나 10월 1·2·3일 로 뻗어 나갔다가
// 온 길 그대로 돌아온다. 0% 와 100% 가 같은 상태(끝=9월 28일)라 이어진다.
// 다섯 칸의 키프레임 창이 서로 반대 방향으로 놓인다 — 뻗을 땐 좌→우, 돌아올 땐 우→좌.
.m3.is-demo .m3__p1 { animation-name: m3-p1; animation-duration: $dur-loop; animation-timing-function: $easing; animation-iteration-count: infinite; }
.m3.is-demo .m3__p2 { animation-name: m3-p2; animation-duration: $dur-loop; animation-timing-function: $easing; animation-iteration-count: infinite; }
.m3.is-demo .m3__p3 { animation-name: m3-p3; animation-duration: $dur-loop; animation-timing-function: $easing; animation-iteration-count: infinite; }
.m3.is-demo .m3__p4 { animation-name: m3-p4; animation-duration: $dur-loop; animation-timing-function: $easing; animation-iteration-count: infinite; }
.m3.is-demo .m3__p5 { animation-name: m3-p5; animation-duration: $dur-loop; animation-timing-function: $easing; animation-iteration-count: infinite; }
@keyframes m3-p1 { 0%, 8% { transform: scale(0); } 12%, 90% { transform: scale(1); } 94%, 100% { transform: scale(0); } }
@keyframes m3-p2 { 0%, 16% { transform: scale(0); } 20%, 82% { transform: scale(1); } 86%, 100% { transform: scale(0); } }
@keyframes m3-p3 { 0%, 24% { transform: scale(0); } 28%, 74% { transform: scale(1); } 78%, 100% { transform: scale(0); } }
@keyframes m3-p4 { 0%, 32% { transform: scale(0); } 36%, 66% { transform: scale(1); } 70%, 100% { transform: scale(0); } }
@keyframes m3-p5 { 0%, 40% { transform: scale(0); } 44%, 58% { transform: scale(1); } 62%, 100% { transform: scale(0); } }
@media (prefers-reduced-motion: reduce) {
.m3.is-demo .m3__p1,
.m3.is-demo .m3__p2,
.m3.is-demo .m3__p3,
.m3.is-demo .m3__p4,
.m3.is-demo .m3__p5 { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.m3 {
container-type: inline-size;
width: min(100%, 436px);
}
.m3__strip {
display: flex;
gap: 8px;
}
.m3__card {
flex: 1 1 0;
min-width: 0;
box-sizing: border-box;
padding: 4px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.06);
}
.m3__card--mid {
background: rgba(255, 247, 230, 0.12);
}
.m3__label {
display: block;
margin: 0 0 4px;
font-size: clamp(9px, 2.6cqw, 13px);
line-height: 1;
font-weight: 800;
color: #17141a;
}
.m3__card--mid .m3__label {
color: #17141a;
}
.m3__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.m3__row {
display: contents;
}
.m3__cell {
position: relative;
aspect-ratio: 1;
display: grid;
place-items: center;
border-radius: 0;
font-size: clamp(7px, 2.2cqw, 11px);
line-height: 1;
}
.m3__cell--blank {
visibility: hidden;
}
.m3__num {
font-weight: 600;
color: #17141a;
}
.m3__card--side .m3__num {
font-weight: 500;
color: rgba(23, 20, 26, 0.9);
}
.m3__sel {
position: absolute;
inset: -1px;
display: grid;
place-items: center;
background: #fff7e6;
color: #c93000;
font-weight: 800;
border-radius: 4px;
transform: scale(0);
pointer-events: none;
}
.m3__cell.is-sel .m3__sel {
transform: scale(1);
}
.m3__cell.is-today .m3__sel {
box-shadow: inset 0 0 0 2px #ff4d1f;
}
.m3__cell:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 1px;
z-index: 1;
}
@container (max-width: 330px) {
.m3__sel {
inset: 0;
}
.m3__cell {
font-size: 7px;
}
}
.m3__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.m3.is-demo .m3__p1 {
animation-name: m3-p1;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.m3.is-demo .m3__p2 {
animation-name: m3-p2;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.m3.is-demo .m3__p3 {
animation-name: m3-p3;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.m3.is-demo .m3__p4 {
animation-name: m3-p4;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.m3.is-demo .m3__p5 {
animation-name: m3-p5;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes m3-p1 {
0%, 8% {
transform: scale(0);
}
12%, 90% {
transform: scale(1);
}
94%, 100% {
transform: scale(0);
}
}
@keyframes m3-p2 {
0%, 16% {
transform: scale(0);
}
20%, 82% {
transform: scale(1);
}
86%, 100% {
transform: scale(0);
}
}
@keyframes m3-p3 {
0%, 24% {
transform: scale(0);
}
28%, 74% {
transform: scale(1);
}
78%, 100% {
transform: scale(0);
}
}
@keyframes m3-p4 {
0%, 32% {
transform: scale(0);
}
36%, 66% {
transform: scale(1);
}
70%, 100% {
transform: scale(0);
}
}
@keyframes m3-p5 {
0%, 40% {
transform: scale(0);
}
44%, 58% {
transform: scale(1);
}
62%, 100% {
transform: scale(0);
}
}
@media (prefers-reduced-motion: reduce) {
.m3.is-demo .m3__p1,
.m3.is-demo .m3__p2,
.m3.is-demo .m3__p3,
.m3.is-demo .m3__p4,
.m3.is-demo .m3__p5 {
animation: none !important;
}
}// 실제 동작: 지난달·이번달·다음달 미니 グリッド 셋. 시작(8月 30日)은 고정,
// 화살표(±1日/±7日)는 달 경계를 넘어 이동하고 Enter/클릭이 범위의 끝을 고른다.
var root = document.getElementById('m3');
var strip = document.getElementById('m3-strip');
var live = document.getElementById('m3-live');
var TODAY = new Date(2026, 8, 12);
var START = new Date(2026, 7, 30); // 범위 시작 — 8月 말
var BASE_END = new Date(2026, 8, 28); // 루프가 멈춰 있는 기본 끝 — 9月 28日
var MS = [7, 8, 9]; // 8·9·10月
var cells = []; // 시간순 — 배열 ±1/±7 이 곧 날짜 계산
function pad(n) { return n < 10 ? '0' + n : '' + n; }
function iso(d) { return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); }
MS.forEach(function (m, mi) {
var card = document.createElement('div');
card.className = 'm3__card' + (mi === 1 ? ' m3__card--mid' : ' m3__card--side');
var label = document.createElement('span');
label.className = 'm3__label';
label.textContent = '2026.' + (m + 1);
card.appendChild(label);
var grid = document.createElement('div');
grid.className = 'm3__grid';
grid.setAttribute('role', 'grid');
grid.setAttribute('aria-label', '2026.' + (m + 1));
var lead = new Date(2026, m, 1).getDay(); // 일요일 시작 앞쪽 빈칸 水
var total = new Date(2026, m + 1, 0).getDate();
var rows = Math.ceil((lead + total) / 7);
for (var r = 0; r < rows; r++) {
var row = document.createElement('div');
row.className = 'm3__row';
row.setAttribute('role', 'row');
for (var c = 0; c < 7; c++) {
var day = r * 7 + c - lead + 1;
var cell = document.createElement('div');
cell.className = 'm3__cell';
if (day < 1 || day > total) {
cell.classList.add('m3__cell--blank');
cell.setAttribute('aria-hidden', 'true');
row.appendChild(cell);
continue;
}
var date = new Date(2026, m, day);
cell.setAttribute('role', 'gridcell');
cell.setAttribute('aria-label', iso(date));
cell.tabIndex = -1;
var num = document.createElement('span');
num.className = 'm3__num';
num.textContent = day;
var sel = document.createElement('span');
sel.className = 'm3__sel';
sel.textContent = day;
sel.setAttribute('aria-hidden', 'true');
cell.appendChild(num);
cell.appendChild(sel);
if (+date === +TODAY) { cell.classList.add('is-today'); cell.setAttribute('aria-current', 'date'); }
var off = Math.round((date - BASE_END) / 864e5); // 루프가 뻗는 길: 9.29 → 10.3 다섯 칸
if (off >= 1 && off <= 5) sel.classList.add('m3__p' + off);
row.appendChild(cell);
cells.push({ el: cell, date: date });
}
grid.appendChild(row);
}
card.appendChild(grid);
strip.appendChild(card);
});
function paint(end) {
cells.forEach(function (c) {
var on = c.date >= START && c.date <= end;
c.el.classList.toggle('is-sel', on);
c.el.setAttribute('aria-selected', on ? 'true' : 'false');
});
live.textContent = iso(START) + ' ~ ' + iso(end);
}
paint(BASE_END);
var cur = 0;
cells[0].el.tabIndex = 0;
function move(n) {
cells[cur].el.tabIndex = -1;
cur = n;
cells[cur].el.tabIndex = 0;
cells[cur].el.focus({ preventScroll: true });
live.textContent = iso(cells[cur].date);
}
strip.addEventListener('keydown', function (e) {
var k = e.key, n = null;
if (k === 'ArrowRight') n = Math.min(cur + 1, cells.length - 1);
else if (k === 'ArrowLeft') n = Math.max(cur - 1, 0);
else if (k === 'ArrowDown') n = Math.min(cur + 7, cells.length - 1);
else if (k === 'ArrowUp') n = Math.max(cur - 7, 0);
else if (k === 'Home') n = 0;
else if (k === 'End') n = cells.length - 1;
else if (k === 'Enter' || k === ' ') { paint(cells[cur].date); e.preventDefault(); return; }
if (n !== null && n !== cur) { e.preventDefault(); root.classList.remove('is-demo'); move(n); }
});
strip.addEventListener('click', function (e) {
var t = e.target.closest ? e.target.closest('.m3__cell') : null;
if (!t || t.classList.contains('m3__cell--blank')) return;
root.classList.remove('is-demo');
for (var i = 0; i < cells.length; i++) if (cells[i].el === t) { move(i); paint(cells[i].date); break; }
});import { useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MiniThreeMonthCalendar.module.scss";
export type MiniThreeMonthCalendarProps = {
year?: number;
months?: number[]; // 0-index 월 3개 (기본 [7, 8, 9] → 8·9·10월)
demo?: boolean;
duration?: number; // 참조용 — 셀 팝 길이
easing?: string;
color?: string; // 선택 칸 색
stage?: string; // 선택 칸 위 글자색 (무대색)
};
type KeyEvt = { key: string; preventDefault: () => void };
type Cell = { month: number; day: number; time: number; iso: string; path: number; today: boolean };
type Vars = CSSProperties & { "--color"?: string; "--stage"?: string };
function pad(n: number): string {
return n < 10 ? "0" + n : "" + n;
}
export default function MiniThreeMonthCalendar({
year = 2026,
months = [7, 8, 9],
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
stage = "#ff4d1f",
}: MiniThreeMonthCalendarProps) {
const [demoOn, setDemoOn] = useState(demo);
const [cur, setCur] = useState(0);
const cellRefs = useRef<(HTMLDivElement | null)[]>([]);
const { cells, start, baseEnd } = useMemo(() => {
const start = new Date(year, months[0], 30); // 범위 시작 — 첫째 달 말
const baseEnd = new Date(year, months[1], 28); // 루프의 기본 끝 — 가운데 달 28일
const out: Cell[] = [];
const now = new Date();
months.forEach((m) => {
const total = new Date(year, m + 1, 0).getDate();
for (let day = 1; day <= total; day++) {
const date = new Date(year, m, day);
const off = Math.round((date.getTime() - baseEnd.getTime()) / 86400000);
out.push({
month: m,
day,
time: date.getTime(),
iso: year + "-" + pad(m + 1) + "-" + pad(day),
path: off >= 1 && off <= 5 ? off : 0, // 루프가 뻗는 다섯 칸
today: date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate(),
});
}
});
return { cells: out, start, baseEnd };
}, [year, months]);
const [end, setEnd] = useState<number>(baseEnd.getTime());
const [ann, setAnn] = useState("");
function isoOf(t: number): string {
const d = new Date(t);
return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate());
}
function paint(t: number) {
setEnd(t);
setAnn(isoOf(start.getTime()) + " ~ " + isoOf(t));
}
function move(i: number) {
setDemoOn(false);
setCur(i);
const refs = cellRefs.current;
const el = refs ? refs[i] : null;
if (el) el.focus({ preventScroll: true });
setAnn(cells[i].iso);
}
function onKeyDown(e: KeyEvt) {
let n: number | null = null;
if (e.key === "ArrowRight") n = Math.min(cur + 1, cells.length - 1);
else if (e.key === "ArrowLeft") n = Math.max(cur - 1, 0);
else if (e.key === "ArrowDown") n = Math.min(cur + 7, cells.length - 1); // ±7일 — 달 경계를 넘는다
else if (e.key === "ArrowUp") n = Math.max(cur - 7, 0);
else if (e.key === "Home") n = 0;
else if (e.key === "End") n = cells.length - 1;
else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
paint(cells[cur].time);
return;
}
if (n !== null && n !== cur) {
e.preventDefault();
move(n);
}
}
const vars: Vars = {
"--color": color,
"--stage": stage,
"--duration": duration + "ms",
"--easing": easing,
};
// 달 경계를 넘는 주(月) 배열 — 카드 안 배치용 (lead 빈칸 포함 6주 고정)
const layout = useMemo(() => {
return months.map((m) => {
const lead = new Date(year, m, 1).getDay();
const total = new Date(year, m + 1, 0).getDate();
const slots: (Cell | null)[] = [];
for (let i = 0; i < 42; i++) {
const day = i - lead + 1;
slots.push(day >= 1 && day <= total ? cells.find((c) => c.month === m && c.day === day) ?? null : null);
}
return { m, slots };
});
}, [months, year, cells]);
let flat = 0; // 카드 안 칸 → 시간순 인덱스
const placed: Record<string, number> = {};
layout.forEach((card) => {
card.slots.forEach((c) => {
if (c) placed[c.month + "-" + c.day] = flat++;
});
});
return (
<section className={`${styles.m3} ${demoOn ? styles.demo : ""}`} style={vars} aria-label="Three-month calendar">
<div className={styles.strip} onKeyDown={onKeyDown}>
{layout.map((card, mi) => (
<div key={card.m} className={mi === 1 ? styles.cardMid : styles.cardSide}>
<span className={styles.label}>{year}.{card.m + 1}</span>
<div className={styles.grid} role="grid" aria-label={year + "." + (card.m + 1)}>
{[0, 1, 2, 3, 4, 5].map((r) => (
<div key={r} className={styles.row} role="row">
{[0, 1, 2, 3, 4, 5, 6].map((c) => {
const cell = card.slots[r * 7 + c];
if (!cell) return <div key={c} className={styles.blank} aria-hidden="true" />;
const idx = placed[cell.month + "-" + cell.day];
const on = cell.time >= start.getTime() && cell.time <= end;
return (
<div
key={c}
ref={(el: HTMLDivElement | null) => { if (cellRefs.current) cellRefs.current[idx] = el; }}
className={styles.cell}
role="gridcell"
aria-label={cell.iso}
aria-selected={on}
aria-current={cell.today ? "date" : undefined}
tabIndex={cur === idx ? 0 : -1}
onClick={() => { move(idx); paint(cell.time); }}
>
<span className={styles.num}>{cell.day}</span>
<span
className={`${styles.sel} ${cell.path ? styles["p" + cell.path] : ""} ${on ? styles.selOn : ""}`}
aria-hidden="true"
>
{cell.day}
</span>
</div>
);
})}
</div>
))}
</div>
</div>
))}
</div>
<span className={styles.live} role="status">{ann}</span>
</section>
);
}08 横並びのミニ3か月 — 計測 累積面積 1.369% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · カレンダー css デザイン 9選 — 実際に月が動く
このデモが属する記事
カレンダー css デザイン 9選 — 実際に月が動くカレンダー css デザイン というと、ブラウザが用意する小さな日付ポップアップの色を変えるところで終わりがちです。ここで作る9つは1か月をまるごと広げる月間グリッドそのもので、どれも本当に動きます。1つは月を送るときに2枚のグリッドが一緒 この記事のデモをすべて見る


