08Mini three-month strip
Last, current and next month stand side by side as three shrunken grids, only the middle one in full contrast, with the range spanning all three.
Best forFor booking screens where check-in and check-out straddle two months and one grid is not enough
container-typearia-selectedclamp()

<main class="stage">
<section class="m3 is-demo" id="m3" aria-label="Three-month calendar">
<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;
}
}// 실제 동작: 지난달·이번달·다음달 미니 Grid 셋. 시작(8M 30S)은 고정,
// 화살표(±1S/±7S)는 달 경계를 넘어 이동하고 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); // 범위 시작 — 8M 말
var BASE_END = new Date(2026, 8, 28); // 루프가 멈춰 있는 기본 끝 — 9M 28S
var MS = [7, 8, 9]; // 8·9·10M
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(); // 일요일 시작 앞쪽 빈칸 W
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 Mini three-month strip — Measured area 1.369% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · 9 css calendar design patterns that really move
The post this demo lives in
9 css calendar design patterns that really movecss calendar design often stops at restyling the date picker the browser gives you. These nine build the whole month gri See all demos in this post


