09格子からアジェンダへ畳む切替
幅が狭いと格子が1行ずつ畳まれて日付順のリストになり、広がると同じマスが再び格子に開きます。
使いどころスマホで7列の格子が1マス40pxまで縮み、文字もチップも見えなくなるカレンダーを直すとき
@containerrole=listtranslateY

<main class="stage">
<section class="ag is-demo" id="ag" aria-label="9月の予定">
<div class="ag__bar">
<span class="ag__title">2026. 9</span>
<span class="ag__toggl" role="group" aria-label="表示切替">
<button type="button" class="ag__btn" id="ag-bgrid" aria-label="グリッド表示" aria-pressed="false"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M112 56v48a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8m88-8h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h48a8 8 0 0 0 8-8V56a8 8 0 0 0-8-8m-96 96H56a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8m96 0h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8" opacity=".2"/><path d="M200 136h-48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h48a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16m0 64h-48v-48h48zM104 40H56a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h48a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 64H56V56h48zm96-64h-48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h48a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 64h-48V56h48zm-96 32H56a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h48a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16m0 64H56v-48h48z"/></g></svg></button>
<button type="button" class="ag__btn" id="ag-blist" aria-label="リスト表示" aria-pressed="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M216 64v128H88V64Z" opacity=".2"/><path d="M80 64a8 8 0 0 1 8-8h128a8 8 0 0 1 0 16H88a8 8 0 0 1-8-8m136 56H88a8 8 0 1 0 0 16h128a8 8 0 0 0 0-16m0 64H88a8 8 0 1 0 0 16h128a8 8 0 0 0 0-16M44 52a12 12 0 1 0 12 12a12 12 0 0 0-12-12m0 64a12 12 0 1 0 12 12a12 12 0 0 0-12-12m0 64a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg></button>
<span class="ag__pill" aria-hidden="true"></span>
</span>
</div>
<div class="ag__frame" id="ag-frame">
<div class="ag__view" id="ag-view" role="grid" aria-label="9月の予定グリッド" data-lgrid="9月の予定グリッド" data-llist="9月の予定リスト" data-wgrid="グリッド" data-wlist="リスト">
<div class="ag__grid" id="ag-grid">
<div class="ag__wd" aria-hidden="true"><span>日</span><span>月</span><span>火</span><span>水</span><span>木</span><span>金</span><span>土</span></div>
<div class="ag__rows" id="ag-rows"></div>
</div>
<div class="ag__list" id="ag-list">
<div class="ag__item" role="listitem" tabindex="-1"><span class="ag__d">14</span><span class="ag__wdn">月曜日</span><span class="ag__chips"><span class="ag__chip">レビュー</span><span class="ag__chip">MTG</span></span></div>
<div class="ag__item" role="listitem" tabindex="-1"><span class="ag__d">16</span><span class="ag__wdn">水曜日</span><span class="ag__chips"><span class="ag__chip">運動</span></span></div>
<div class="ag__item" role="listitem" tabindex="-1"><span class="ag__d">18</span><span class="ag__wdn">金曜日</span><span class="ag__chips"><span class="ag__chip">通院</span></span></div>
<div class="ag__item" role="listitem" tabindex="-1"><span class="ag__d">21</span><span class="ag__wdn">月曜日</span><span class="ag__chips"><span class="ag__chip">ふり返り</span></span></div>
<div class="ag__item" role="listitem" tabindex="-1"><span class="ag__d">23</span><span class="ag__wdn">水曜日</span><span class="ag__chips"><span class="ag__chip">デプロイ</span></span></div>
</div>
</div>
</div>
<span class="ag__live" role="status" id="ag-live"></span>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@use "sass:color";
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue; // 잉크 무대 위 크림 글자 + 파란 포인트
@include stage($stage-ink);
.ag {
width: min(100%, 436px);
}
.ag__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
margin: 0 0 $sp-2;
}
.ag__title {
font-size: clamp(13px, 3vw, 16px);
line-height: 1;
font-weight: 800;
color: $subject-cream;
}
// 보기 표시 아이콘 — 활성 쪽만 진하다. 교체는 크로스페이드가 아니라 steps(1, end) 컷
.ag__toggl {
position: relative;
display: flex;
gap: $sp-1;
padding: $sp-1;
border: 1px solid rgba(255, 255, 255, .14);
background: rgba(255, 255, 255, .06);
border-radius: $r-control;
}
.ag__btn {
position: relative;
z-index: 1;
width: 28px;
height: 20px;
display: grid;
place-items: center;
padding: 0;
border: 0;
background: none;
border-radius: $r-xs;
color: $subject-cream;
opacity: .4;
cursor: pointer;
transition: opacity $duration steps(1, end), color $duration steps(1, end);
}
.ag__btn.is-on { opacity: 1; color: $color; }
.ag__btn:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
.ag__pill {
position: absolute;
top: $sp-1;
left: $sp-1;
width: 28px;
height: 20px;
border: 1px solid $color;
border-radius: $r-xs;
pointer-events: none;
transition: transform $duration steps(1, end);
}
.ag.is-agenda .ag__pill { transform: translateX(32px); }
// 칩: @container — 이 상자의 폭이 좁아지면 격자가 아젠다로 접힌다
.ag__frame {
container-type: inline-size;
position: relative;
box-sizing: border-box;
width: var(--fw, 100%);
height: clamp(132px, 58vh, 174px);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, .14);
background: rgba(255, 255, 255, .04);
border-radius: $r-card;
transition: width $dur-base $ease-spring;
}
.ag__view {
position: absolute;
inset: 0;
}
.ag__grid {
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
}
.ag__list {
display: none;
flex-direction: column;
height: 100%;
box-sizing: border-box;
margin: 0;
padding: $sp-2;
list-style: none;
}
@container (max-width: 176px) {
.ag__grid { display: none; }
.ag__list { display: flex; }
}
// ── 격자 보기 ──
.ag__wd {
display: grid;
grid-template-columns: repeat(7, 1fr);
padding: $sp-2 $sp-2 0;
color: color.mix($subject-cream, transparent, 72%); // 45% 는 4.21:1 이었다 — AA 4.5 (8.65:1)
font-size: clamp(8px, 1.8vw, 10px);
line-height: 1.2;
font-weight: 600;
text-align: center;
}
.ag__rows {
flex: 1 1 0;
min-height: 0;
display: grid;
grid-template-rows: repeat(5, 1fr);
padding: $sp-1 $sp-2 $sp-2;
}
.ag__row {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.ag__cell {
position: relative;
display: grid;
place-items: center;
border-radius: $r-xs;
font-size: clamp(10px, 2.4vw, 13px);
line-height: 1;
font-weight: 500;
color: color.mix($subject-cream, transparent, 82%);
cursor: pointer;
}
.ag__cell.is-today {
color: $subject-cream;
font-weight: 800;
box-shadow: inset 0 0 0 2px $color;
}
.ag__cell:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
.ag__dot {
position: absolute;
bottom: 2px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: $color;
}
// ── 아젠다 보기 — 접힐 때 한 줄씩 올라온다 (translateY) ──
.ag__item {
flex: 1 1 0;
min-height: 0;
display: flex;
align-items: center;
gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
animation-name: ag-rise;
animation-duration: $duration;
animation-timing-function: $easing;
animation-fill-mode: backwards;
}
// 줄 시차 — 낱개 속성 (animation 단축은 delay 를 0s 로 리셋한다)
.ag__item:nth-child(2) { animation-delay: 40ms; }
.ag__item:nth-child(3) { animation-delay: 80ms; }
.ag__item:nth-child(4) { animation-delay: 120ms; }
.ag__item:nth-child(5) { animation-delay: 160ms; }
@keyframes ag-rise {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.ag__item:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
.ag__d {
font-size: clamp(14px, 3.2vw, 18px);
line-height: 1;
font-weight: 800;
color: $subject-cream;
}
.ag__wdn {
min-width: 44px;
font-size: clamp(9px, 2vw, 11px);
line-height: 1;
font-weight: 600;
color: color.mix($subject-cream, transparent, 55%);
}
.ag__chips {
display: flex;
gap: $sp-1;
min-width: 0;
overflow: hidden;
}
.ag__chip {
display: inline-flex;
align-items: center;
gap: $sp-1;
height: 18px;
padding: 0 $sp-2;
border-radius: $r-pill;
background: color.mix($color, transparent, 24%);
color: $subject-cream;
font-size: clamp(8px, 1.9vw, 10px);
line-height: 1;
font-weight: 600;
white-space: nowrap;
}
.ag__chip::before {
content: "";
width: 4px;
height: 4px;
border-radius: 50%;
background: $color;
}
.ag__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
// ── 자동 루프: 컨테이너 폭을 오간다 — 아젠다 → 펴져 격자 → 다시 접힌다.
// 0% 와 100% 가 같은 폭(34%)이라 이어 붙여도 텔레포트가 없다.
.ag.is-demo .ag__frame {
animation-name: ag-fold;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
@keyframes ag-fold {
0%, 6% { width: 34%; }
24%, 54% { width: 100%; }
72%, 100% { width: 34%; }
}
@media (prefers-reduced-motion: reduce) {
.ag.is-demo .ag__frame { animation: none !important; }
.ag__item { animation: none !important; }
.ag__btn, .ag__pill, .ag__frame { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ag {
width: min(100%, 436px);
}
.ag__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0 0 8px;
}
.ag__title {
font-size: clamp(13px, 3vw, 16px);
line-height: 1;
font-weight: 800;
color: #fff7e6;
}
.ag__toggl {
position: relative;
display: flex;
gap: 4px;
padding: 4px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.06);
border-radius: 12px;
}
.ag__btn {
position: relative;
z-index: 1;
width: 28px;
height: 20px;
display: grid;
place-items: center;
padding: 0;
border: 0;
background: none;
border-radius: 4px;
color: #fff7e6;
opacity: 0.4;
cursor: pointer;
transition: opacity 220ms steps(1, end), color 220ms steps(1, end);
}
.ag__btn.is-on {
opacity: 1;
color: #2f6df6;
}
.ag__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.ag__pill {
position: absolute;
top: 4px;
left: 4px;
width: 28px;
height: 20px;
border: 1px solid #2f6df6;
border-radius: 4px;
pointer-events: none;
transition: transform 220ms steps(1, end);
}
.ag.is-agenda .ag__pill {
transform: translateX(32px);
}
.ag__frame {
container-type: inline-size;
position: relative;
box-sizing: border-box;
width: var(--fw, 100%);
height: clamp(132px, 58vh, 174px);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
border-radius: 16px;
transition: width 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ag__view {
position: absolute;
inset: 0;
}
.ag__grid {
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
}
.ag__list {
display: none;
flex-direction: column;
height: 100%;
box-sizing: border-box;
margin: 0;
padding: 8px;
list-style: none;
}
@container (max-width: 176px) {
.ag__grid {
display: none;
}
.ag__list {
display: flex;
}
}
.ag__wd {
display: grid;
grid-template-columns: repeat(7, 1fr);
padding: 8px 8px 0;
color: rgba(255, 247, 230, 0.72);
font-size: clamp(8px, 1.8vw, 10px);
line-height: 1.2;
font-weight: 600;
text-align: center;
}
.ag__rows {
flex: 1 1 0;
min-height: 0;
display: grid;
grid-template-rows: repeat(5, 1fr);
padding: 4px 8px 8px;
}
.ag__row {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.ag__cell {
position: relative;
display: grid;
place-items: center;
border-radius: 4px;
font-size: clamp(10px, 2.4vw, 13px);
line-height: 1;
font-weight: 500;
color: rgba(255, 247, 230, 0.82);
cursor: pointer;
}
.ag__cell.is-today {
color: #fff7e6;
font-weight: 800;
box-shadow: inset 0 0 0 2px #2f6df6;
}
.ag__cell:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.ag__dot {
position: absolute;
bottom: 2px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: #2f6df6;
}
.ag__item {
flex: 1 1 0;
min-height: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
animation-name: ag-rise;
animation-duration: 220ms;
animation-timing-function: ease-out;
animation-fill-mode: backwards;
}
.ag__item:nth-child(2) {
animation-delay: 40ms;
}
.ag__item:nth-child(3) {
animation-delay: 80ms;
}
.ag__item:nth-child(4) {
animation-delay: 120ms;
}
.ag__item:nth-child(5) {
animation-delay: 160ms;
}
@keyframes ag-rise {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ag__item:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.ag__d {
font-size: clamp(14px, 3.2vw, 18px);
line-height: 1;
font-weight: 800;
color: #fff7e6;
}
.ag__wdn {
min-width: 44px;
font-size: clamp(9px, 2vw, 11px);
line-height: 1;
font-weight: 600;
color: rgba(255, 247, 230, 0.55);
}
.ag__chips {
display: flex;
gap: 4px;
min-width: 0;
overflow: hidden;
}
.ag__chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 18px;
padding: 0 8px;
border-radius: 999px;
background: rgba(47, 109, 246, 0.24);
color: #fff7e6;
font-size: clamp(8px, 1.9vw, 10px);
line-height: 1;
font-weight: 600;
white-space: nowrap;
}
.ag__chip::before {
content: "";
width: 4px;
height: 4px;
border-radius: 50%;
background: #2f6df6;
}
.ag__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.ag.is-demo .ag__frame {
animation-name: ag-fold;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes ag-fold {
0%, 6% {
width: 34%;
}
24%, 54% {
width: 100%;
}
72%, 100% {
width: 34%;
}
}
@media (prefers-reduced-motion: reduce) {
.ag.is-demo .ag__frame {
animation: none !important;
}
.ag__item {
animation: none !important;
}
.ag__btn, .ag__pill, .ag__frame {
transition: none;
}
}// 실제 동작: 같은 날짜 데이터가 넓으면 7열 グリッド, 좁으면 아젠다.
// @container 가 보기를 고르고 ResizeObserver 가 role(grid/list)·아이콘·포커스를 실제로 바꾼다.
var root = document.getElementById('ag');
var frame = document.getElementById('ag-frame');
var view = document.getElementById('ag-view');
var rowsBox = document.getElementById('ag-rows');
var list = document.getElementById('ag-list');
var live = document.getElementById('ag-live');
var bgrid = document.getElementById('ag-bgrid');
var blist = document.getElementById('ag-blist');
var EVENTS = { 14: 1, 16: 1, 18: 1, 21: 1, 23: 1 };
var TODAY = 12;
var cells = [];
var lead = new Date(2026, 8, 1).getDay(); // 2 (火)
for (var r = 0; r < 5; r++) {
var row = document.createElement('div');
row.className = 'ag__row';
row.setAttribute('role', 'row');
for (var c = 0; c < 7; c++) {
var day = r * 7 + c - lead + 1;
var cell = document.createElement('span');
cell.className = 'ag__cell';
if (day < 1 || day > 30) {
cell.setAttribute('aria-hidden', 'true');
} else {
cell.setAttribute('role', 'gridcell');
cell.setAttribute('aria-label', '2026-09-' + (day < 10 ? '0' + day : day));
cell.tabIndex = -1;
cell.textContent = day;
if (EVENTS[day]) {
var dot = document.createElement('i');
dot.className = 'ag__dot';
cell.appendChild(dot);
}
if (day === TODAY) { cell.classList.add('is-today'); cell.setAttribute('aria-current', 'date'); }
cells.push(cell);
}
row.appendChild(cell);
}
rowsBox.appendChild(row);
}
var items = [].slice.call(list.children);
cells[0].tabIndex = 0;
items[0].tabIndex = 0;
var isAgenda = null;
function focusFirst() {
var pool = isAgenda ? items : cells;
pool.forEach(function (x) { x.tabIndex = -1; });
pool[0].tabIndex = 0;
pool[0].focus({ preventScroll: true });
}
function setMode(narrow) {
if (narrow === isAgenda) return;
isAgenda = narrow;
root.classList.toggle('is-agenda', narrow);
view.setAttribute('role', narrow ? 'list' : 'grid');
view.setAttribute('aria-label', narrow ? view.dataset.llist : view.dataset.lgrid);
bgrid.classList.toggle('is-on', !narrow);
blist.classList.toggle('is-on', narrow);
bgrid.setAttribute('aria-pressed', String(!narrow));
blist.setAttribute('aria-pressed', String(narrow));
live.textContent = narrow ? view.dataset.wlist : view.dataset.wgrid;
var a = document.activeElement;
if (!a || a === document.body || !a.offsetParent) focusFirst(); // 숨겨진 칸에 있으면 새 보기 첫 칸으로
}
new ResizeObserver(function () { setMode(frame.getBoundingClientRect().width <= 176); }).observe(frame);
function pick(narrow, el) { // 아이콘 버튼 — 폭을 직접 오긴다 (전환이 눈에 보인다)
root.classList.remove('is-demo');
frame.style.setProperty('--fw', narrow ? '34%' : '100%');
el.focus({ preventScroll: true });
}
bgrid.addEventListener('click', function () { pick(false, bgrid); });
blist.addEventListener('click', function () { pick(true, blist); });
var cur = 0, curI = 0;
root.addEventListener('keydown', function (e) {
var k = e.key, n = null;
root.classList.remove('is-demo');
if (isAgenda) { // 리스트 축 — ±1
if (k === 'ArrowDown') n = Math.min(curI + 1, items.length - 1);
else if (k === 'ArrowUp') n = Math.max(curI - 1, 0);
else if (k === 'Home') n = 0;
else if (k === 'End') n = items.length - 1;
if (n !== null && n !== curI) {
e.preventDefault();
items[curI].tabIndex = -1; curI = n; items[curI].tabIndex = 0;
items[curI].focus({ preventScroll: true });
live.textContent = '9.' + items[curI].querySelector('.ag__d').textContent;
}
} else { // グリッド 축 — ±1日 / ±7日
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;
if (n !== null && n !== cur) {
e.preventDefault();
cells[cur].tabIndex = -1; cur = n; cells[cur].tabIndex = 0;
cells[cur].focus({ preventScroll: true });
live.textContent = cells[cur].getAttribute('aria-label');
}
}
});
rowsBox.addEventListener('click', function (e) {
var t = e.target.closest ? e.target.closest('.ag__cell') : null;
if (!t) return;
root.classList.remove('is-demo');
for (var i = 0; i < cells.length; i++) if (cells[i] === t) {
cells[cur].tabIndex = -1; cur = i; cells[cur].tabIndex = 0;
cells[cur].focus({ preventScroll: true });
break;
}
});
list.addEventListener('click', function (e) {
var t = e.target.closest ? e.target.closest('.ag__item') : null;
if (!t) return;
root.classList.remove('is-demo');
for (var i = 0; i < items.length; i++) if (items[i] === t) {
items[curI].tabIndex = -1; curI = i; items[curI].tabIndex = 0;
items[curI].focus({ preventScroll: true });
break;
}
});import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AgendaSwitchCalendar.module.scss";
export type AgendaSwitchCalendarProps = {
demo?: boolean;
duration?: number; // 전환(폭)·줄 올라옴 길이 ms
easing?: string;
color?: string; // 포인트 색 (아이콘·칩·오늘 링)
rows?: { day: number; weekday: string; chips: string[] }[];
};
type KeyEvt = { key: string; preventDefault: () => void };
type PtrEvt = { currentTarget: HTMLElement };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const DEFAULT_ROWS = [
{ day: 14, weekday: "월요일", chips: ["리뷰", "미팅"] },
{ day: 16, weekday: "수요일", chips: ["운동"] },
{ day: 18, weekday: "금요일", chips: ["병원"] },
{ day: 21, weekday: "월요일", chips: ["회고"] },
{ day: 23, weekday: "수요일", chips: ["배포"] },
];
function pad(n: number): string {
return n < 10 ? "0" + n : "" + n;
}
export default function AgendaSwitchCalendar({
demo = true,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
rows = DEFAULT_ROWS,
}: AgendaSwitchCalendarProps) {
const [demoOn, setDemoOn] = useState(demo);
const [agenda, setAgenda] = useState(false); // @container 결과를 따라간다
const [fw, setFw] = useState("100%");
const [cur, setCur] = useState(0);
const [curI, setCurI] = useState(0);
const [ann, setAnn] = useState("");
const frameRef = useRef<HTMLDivElement>(null);
const cellRefs = useRef<(HTMLSpanElement | null)[]>([]);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const days = useMemo(() => {
const eventDays: Record<number, number> = {};
rows.forEach((r) => { eventDays[r.day] = r.chips.length; });
const out: { day: number; label: string; events: number }[] = [];
const lead = new Date(2026, 8, 1).getDay(); // 9월 1일 = 화요일
const total = 30;
for (let i = 0; i < 35; i++) {
const day = i - lead + 1;
if (day < 1 || day > total) continue;
out.push({ day, label: "2026-09-" + pad(day), events: eventDays[day] || 0 });
}
return out;
}, [rows]);
// @container (max-width: 176px) 와 같은 조건을 ResizeObserver 로 — role 도 실제로 바꾼다
useEffect(() => {
const frame = frameRef.current;
if (!frame || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => {
setAgenda(frame.getBoundingClientRect().width <= 176);
});
ro.observe(frame);
return () => ro.disconnect();
}, []);
// 보기가 바뀌어 포커스가 숨겨진 칸에 남으면 새 보기의 첫 칸으로
useEffect(() => {
const a = document.activeElement;
if (!a || a === document.body || !(a as HTMLElement).offsetParent) {
const pool = (agenda ? itemRefs.current : cellRefs.current) || [];
const first = pool[0];
if (first) first.focus({ preventScroll: true });
}
}, [agenda]);
function moveCell(i: number) {
setDemoOn(false);
setCur(i);
const el = cellRefs.current ? cellRefs.current[i] : null;
if (el) el.focus({ preventScroll: true });
setAnn(days[i].label);
}
function moveItem(i: number) {
setDemoOn(false);
setCurI(i);
const el = itemRefs.current ? itemRefs.current[i] : null;
if (el) el.focus({ preventScroll: true });
setAnn("9." + rows[i].day);
}
function pick(narrow: boolean) {
setDemoOn(false);
setFw(narrow ? "34%" : "100%"); // 폭을 직접 오긴다 — 전환이 눈에 보인다
}
function onKey(e: KeyEvt) {
setDemoOn(false);
let n: number | null = null;
if (agenda) { // 리스트 축 — ±1
if (e.key === "ArrowDown") n = Math.min(curI + 1, rows.length - 1);
else if (e.key === "ArrowUp") n = Math.max(curI - 1, 0);
else if (e.key === "Home") n = 0;
else if (e.key === "End") n = rows.length - 1;
if (n !== null && n !== curI) { e.preventDefault(); moveItem(n); }
} else { // 격자 축 — ±1일 / ±7일
if (e.key === "ArrowRight") n = Math.min(cur + 1, days.length - 1);
else if (e.key === "ArrowLeft") n = Math.max(cur - 1, 0);
else if (e.key === "ArrowDown") n = Math.min(cur + 7, days.length - 1);
else if (e.key === "ArrowUp") n = Math.max(cur - 7, 0);
else if (e.key === "Home") n = 0;
else if (e.key === "End") n = days.length - 1;
if (n !== null && n !== cur) { e.preventDefault(); moveCell(n); }
}
}
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<section
className={`${styles.ag} ${demoOn ? styles.demo : ""} ${agenda ? styles.isAgenda : ""}`}
style={vars}
aria-label="9월 일정"
onKeyDown={onKey}
onPointerDown={() => setDemoOn(false)}
>
<div className={styles.bar}>
<span className={styles.title}>2026. 9</span>
<span className={styles.toggl} role="group" aria-label="보기 전환">
<button
type="button"
className={`${styles.btn} ${!agenda ? styles.on : ""}`}
aria-label="격자 보기"
aria-pressed={!agenda}
onClick={() => pick(false)}
>
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M112 56v48a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8m88-8h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h48a8 8 0 0 0 8-8V56a8 8 0 0 0-8-8m-96 96H56a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8m96 0h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8" opacity=".2"/><path d="M200 136h-48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h48a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16m0 64h-48v-48h48zM104 40H56a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h48a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 64H56V56h48zm96-64h-48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h48a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 64h-48V56h48zm-96 32H56a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h48a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16m0 64H56v-48h48z"/></g></svg>
</button>
<button
type="button"
className={`${styles.btn} ${agenda ? styles.on : ""}`}
aria-label="목록 보기"
aria-pressed={agenda}
onClick={() => pick(true)}
>
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M216 64v128H88V64Z" opacity=".2"/><path d="M80 64a8 8 0 0 1 8-8h128a8 8 0 0 1 0 16H88a8 8 0 0 1-8-8m136 56H88a8 8 0 1 0 0 16h128a8 8 0 0 0 0-16m0 64H88a8 8 0 1 0 0 16h128a8 8 0 0 0 0-16M44 52a12 12 0 1 0 12 12a12 12 0 0 0-12-12m0 64a12 12 0 1 0 12 12a12 12 0 0 0-12-12m0 64a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg>
</button>
<span className={styles.pill} aria-hidden="true" />
</span>
</div>
<div className={styles.frame} ref={frameRef} style={{ width: fw }}>
{/* role 는 보기에 따라 실제로 바뀐다 — grid ↔ list */}
<div
className={styles.view}
role={agenda ? "list" : "grid"}
aria-label={agenda ? "9월 일정 목록" : "9월 일정 격자"}
>
<div className={styles.grid}>
<div className={styles.wd} aria-hidden="true">
{["일", "월", "화", "수", "목", "금", "토"].map((w, i) => <span key={i}>{w}</span>)}
</div>
<div className={styles.rows}>
{[0, 1, 2, 3, 4].map((r) => (
<div key={r} className={styles.row} role="row">
{[0, 1, 2, 3, 4, 5, 6].map((c) => {
const i = r * 7 + c - 2; // 9월 1일 = 화요일(lead 2)
const d = i >= 0 && i < days.length ? days[i] : null;
if (!d) return <span key={c} aria-hidden="true" />;
const idx = days.indexOf(d);
return (
<span
key={c}
ref={(el: HTMLSpanElement | null) => { if (cellRefs.current) cellRefs.current[idx] = el; }}
className={`${styles.cell} ${d.day === 12 ? styles.today : ""}`}
role="gridcell"
aria-label={d.label}
aria-current={d.day === 12 ? "date" : undefined}
tabIndex={cur === idx ? 0 : -1}
onClick={() => moveCell(idx)}
>
{d.day}
{d.events > 0 && <i className={styles.dot} aria-hidden="true" />}
</span>
);
})}
</div>
))}
</div>
</div>
<div className={styles.list}>
{rows.map((r, i) => (
<div
key={r.day}
ref={(el: HTMLDivElement | null) => { if (itemRefs.current) itemRefs.current[i] = el; }}
className={styles.item}
role="listitem"
tabIndex={curI === i ? 0 : -1}
style={{ animationDelay: i * 40 + "ms" }}
onClick={(e: PtrEvt) => { void e; moveItem(i); }}
>
<span className={styles.d}>{r.day}</span>
<span className={styles.wdn}>{r.weekday}</span>
<span className={styles.chips}>
{r.chips.map((chip) => (
<span key={chip} className={styles.chip}>{chip}</span>
))}
</span>
</div>
))}
</div>
</div>
</div>
<span className={styles.live} role="status">{ann}</span>
</section>
);
}09 格子からアジェンダへ畳む切替 — 計測 累積面積 14.776% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · カレンダー css デザイン 9選 — 実際に月が動く
このデモが属する記事
カレンダー css デザイン 9選 — 実際に月が動くカレンダー css デザイン というと、ブラウザが用意する小さな日付ポップアップの色を変えるところで終わりがちです。ここで作る9つは1か月をまるごと広げる月間グリッドそのもので、どれも本当に動きます。1つは月を送るときに2枚のグリッドが一緒 この記事のデモをすべて見る


