09Grid to agenda fold
On a narrow width the grid folds row by row into a day-by-day list, and the same cells unfold back into a grid when there is room.
Best forWhen a seven-column grid shrinks to 40px cells on a phone and neither text nor chips survive
@containerrole=listtranslateY

<main class="stage">
<section class="ag is-demo" id="ag" aria-label="September schedule">
<div class="ag__bar">
<span class="ag__title">2026. 9</span>
<span class="ag__toggl" role="group" aria-label="View switch">
<button type="button" class="ag__btn" id="ag-bgrid" aria-label="Grid view" 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="List view" 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="September schedule grid" data-lgrid="September schedule grid" data-llist="September schedule list" data-wgrid="Grid" data-wlist="List">
<div class="ag__grid" id="ag-grid">
<div class="ag__wd" aria-hidden="true"><span>S</span><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</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">Monday</span><span class="ag__chips"><span class="ag__chip">Review</span><span class="ag__chip">Meeting</span></span></div>
<div class="ag__item" role="listitem" tabindex="-1"><span class="ag__d">16</span><span class="ag__wdn">Wednesday</span><span class="ag__chips"><span class="ag__chip">Gym</span></span></div>
<div class="ag__item" role="listitem" tabindex="-1"><span class="ag__d">18</span><span class="ag__wdn">Friday</span><span class="ag__chips"><span class="ag__chip">Clinic</span></span></div>
<div class="ag__item" role="listitem" tabindex="-1"><span class="ag__d">21</span><span class="ag__wdn">Monday</span><span class="ag__chips"><span class="ag__chip">Retro</span></span></div>
<div class="ag__item" role="listitem" tabindex="-1"><span class="ag__d">23</span><span class="ag__wdn">Wednesday</span><span class="ag__chips"><span class="ag__chip">Deploy</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열 Grid, 좁으면 아젠다.
// @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 (T)
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 { // Grid 축 — ±1S / ±7S
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 Grid to agenda fold — Measured area 14.776% · moving frames 11/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


