css 달력 만들기 9종 — 진짜 넘어가는 월간 캘린더
css 달력 만들기는 브라우저가 주는 조그만 날짜 고르기 창 대신, 한 달을 통째로 펼쳐 놓는 격자를 직접 짜는 일입니다. 아홉 개 중 하나는 달을 넘길 때 두 장의 격자가 나란히 미끄러지고, 또 하나는 1년을 네모 365개로 깔아 꾸준함을 색으로 보여 줍니다.
01 calendar-month-slide-transition
02 calendar-today-ring-badge
03 calendar-event-chips-overflow
04 calendar-range-drag-highlight
05 calendar-week-timeline-view
06 calendar-drag-drop-event-move
07 calendar-contribution-heatmap
08 calendar-mini-three-month
09 calendar-mobile-agenda-switch
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 두 트랙으로 넘어가는 월 이동
- 02 오늘 링과 숫자 배지
- 03 이벤트 칩과 더보기 +N
- 04 드래그로 칠하는 기간 선택
- 05 시간 축 위의 주간 타임라인
- 06 집어서 옮기는 일정 카드
- 07 진하기로 읽는 히트맵 달력
- 08 나란한 미니 3개월
- 09 격자에서 아젠다로 접히는 전환
아홉 개는 인기 순서가 아니라 달력이 화면에서 실제로 겪는 일 순서대로 놓았습니다. 먼저 달이 넘어가고(01), 오늘이 어디인지 한눈에 보이고(02), 일정이 하루에 쌓이고(03), 며칠씩 한 번에 잡히고(04), 시간이 겹치는 것이 드러나고(05), 일정이 집혀서 옮겨지고(06), 1년이 한 장으로 펼쳐지고(07), 범위가 달을 건너 이어지고(08), 폭이 좁아지면 아젠다로 접힙니다(09). 아홉 개 css 달력 전부 role="grid"를 달고 있고 화살표 키가 칸 사이를 실제로 옮기며, 날짜를 고르는 다섯 개는 aria-selected로 고른 칸까지 알립니다. 선택이 하루가 아니라 구간이면 css 셀렉트박스 디자인의 드롭다운이 앞에 오고, 지도 위의 점을 찍는 선택은 지도 ui 디자인로 갈라집니다.
01두 트랙으로 넘어가는 월 이동
css 캘린더는 무엇이 보이나
화살표를 누르면 나가는 달과 들어오는 달이 날짜 격자 트랙과 달 이름 띠에 실려 한 몸처럼 미끄러집니다. 숫자만 갈아 끼우면 어느 방향으로 갔는지 알 수 없어서, 트랙을 300%로 넓게 깔고 이번 달을 중앙에 둔 뒤 좌우로 밀어 넘깁니다.

<main class="stage">
<div class="cal is-demo">
<span class="cal__sr" id="cal-live" aria-live="polite">2026. 9</span>
<div class="cal__bar">
<button class="cal__nav" type="button" id="cal-prev" aria-label="이전 달"><svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M160 48v160l-80-80Z" opacity=".2"/><path d="M163.06 40.61a8 8 0 0 0-8.72 1.73l-80 80a8 8 0 0 0 0 11.32l80 80A8 8 0 0 0 168 208V48a8 8 0 0 0-4.94-7.39M152 188.69L91.31 128L152 67.31Z"/></g></svg></button>
<div class="cal__titles" aria-hidden="true"><div class="cal__titlestrip" id="cal-strip"></div></div>
<button class="cal__nav" type="button" id="cal-next" aria-label="다음 달"><svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg></button>
</div>
<div class="cal__dows" aria-hidden="true"><span>일</span><span>월</span><span>화</span><span>수</span><span>목</span><span>금</span><span>토</span></div>
<div class="cal__viewport"><div class="cal__track" id="cal-track"></div></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.cal {
position: relative;
box-sizing: border-box;
width: min(288px, 88vw);
color: $subject-cream;
}
.cal__sr {
position: absolute; width: 1px; height: 1px;
overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
// ── 상단 바: 화살표 두 개 + 미끄러지는 월 제목 트랙 ──
.cal__bar {
display: flex; align-items: center; gap: $sp-2;
margin: 0 0 $sp-2;
}
.cal__nav {
flex: 0 0 auto;
box-sizing: border-box;
width: clamp(22px, 6.5vw, 28px); height: clamp(22px, 6.5vw, 28px);
display: inline-flex; align-items: center; justify-content: center;
padding: 0;
border: 1px solid rgba(255, 255, 255, .16);
background: rgba(255, 255, 255, .07);
border-radius: $r-control;
color: $subject-cream;
cursor: pointer;
transition: background $duration $easing, border-color $duration $easing;
}
.cal__nav:hover { background: rgba(255, 255, 255, .14); }
.cal__nav:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.cal__titles { flex: 1 1 auto; min-width: 0; overflow: hidden; }
.cal__titlestrip {
display: flex; width: 300%;
transform: translateX(-33.3333%);
}
.cal__t {
flex: 0 0 33.3333%;
text-align: center;
font-size: clamp(12px, 3.2vw, 15px);
line-height: 1.3;
font-weight: 700;
letter-spacing: .02em;
}
// ── 요일 머리 (달과 함께 미끄러지지 않는다) ──
.cal__dows {
display: grid; grid-template-columns: repeat(7, 1fr); gap: $sp-1;
margin: 0 0 $sp-1;
font-size: clamp(8px, 2.2vw, 10px);
line-height: 1.2;
font-weight: 700;
color: rgba(255, 255, 255, .45);
text-align: center;
}
// ── 두 트랙: 나가는 달과 들어오는 달이 동시에 존재한다 ──
.cal__viewport { overflow: hidden; border-radius: $r-sm; }
.cal__track {
display: flex; width: 300%;
transform: translateX(-33.3333%);
}
.cal.is-anim .cal__track,
.cal.is-anim .cal__titlestrip { transition: transform $duration $easing; }
.cal__month {
flex: 0 0 33.3333%;
display: grid; gap: $sp-1;
}
.cal__row { display: grid; grid-template-columns: repeat(7, 1fr); gap: $sp-1; }
.cal__cell {
height: clamp(20px, 6vw, 28px);
display: grid; place-items: center;
background: rgba(255, 255, 255, .08);
border-radius: $r-xs;
font-size: clamp(9px, 2.6vw, 12px);
line-height: 1;
font-weight: 600;
color: $subject-cream;
cursor: pointer;
}
.cal__cell.is-out { background: rgba(255, 255, 255, .03); color: rgba(255, 255, 255, .72); } // .28 은 2.51:1 이었다 — AA 4.5 (9.75:1)
.cal__cell[aria-selected="true"] { background: $color; color: $subject-cream; }
.cal__cell:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
// ── 자동 루프: 가운데 → 다음 달 → 가운데 → 이전 달 → 가운데.
// 0% 와 100% 가 같은 위치라 끝 프레임이 첫 프레임으로 이어진다 (텔레포트 없음) ──
.cal.is-demo .cal__track,
.cal.is-demo .cal__titlestrip {
animation-name: cal-slide;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cal-slide {
0% { transform: translateX(-33.3333%); } // 이번 달
20% { transform: translateX(-66.6667%); } // 다음 달로
33% { transform: translateX(-66.6667%); }
50% { transform: translateX(-33.3333%); } // 되돌아온다
58% { transform: translateX(-33.3333%); }
75% { transform: translateX(0); } // 이전 달로
85% { transform: translateX(0); }
100% { transform: translateX(-33.3333%); } // 첫 프레임과 같은 자리 — 텔레포트 없음
}
@media (prefers-reduced-motion: reduce) {
.cal.is-demo .cal__track,
.cal.is-demo .cal__titlestrip { animation: none !important; }
.cal.is-anim .cal__track,
.cal.is-anim .cal__titlestrip,
.cal__nav { 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);
}
.cal {
position: relative;
box-sizing: border-box;
width: min(288px, 88vw);
color: #fff7e6;
}
.cal__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.cal__bar {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px;
}
.cal__nav {
flex: 0 0 auto;
box-sizing: border-box;
width: clamp(22px, 6.5vw, 28px);
height: clamp(22px, 6.5vw, 28px);
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.07);
border-radius: 12px;
color: #fff7e6;
cursor: pointer;
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cal__nav:hover {
background: rgba(255, 255, 255, 0.14);
}
.cal__nav:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.cal__titles {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
.cal__titlestrip {
display: flex;
width: 300%;
transform: translateX(-33.3333%);
}
.cal__t {
flex: 0 0 33.3333%;
text-align: center;
font-size: clamp(12px, 3.2vw, 15px);
line-height: 1.3;
font-weight: 700;
letter-spacing: 0.02em;
}
.cal__dows {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin: 0 0 4px;
font-size: clamp(8px, 2.2vw, 10px);
line-height: 1.2;
font-weight: 700;
color: rgba(255, 255, 255, 0.45);
text-align: center;
}
.cal__viewport {
overflow: hidden;
border-radius: 8px;
}
.cal__track {
display: flex;
width: 300%;
transform: translateX(-33.3333%);
}
.cal.is-anim .cal__track,
.cal.is-anim .cal__titlestrip {
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cal__month {
flex: 0 0 33.3333%;
display: grid;
gap: 4px;
}
.cal__row {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.cal__cell {
height: clamp(20px, 6vw, 28px);
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.08);
border-radius: 4px;
font-size: clamp(9px, 2.6vw, 12px);
line-height: 1;
font-weight: 600;
color: #fff7e6;
cursor: pointer;
}
.cal__cell.is-out {
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.72);
}
.cal__cell[aria-selected=true] {
background: #2f6df6;
color: #fff7e6;
}
.cal__cell:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.cal.is-demo .cal__track,
.cal.is-demo .cal__titlestrip {
animation-name: cal-slide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes cal-slide {
0% {
transform: translateX(-33.3333%);
}
20% {
transform: translateX(-66.6667%);
}
33% {
transform: translateX(-66.6667%);
}
50% {
transform: translateX(-33.3333%);
}
58% {
transform: translateX(-33.3333%);
}
75% {
transform: translateX(0);
}
85% {
transform: translateX(0);
}
100% {
transform: translateX(-33.3333%);
}
}
@media (prefers-reduced-motion: reduce) {
.cal.is-demo .cal__track,
.cal.is-demo .cal__titlestrip {
animation: none !important;
}
.cal.is-anim .cal__track,
.cal.is-anim .cal__titlestrip,
.cal__nav {
transition: none;
}
}(function () {
var cal = document.querySelector('.cal');
var track = document.getElementById('cal-track');
var strip = document.getElementById('cal-strip');
var live = document.getElementById('cal-live');
var BASE = -100 / 3, busy = false, idx = 16;
var cur = new Date(2026, 8, 1);
function pad(n) { return (n < 10 ? '0' : '') + n; }
function label(d) { return d.getFullYear() + '. ' + (d.getMonth() + 1); }
function cells() {
return [].slice.call(track.children[1].children).reduce(function (a, r) { return a.concat([].slice.call(r.children)); }, []);
}
function build(d) {
var y = d.getFullYear(), m = d.getMonth(), off = 1 - new Date(y, m, 1).getDay();
var g = document.createElement('div');
g.className = 'cal__month'; g.setAttribute('role', 'grid'); g.setAttribute('aria-label', label(d));
for (var w = 0; w < 5; w++) {
var r = document.createElement('div');
r.className = 'cal__row'; r.setAttribute('role', 'row');
for (var i = 0; i < 7; i++) {
var t = new Date(y, m, off + w * 7 + i), c = document.createElement('div');
c.className = 'cal__cell' + (t.getMonth() === m ? '' : ' is-out');
c.setAttribute('role', 'gridcell');
c.setAttribute('aria-selected', 'false');
c.setAttribute('aria-label', t.getFullYear() + '-' + pad(t.getMonth() + 1) + '-' + pad(t.getDate()));
c.tabIndex = -1; c.textContent = t.getDate();
r.appendChild(c);
}
g.appendChild(r);
}
return g;
}
function render() {
track.textContent = ''; strip.textContent = '';
[-1, 0, 1].forEach(function (k) {
var d = new Date(cur.getFullYear(), cur.getMonth() + k, 1);
track.appendChild(build(d));
var s = document.createElement('span');
s.className = 'cal__t'; s.textContent = label(d);
strip.appendChild(s);
});
cells()[idx].tabIndex = 0;
}
function move(n) {
var cs = cells(); n = Math.max(0, Math.min(cs.length - 1, n));
cs.forEach(function (c, k) { c.tabIndex = k === n ? 0 : -1; c.setAttribute('aria-selected', String(k === n)); });
cs[n].focus({ preventScroll: true }); idx = n;
}
function finish(dir) {
if (!busy) return;
busy = false; cal.classList.remove('is-anim');
cur = new Date(cur.getFullYear(), cur.getMonth() + dir, 1);
render(); // 리셋은 transition 이 꺼진 순간에 — 눈에 보이지 않는다
track.style.transform = ''; strip.style.transform = '';
live.textContent = label(cur);
}
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);
}
document.getElementById('cal-prev').addEventListener('click', function () { go(-1); });
document.getElementById('cal-next').addEventListener('click', function () { go(1); });
track.addEventListener('click', function (e) {
var c = e.target.closest ? e.target.closest('.cal__cell') : null;
var n = c ? cells().indexOf(c) : -1;
if (n < 0) return;
cal.classList.remove('is-demo'); move(n);
});
track.addEventListener('keydown', function (e) {
var n = cells().indexOf(document.activeElement);
if (n < 0) return;
var to = null;
if (e.key === 'ArrowRight') to = n + 1;
else if (e.key === 'ArrowLeft') to = n - 1;
else if (e.key === 'ArrowDown') to = n + 7;
else if (e.key === 'ArrowUp') to = n - 7;
else if (e.key === 'Home') to = n - (n % 7);
else if (e.key === 'End') to = n - (n % 7) + 6;
else return;
e.preventDefault(); cal.classList.remove('is-demo'); move(to);
});
render();
})();import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MonthSlideCalendar.module.scss";
export type MonthSlideCalendarProps = {
/** 시작 달 (기본: 오늘) */
initialMonth?: Date;
/** 슬라이드 시간(ms) */
duration?: number;
easing?: string;
color?: string;
dayNames?: string[];
prevLabel?: string;
nextLabel?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
const BASE = -100 / 3;
function pad(n: number) {
return (n < 10 ? "0" : "") + n;
}
function label(d: Date) {
return d.getFullYear() + ". " + (d.getMonth() + 1);
}
/** 한 달을 5주 × 7일 = 35칸으로. 6행은 좁은 폭에서 넘친다 */
function monthCells(d: Date) {
const y = d.getFullYear();
const m = d.getMonth();
const off = 1 - new Date(y, m, 1).getDay();
const out: { date: Date; outside: boolean }[] = [];
for (let i = 0; i < 35; i++) {
const t = new Date(y, m, off + i);
out.push({ date: t, outside: t.getMonth() !== m });
}
return out;
}
export default function MonthSlideCalendar({
initialMonth,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
dayNames = ["S", "M", "T", "W", "T", "F", "S"],
prevLabel = "Previous month",
nextLabel = "Next month",
}: MonthSlideCalendarProps) {
const first = initialMonth ?? new Date();
const [cur, setCur] = useState(new Date(first.getFullYear(), first.getMonth(), 1));
const [shift, setShift] = useState(0); // -1 · 0 · +1 트랙 오프셋
const [animating, setAnimating] = useState(false);
const [focused, setFocused] = useState(16);
const busy = useRef(false);
const cellRefs = useRef<(HTMLDivElement | null)[]>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const months = [-1, 0, 1].map((k) => new Date(cur.getFullYear(), cur.getMonth() + k, 1));
function go(dir: number) {
if (busy.current) return;
busy.current = true;
setAnimating(true);
setShift(dir);
window.setTimeout(() => {
// 전환이 끝나면 들어온 달이 유일한 트랙이 된다. 리셋은 transition 이 꺼진 프레임에
setAnimating(false);
setShift(0);
setCur((c) => new Date(c.getFullYear(), c.getMonth() + dir, 1));
busy.current = false;
}, duration);
}
function setCell(n: number) {
return (el: HTMLDivElement | null) => {
const a = cellRefs.current;
if (a) a[n] = el;
};
}
function move(n: number) {
const to = Math.max(0, Math.min(34, n));
setFocused(to);
cellRefs.current?.[to]?.focus({ preventScroll: true });
}
function onKeyDown(e: KeyEvt, n: number) {
let to: number | null = null;
if (e.key === "ArrowRight") to = n + 1;
else if (e.key === "ArrowLeft") to = n - 1;
else if (e.key === "ArrowDown") to = n + 7;
else if (e.key === "ArrowUp") to = n - 7;
else if (e.key === "Home") to = n - (n % 7);
else if (e.key === "End") to = n - (n % 7) + 6;
if (to === null) return;
e.preventDefault();
move(to);
}
const offset = `translateX(${BASE - shift * (100 / 3)}%)`;
const trackStyle: CSSProperties = { transform: offset };
return (
<div className={styles.cal} style={vars}>
<span className={styles.sr} aria-live="polite">{label(cur)}</span>
<div className={styles.bar}>
<button type="button" className={styles.nav} aria-label={prevLabel} onClick={() => go(-1)}>
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M160 48v160l-80-80Z" opacity=".2" />
<path d="M163.06 40.61a8 8 0 0 0-8.72 1.73l-80 80a8 8 0 0 0 0 11.32l80 80A8 8 0 0 0 168 208V48a8 8 0 0 0-4.94-7.39M152 188.69L91.31 128L152 67.31Z" />
</g>
</svg>
</button>
<div className={styles.titles} aria-hidden="true">
<div className={`${styles.titlestrip} ${animating ? styles.isAnim : ""}`} style={trackStyle}>
{months.map((m) => (
<span key={m.getTime()} className={styles.t}>{label(m)}</span>
))}
</div>
</div>
<button type="button" className={styles.nav} aria-label={nextLabel} onClick={() => go(1)}>
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="m176 128l-80 80V48Z" opacity=".2" />
<path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z" />
</g>
</svg>
</button>
</div>
<div className={styles.dows} aria-hidden="true">
{dayNames.map((d, i) => <span key={`${d}-${i}`}>{d}</span>)}
</div>
<div className={styles.viewport}>
<div className={`${styles.track} ${animating ? styles.isAnim : ""}`} style={trackStyle}>
{months.map((m, mi) => (
<div key={m.getTime()} className={styles.month} role="grid" aria-label={label(m)}>
{[0, 1, 2, 3, 4].map((w) => (
<div key={w} className={styles.row} role="row">
{monthCells(m).slice(w * 7, w * 7 + 7).map((c, i) => {
const n = w * 7 + i;
const live = mi === 1; // 가운데(현재) 달만 키보드 대상
return (
<div
key={c.date.getTime()}
ref={live ? setCell(n) : undefined}
className={`${styles.cell} ${c.outside ? styles.isOut : ""}`}
role="gridcell"
aria-selected={live && n === focused}
aria-label={`${c.date.getFullYear()}-${pad(c.date.getMonth() + 1)}-${pad(c.date.getDate())}`}
tabIndex={live && n === focused ? 0 : -1}
onClick={() => { if (live) move(n); }}
onKeyDown={(e: KeyEvt) => { if (live) onKeyDown(e, n); }}
>
{c.date.getDate()}
</div>
);
})}
</div>
))}
</div>
))}
</div>
</div>
</div>
);
}01 · 두 트랙으로 넘어가는 월 이동 — 측정 누적면적 19.754% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
전환이 끝나면 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오늘 링과 숫자 배지
화면에서 일어나는 일
오늘 칸에만 파란 원이 깔리고 그 위로 링이 퍼졌다 사라지기를 반복합니다. 일정이 있는 날은 칸 모서리에 그날 개수가 작은 알약으로 올라앉는데, 흰 테두리를 둘러 파란 원 위에서도 숫자가 읽힙니다.

<main class="stage">
<div class="cal">
<div class="cal__bar"><h2 class="cal__title" id="cal-title">2026. 9</h2></div>
<div class="cal__dows" aria-hidden="true"><span>일</span><span>월</span><span>화</span><span>수</span><span>목</span><span>금</span><span>토</span></div>
<div class="cal__grid" id="cal-grid" role="grid" aria-labelledby="cal-title">
<div class="cal__row" role="row">
<div class="cal__cell is-out" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">30</span></div>
<div class="cal__cell is-out" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">31</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">1</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">2</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">3</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">4</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">5</span></div>
</div>
<div class="cal__row" role="row">
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">6</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">7</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">8</span><span class="cal__badge" aria-label="일정 1건">1</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">9</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">10</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">11</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">12</span></div>
</div>
<div class="cal__row" role="row">
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">13</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">14</span></div>
<div class="cal__cell is-today" role="gridcell" aria-current="date" aria-selected="true" tabindex="0"><span class="cal__n">15</span><span class="cal__badge" aria-label="일정 2건">2</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">16</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">17</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">18</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">19</span></div>
</div>
<div class="cal__row" role="row">
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">20</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">21</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">22</span><span class="cal__badge" aria-label="일정 4건">4</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">23</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">24</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">25</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">26</span></div>
</div>
<div class="cal__row" role="row">
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">27</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">28</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">29</span></div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">30</span></div>
<div class="cal__cell is-out" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">1</span></div>
<div class="cal__cell is-out" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">2</span></div>
<div class="cal__cell is-out" role="gridcell" aria-selected="false" tabindex="-1"><span class="cal__n">3</span></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
.cal {
box-sizing: border-box;
width: min(280px, 88vw);
color: $subject-ink;
}
.cal__bar { margin: 0 0 $sp-2; }
.cal__title {
margin: 0;
font-size: clamp(12px, 3.4vw, 15px);
line-height: 1.3;
font-weight: 700;
letter-spacing: .02em;
text-align: center;
}
.cal__dows {
display: grid; grid-template-columns: repeat(7, 1fr); gap: $sp-1;
margin: 0 0 $sp-1;
font-size: clamp(8px, 2.2vw, 10px);
line-height: 1.2;
font-weight: 700;
color: rgba(23, 20, 26, .72); // AA 4.5 (.4 는 2.57:1 이었다)
text-align: center;
}
.cal__grid { display: grid; gap: $sp-1; }
.cal__row { display: grid; grid-template-columns: repeat(7, 1fr); gap: $sp-1; }
// 오늘 칸 — 배경 원을 두고 그 위로 링이 퍼진다 (::after 는 배경색으로 덮어 둔다)
.cal__cell {
position: relative;
height: clamp(24px, 7.5vw, 32px);
display: grid; place-items: center;
border-radius: $r-xs;
font-size: clamp(9px, 2.8vw, 12px);
line-height: 1;
font-weight: 600;
color: $subject-ink;
cursor: pointer;
background: transparent;
transition: background $dur-quick $easing;
}
.cal__cell:hover { background: rgba(23, 20, 26, .06); }
.cal__cell:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
.cal__cell.is-out .cal__n { opacity: .25; }
.cal__cell.is-today { color: #fff; }
.cal__n { position: relative; z-index: 1; }
.cal__cell.is-today::before {
content: "";
position: absolute; left: 50%; top: 50%;
width: clamp(24px, 7.5vw, 32px); height: clamp(24px, 7.5vw, 32px);
transform: translate(-50%, -50%);
background: $color;
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;
}
// ── 자동 루프: 링이 한 번 퍼졌다 가라앉는다. 0% 와 100% 를 같게 — 이어져도 튀지 않는다 ──
.cal__cell.is-today::after {
animation-name: today-ring;
animation-duration: $duration;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes today-ring {
0% { transform: translate(-50%, -50%) scale(.9); opacity: 0; }
16% { transform: translate(-50%, -50%) scale(1.08); opacity: 1; }
64% { transform: translate(-50%, -50%) scale(1.5); opacity: 0; }
100% { transform: translate(-50%, -50%) scale(1.5); opacity: 0; }
}
// ── 일정 수 배지: 칸 모서리에 올라앉는 작은 알약 ──
.cal__badge {
position: absolute; right: 0; top: 0;
z-index: 2;
min-width: 14px; height: 14px;
box-sizing: border-box;
padding: 0 $sp-1;
display: inline-flex; align-items: center; justify-content: center;
background: $subject-ink;
color: #fff;
border-radius: $r-pill;
box-shadow: 0 0 0 2px #fff;
font-size: 9px;
line-height: 1;
font-weight: 800;
}
.cal__cell.is-today .cal__badge { background: $subject-cream; color: $subject-ink; }
.cal__cell[aria-selected="true"]:not(.is-today) { background: rgba(47, 109, 246, .14); }
@media (prefers-reduced-motion: reduce) {
.cal__cell.is-today::after { animation: none !important; opacity: 0; }
.cal__cell { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cal {
box-sizing: border-box;
width: min(280px, 88vw);
color: #17141a;
}
.cal__bar {
margin: 0 0 8px;
}
.cal__title {
margin: 0;
font-size: clamp(12px, 3.4vw, 15px);
line-height: 1.3;
font-weight: 700;
letter-spacing: 0.02em;
text-align: center;
}
.cal__dows {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin: 0 0 4px;
font-size: clamp(8px, 2.2vw, 10px);
line-height: 1.2;
font-weight: 700;
color: rgba(23, 20, 26, 0.72);
text-align: center;
}
.cal__grid {
display: grid;
gap: 4px;
}
.cal__row {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.cal__cell {
position: relative;
height: clamp(24px, 7.5vw, 32px);
display: grid;
place-items: center;
border-radius: 4px;
font-size: clamp(9px, 2.8vw, 12px);
line-height: 1;
font-weight: 600;
color: #17141a;
cursor: pointer;
background: transparent;
transition: background 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cal__cell:hover {
background: rgba(23, 20, 26, 0.06);
}
.cal__cell:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.cal__cell.is-out .cal__n {
opacity: 0.25;
}
.cal__cell.is-today {
color: #fff;
}
.cal__n {
position: relative;
z-index: 1;
}
.cal__cell.is-today::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: clamp(24px, 7.5vw, 32px);
height: clamp(24px, 7.5vw, 32px);
transform: translate(-50%, -50%);
background: #2f6df6;
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 #2f6df6;
border-radius: 50%;
transform: translate(-50%, -50%) scale(0.9);
opacity: 0;
pointer-events: none;
}
.cal__cell.is-today::after {
animation-name: today-ring;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes today-ring {
0% {
transform: translate(-50%, -50%) scale(0.9);
opacity: 0;
}
16% {
transform: translate(-50%, -50%) scale(1.08);
opacity: 1;
}
64% {
transform: translate(-50%, -50%) scale(1.5);
opacity: 0;
}
100% {
transform: translate(-50%, -50%) scale(1.5);
opacity: 0;
}
}
.cal__badge {
position: absolute;
right: 0;
top: 0;
z-index: 2;
min-width: 14px;
height: 14px;
box-sizing: border-box;
padding: 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #17141a;
color: #fff;
border-radius: 999px;
box-shadow: 0 0 0 2px #fff;
font-size: 9px;
line-height: 1;
font-weight: 800;
}
.cal__cell.is-today .cal__badge {
background: #fff7e6;
color: #17141a;
}
.cal__cell[aria-selected=true]:not(.is-today) {
background: rgba(47, 109, 246, 0.14);
}
@media (prefers-reduced-motion: reduce) {
.cal__cell.is-today::after {
animation: none !important;
opacity: 0;
}
.cal__cell {
transition: none;
}
}(function () {
var grid = document.getElementById('cal-grid');
var cells = [].slice.call(grid.querySelectorAll('.cal__cell'));
function move(n) {
n = Math.max(0, Math.min(cells.length - 1, n));
cells.forEach(function (c, k) { c.tabIndex = k === n ? 0 : -1; c.setAttribute('aria-selected', String(k === n)); });
cells[n].focus({ preventScroll: true });
}
grid.addEventListener('click', function (e) {
var c = e.target.closest ? e.target.closest('.cal__cell') : null;
if (c) move(cells.indexOf(c));
});
grid.addEventListener('keydown', function (e) {
var n = cells.indexOf(document.activeElement);
if (n < 0) return;
var to = null;
if (e.key === 'ArrowRight') to = n + 1;
else if (e.key === 'ArrowLeft') to = n - 1;
else if (e.key === 'ArrowDown') to = n + 7;
else if (e.key === 'ArrowUp') to = n - 7;
else if (e.key === 'Home') to = n - (n % 7);
else if (e.key === 'End') to = n - (n % 7) + 6;
else return;
e.preventDefault(); move(to);
});
})();import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TodayRingCalendar.module.scss";
/** 로컬 이벤트 타입 — 라이브러리 타입을 끌어오지 않는다 */
export type DayCount = { day: number; count: number };
export type TodayRingCalendarProps = {
month?: Date;
today?: number;
counts?: DayCount[];
/** 펄스 한 바퀴(ms) */
duration?: number;
easing?: string;
color?: string;
dayNames?: string[];
/** 배지 접근 이름. {n} 이 건수로 바뀐다 */
countLabel?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
export default function TodayRingCalendar({
month = new Date(2026, 8, 1),
today = 15,
counts = [{ day: 8, count: 1 }, { day: 15, count: 2 }, { day: 22, count: 4 }],
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
dayNames = ["S", "M", "T", "W", "T", "F", "S"],
countLabel = "{n} events",
}: TodayRingCalendarProps) {
const [focused, setFocused] = useState(() => 0);
const cellRefs = useRef<(HTMLDivElement | null)[]>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const y = month.getFullYear();
const m = month.getMonth();
const off = 1 - new Date(y, m, 1).getDay();
const cells = Array.from({ length: 35 }, (_, i) => {
const d = new Date(y, m, off + i);
return { date: d, outside: d.getMonth() !== m };
});
const startIdx = cells.findIndex((c) => !c.outside && c.date.getDate() === today);
const sel = focused || (startIdx < 0 ? 0 : startIdx);
function setCell(n: number) {
return (el: HTMLDivElement | null) => {
const a = cellRefs.current;
if (a) a[n] = el;
};
}
function move(n: number) {
const to = Math.max(0, Math.min(34, n));
setFocused(to);
cellRefs.current?.[to]?.focus({ preventScroll: true });
}
function onKeyDown(e: KeyEvt, n: number) {
let to: number | null = null;
if (e.key === "ArrowRight") to = n + 1;
else if (e.key === "ArrowLeft") to = n - 1;
else if (e.key === "ArrowDown") to = n + 7;
else if (e.key === "ArrowUp") to = n - 7;
else if (e.key === "Home") to = n - (n % 7);
else if (e.key === "End") to = n - (n % 7) + 6;
if (to === null) return;
e.preventDefault();
move(to);
}
return (
<div className={styles.cal} style={vars}>
<div className={styles.bar}>
<h2 className={styles.title} id="trc-title">{y}. {m + 1}</h2>
</div>
<div className={styles.dows} aria-hidden="true">
{dayNames.map((d, i) => <span key={`${d}-${i}`}>{d}</span>)}
</div>
<div className={styles.grid} role="grid" aria-labelledby="trc-title">
{[0, 1, 2, 3, 4].map((w) => (
<div key={w} className={styles.row} role="row">
{cells.slice(w * 7, w * 7 + 7).map((c, i) => {
const n = w * 7 + i;
const isToday = !c.outside && c.date.getDate() === today;
const hit = c.outside ? undefined : counts.find((x) => x.day === c.date.getDate());
return (
<div
key={c.date.getTime()}
ref={setCell(n)}
className={`${styles.cell} ${c.outside ? styles.isOut : ""} ${isToday ? styles.isToday : ""}`}
role="gridcell"
aria-current={isToday ? "date" : undefined}
aria-selected={n === sel}
tabIndex={n === sel ? 0 : -1}
onClick={() => move(n)}
onKeyDown={(e: KeyEvt) => onKeyDown(e, n)}
>
<span className={styles.n}>{c.date.getDate()}</span>
{hit ? (
<span className={styles.badge} aria-label={countLabel.replace("{n}", String(hit.count))}>
{hit.count}
</span>
) : null}
</div>
);
})}
</div>
))}
</div>
</div>
);
}02 · 오늘 링과 숫자 배지 — 측정 누적면적 0.972% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
링의 원은 셀 높이와 같은 정사각형을 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
어떤 움직임인가
한 칸에 색 칩이 두 개까지 쌓이고 남는 일정은 +N 줄로 접힙니다. 그 줄을 누르면 나머지가 grid-template-rows의 0fr에서 1fr로 펴지면서 실제로 열립니다.

<main class="stage">
<div class="cal">
<span class="cal__sr" id="cal-live" aria-live="polite">2026. 9</span>
<div class="cal__bar"><h2 class="cal__title" id="cal-title">2026. 9</h2></div>
<div class="cal__dows" aria-hidden="true"><span>일</span><span>월</span><span>화</span><span>수</span><span>목</span><span>금</span><span>토</span></div>
<div class="cal__grid" id="cal-grid" role="grid" aria-labelledby="cal-title">
<div class="cal__row" role="row">
<div class="cal__cell is-out" role="gridcell" aria-selected="false" tabindex="-1">30</div>
<div class="cal__cell is-out" role="gridcell" aria-selected="false" tabindex="-1">31</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">1</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">2</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">3</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">4</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">5</div>
</div>
<div class="cal__row" role="row">
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">6</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">7</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">8</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">9</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">10</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">11</div>
<div class="cal__cell is-today" role="gridcell" aria-current="date" aria-selected="false" tabindex="-1">12</div>
</div>
<div class="cal__row" role="row">
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">13</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">14</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">15</div>
<div class="cal__cell is-chips is-demo" id="cal-day16" role="gridcell" aria-selected="false" tabindex="-1">
<span class="cal__n">16</span>
<span class="cal__chip cal__chip--blue">팀 회의</span>
<span class="cal__chip cal__chip--ink">치과</span>
<button class="cal__morebtn" type="button" id="cal-morebtn" aria-expanded="false" aria-controls="cal-more">+3</button>
<span class="cal__morewrap" id="cal-more">
<span class="cal__more">
<span class="cal__chip cal__chip--mint">운동</span>
<span class="cal__chip cal__chip--blue">보고서</span>
<span class="cal__chip cal__chip--ink">저녁</span>
</span>
</span>
</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">17</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">18</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">19</div>
</div>
<div class="cal__row" role="row">
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">20</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">21</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">22</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">23</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">24</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">25</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">26</div>
</div>
<div class="cal__row" role="row">
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">27</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">28</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">29</div>
<div class="cal__cell" role="gridcell" aria-selected="false" tabindex="-1">30</div>
<div class="cal__cell is-out" role="gridcell" aria-selected="false" tabindex="-1">1</div>
<div class="cal__cell is-out" role="gridcell" aria-selected="false" tabindex="-1">2</div>
<div class="cal__cell is-out" role="gridcell" aria-selected="false" tabindex="-1">3</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.cal {
box-sizing: border-box;
width: min(300px, 92vw);
color: $color;
}
.cal__sr {
position: absolute; width: 1px; height: 1px;
overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
.cal__bar { margin: 0 0 $sp-1; }
.cal__title {
margin: 0;
font-size: clamp(11px, 3vw, 14px);
line-height: 1.2;
font-weight: 800;
letter-spacing: .02em;
text-align: center;
}
.cal__dows {
display: grid; grid-template-columns: repeat(7, 1fr); gap: $sp-1;
margin: 0 0 $sp-1;
font-size: clamp(7px, 2vw, 9px);
line-height: 1.2;
font-weight: 700;
color: rgba(23, 20, 26, .72); // AA 4.5 (.5 는 3.17:1 이었다)
text-align: center;
}
// 5행 — 이벤트 칩이 있는 셋째 행만 높다. 펼친 목록은 절대배치라 행 높이를 늘리지 않는다
.cal__grid {
display: grid; gap: $sp-1;
grid-template-rows: auto auto auto auto auto;
}
.cal__row { display: grid; grid-template-columns: repeat(7, 1fr); gap: $sp-1; }
.cal__cell {
position: relative;
box-sizing: border-box;
// 높이는 고정하지 않는다 — 칩이 있는 칸이 높아지면 그 줄의 일곱 칸이 같이 늘어난다
min-height: clamp(16px, 4.8vw, 22px);
padding: $sp-1 0;
display: grid; place-items: start center;
background: rgba(255, 255, 255, .5);
border-radius: $r-xs;
font-size: clamp(8px, 2.2vw, 10px);
line-height: 1;
font-weight: 700;
color: $color;
cursor: pointer;
}
.cal__cell.is-out { background: rgba(255, 255, 255, .22); color: rgba(23, 20, 26, .72); } // .35 는 2.16:1 이었다 — AA 4.5 (6.30:1)
.cal__cell.is-today { box-shadow: inset 0 0 0 2px $subject-blue; }
.cal__cell[aria-selected="true"] { background: #fff; box-shadow: 0 0 0 2px $subject-blue; }
.cal__cell:focus-visible { outline: 2px solid $subject-blue; outline-offset: 1px; }
// ── 칩이 쌓이는 칸 ──
.cal__cell.is-chips {
min-height: clamp(40px, 12vw, 52px);
display: flex; flex-direction: column; align-items: stretch;
gap: $sp-1;
padding: $sp-1;
background: #fff;
border-radius: $r-sm;
}
.cal__n { font-size: clamp(8px, 2.2vw, 10px); line-height: 1.1; font-weight: 700; text-align: center; }
.cal__chip {
display: block;
box-sizing: border-box;
height: clamp(9px, 2.6vw, 11px);
padding: 0 $sp-1;
border-radius: $r-xs;
font-size: clamp(6px, 1.8vw, 8px);
line-height: clamp(9px, 2.6vw, 11px);
font-weight: 700;
white-space: nowrap; overflow: hidden; text-overflow: clip;
}
.cal__chip--blue { background: $subject-blue-deep; color: $subject-cream; }
.cal__chip--ink { background: $subject-ink; color: $subject-cream; }
.cal__chip--mint { background: $subject-mint; color: $subject-ink; }
.cal__morebtn {
display: block;
box-sizing: border-box;
width: 100%; height: clamp(9px, 2.6vw, 11px);
padding: 0;
border: 0;
background: rgba(23, 20, 26, .1);
border-radius: $r-xs;
color: $color;
font-family: inherit;
font-size: clamp(6px, 1.8vw, 8px);
line-height: 1;
font-weight: 800;
text-align: left;
text-indent: 2px;
cursor: pointer;
transition: background $dur-quick $easing;
}
.cal__morebtn:hover { background: rgba(23, 20, 26, .18); }
.cal__morebtn:focus-visible { outline: 2px solid $subject-blue; outline-offset: 1px; }
// 펼쳐지는 나머지 — 절대배치 카드라 격자 높이를 밀지 않는다 (아래 두 행 위로 덮인다)
.cal__morewrap {
position: absolute;
left: 0; right: 0; top: calc(100% + 2px);
display: grid;
grid-template-rows: 0fr;
box-sizing: border-box;
background: #fff;
border-radius: $r-sm;
box-shadow: $shadow-press;
transition: grid-template-rows $duration $easing;
z-index: 2;
}
.cal__more {
display: flex; flex-direction: column; gap: $sp-1;
min-height: 0;
overflow: hidden;
padding: 0 $sp-1;
}
.cal__cell.is-open .cal__morewrap { grid-template-rows: 1fr; }
.cal__cell.is-open .cal__more { padding: $sp-1; }
// ── 자동 루프: 한 칸의 +N 이 열렸다 닫힌다. 0% 와 100% 가 같아 이어져도 튀지 않는다 ──
.cal__cell.is-demo .cal__morewrap {
animation-name: more-open;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.cal__cell.is-demo .cal__more {
animation-name: more-pad;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes more-open {
0% { grid-template-rows: 0fr; }
25% { grid-template-rows: 1fr; }
70% { grid-template-rows: 1fr; }
95% { grid-template-rows: 0fr; }
100% { grid-template-rows: 0fr; }
}
@keyframes more-pad {
0% { padding: 0 $sp-1; }
25% { padding: $sp-1; }
70% { padding: $sp-1; }
95% { padding: 0 $sp-1; }
100% { padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.cal__cell.is-demo .cal__morewrap,
.cal__cell.is-demo .cal__more { animation: none !important; }
.cal__morewrap, .cal__morebtn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cal {
box-sizing: border-box;
width: min(300px, 92vw);
color: #17141a;
}
.cal__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.cal__bar {
margin: 0 0 4px;
}
.cal__title {
margin: 0;
font-size: clamp(11px, 3vw, 14px);
line-height: 1.2;
font-weight: 800;
letter-spacing: 0.02em;
text-align: center;
}
.cal__dows {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin: 0 0 4px;
font-size: clamp(7px, 2vw, 9px);
line-height: 1.2;
font-weight: 700;
color: rgba(23, 20, 26, 0.72);
text-align: center;
}
.cal__grid {
display: grid;
gap: 4px;
grid-template-rows: auto auto auto auto auto;
}
.cal__row {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.cal__cell {
position: relative;
box-sizing: border-box;
min-height: clamp(16px, 4.8vw, 22px);
padding: 4px 0;
display: grid;
place-items: start center;
background: rgba(255, 255, 255, 0.5);
border-radius: 4px;
font-size: clamp(8px, 2.2vw, 10px);
line-height: 1;
font-weight: 700;
color: #17141a;
cursor: pointer;
}
.cal__cell.is-out {
background: rgba(255, 255, 255, 0.22);
color: rgba(23, 20, 26, 0.72);
}
.cal__cell.is-today {
box-shadow: inset 0 0 0 2px #2f6df6;
}
.cal__cell[aria-selected=true] {
background: #fff;
box-shadow: 0 0 0 2px #2f6df6;
}
.cal__cell:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.cal__cell.is-chips {
min-height: clamp(40px, 12vw, 52px);
display: flex;
flex-direction: column;
align-items: stretch;
gap: 4px;
padding: 4px;
background: #fff;
border-radius: 8px;
}
.cal__n {
font-size: clamp(8px, 2.2vw, 10px);
line-height: 1.1;
font-weight: 700;
text-align: center;
}
.cal__chip {
display: block;
box-sizing: border-box;
height: clamp(9px, 2.6vw, 11px);
padding: 0 4px;
border-radius: 4px;
font-size: clamp(6px, 1.8vw, 8px);
line-height: clamp(9px, 2.6vw, 11px);
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: clip;
}
.cal__chip--blue {
background: #0a4fe8;
color: #fff7e6;
}
.cal__chip--ink {
background: #17141a;
color: #fff7e6;
}
.cal__chip--mint {
background: #4cd4a6;
color: #17141a;
}
.cal__morebtn {
display: block;
box-sizing: border-box;
width: 100%;
height: clamp(9px, 2.6vw, 11px);
padding: 0;
border: 0;
background: rgba(23, 20, 26, 0.1);
border-radius: 4px;
color: #17141a;
font-family: inherit;
font-size: clamp(6px, 1.8vw, 8px);
line-height: 1;
font-weight: 800;
text-align: left;
text-indent: 2px;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cal__morebtn:hover {
background: rgba(23, 20, 26, 0.18);
}
.cal__morebtn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.cal__morewrap {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 2px);
display: grid;
grid-template-rows: 0fr;
box-sizing: border-box;
background: #fff;
border-radius: 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
z-index: 2;
}
.cal__more {
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
overflow: hidden;
padding: 0 4px;
}
.cal__cell.is-open .cal__morewrap {
grid-template-rows: 1fr;
}
.cal__cell.is-open .cal__more {
padding: 4px;
}
.cal__cell.is-demo .cal__morewrap {
animation-name: more-open;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.cal__cell.is-demo .cal__more {
animation-name: more-pad;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes more-open {
0% {
grid-template-rows: 0fr;
}
25% {
grid-template-rows: 1fr;
}
70% {
grid-template-rows: 1fr;
}
95% {
grid-template-rows: 0fr;
}
100% {
grid-template-rows: 0fr;
}
}
@keyframes more-pad {
0% {
padding: 0 4px;
}
25% {
padding: 4px;
}
70% {
padding: 4px;
}
95% {
padding: 0 4px;
}
100% {
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.cal__cell.is-demo .cal__morewrap,
.cal__cell.is-demo .cal__more {
animation: none !important;
}
.cal__morewrap, .cal__morebtn {
transition: none;
}
}(function () {
var cal = document.querySelector('.cal');
var grid = document.getElementById('cal-grid');
var day = document.getElementById('cal-day16');
var btn = document.getElementById('cal-morebtn');
var cells = [].slice.call(grid.querySelectorAll('.cal__cell'));
function move(n) {
n = Math.max(0, Math.min(cells.length - 1, n));
cells.forEach(function (c, k) { c.tabIndex = k === n ? 0 : -1; c.setAttribute('aria-selected', String(k === n)); });
cells[n].focus({ preventScroll: true });
}
function toggle(force) {
var open = typeof force === 'boolean' ? force : !day.classList.contains('is-open');
day.classList.remove('is-demo');
day.classList.toggle('is-open', open);
btn.setAttribute('aria-expanded', String(open));
}
btn.addEventListener('click', function (e) { e.stopPropagation(); toggle(); });
grid.addEventListener('click', function (e) {
var c = e.target.closest ? e.target.closest('.cal__cell') : null;
if (c) move(cells.indexOf(c));
});
grid.addEventListener('keydown', function (e) {
var n = cells.indexOf(document.activeElement);
if (n < 0) return;
var to = null;
if (e.key === 'ArrowRight') to = n + 1;
else if (e.key === 'ArrowLeft') to = n - 1;
else if (e.key === 'ArrowDown') to = n + 7;
else if (e.key === 'ArrowUp') to = n - 7;
else if (e.key === 'Home') to = n - (n % 7);
else if (e.key === 'End') to = n - (n % 7) + 6;
else return;
e.preventDefault(); move(to);
});
btn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); toggle(); }
});
})();import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./EventChipsCalendar.module.scss";
/** 로컬 이벤트 타입 — 라이브러리 타입을 끌어오지 않는다 */
export type CalEvent = { day: number; title: string; tone: "blue" | "ink" | "mint" };
export type EventChipsCalendarProps = {
month?: Date;
events?: CalEvent[];
/** 한 칸에 접지 않고 보여줄 칩 수 */
visible?: number;
/** 펼침 전환 시간(ms) */
duration?: number;
easing?: string;
color?: string;
dayNames?: string[];
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void; stopPropagation: () => void };
type ClickEvt = { stopPropagation: () => void };
const TONE: Record<CalEvent["tone"], string> = { blue: styles.blue, ink: styles.ink, mint: styles.mint };
export default function EventChipsCalendar({
month = new Date(2026, 8, 1),
events = [
{ day: 16, title: "Standup", tone: "blue" },
{ day: 16, title: "Dentist", tone: "ink" },
{ day: 16, title: "Gym", tone: "mint" },
{ day: 16, title: "Report", tone: "blue" },
{ day: 16, title: "Dinner", tone: "ink" },
],
visible = 2,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
dayNames = ["S", "M", "T", "W", "T", "F", "S"],
}: EventChipsCalendarProps) {
const [open, setOpen] = useState<number | null>(null);
const [focused, setFocused] = useState(0);
const cellRefs = useRef<(HTMLDivElement | null)[]>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const y = month.getFullYear();
const m = month.getMonth();
const off = 1 - new Date(y, m, 1).getDay();
const cells = Array.from({ length: 35 }, (_, i) => {
const d = new Date(y, m, off + i);
return { date: d, outside: d.getMonth() !== m };
});
function setCell(n: number) {
return (el: HTMLDivElement | null) => {
const a = cellRefs.current;
if (a) a[n] = el;
};
}
function move(n: number) {
const to = Math.max(0, Math.min(34, n));
setFocused(to);
cellRefs.current?.[to]?.focus({ preventScroll: true });
}
function onKeyDown(e: KeyEvt, n: number) {
let to: number | null = null;
if (e.key === "ArrowRight") to = n + 1;
else if (e.key === "ArrowLeft") to = n - 1;
else if (e.key === "ArrowDown") to = n + 7;
else if (e.key === "ArrowUp") to = n - 7;
else if (e.key === "Home") to = n - (n % 7);
else if (e.key === "End") to = n - (n % 7) + 6;
if (to === null) return;
e.preventDefault();
move(to);
}
return (
<div className={styles.cal} style={vars}>
<div className={styles.bar}>
<h2 className={styles.title} id="ecc-title">{y}. {m + 1}</h2>
</div>
<div className={styles.dows} aria-hidden="true">
{dayNames.map((d, i) => <span key={`${d}-${i}`}>{d}</span>)}
</div>
<div className={styles.grid} role="grid" aria-labelledby="ecc-title">
{[0, 1, 2, 3, 4].map((w) => (
<div key={w} className={styles.row} role="row">
{cells.slice(w * 7, w * 7 + 7).map((c, i) => {
const n = w * 7 + i;
const mine = c.outside ? [] : events.filter((ev) => ev.day === c.date.getDate());
const head = mine.slice(0, visible);
const rest = mine.slice(visible);
const isOpen = open === n;
return (
<div
key={c.date.getTime()}
ref={setCell(n)}
className={[
styles.cell,
c.outside ? styles.isOut : "",
mine.length ? styles.isChips : "",
isOpen ? styles.isOpen : "",
].join(" ")}
role="gridcell"
aria-selected={n === focused}
tabIndex={n === focused ? 0 : -1}
onClick={() => move(n)}
onKeyDown={(e: KeyEvt) => onKeyDown(e, n)}
>
{mine.length ? <span className={styles.n}>{c.date.getDate()}</span> : c.date.getDate()}
{head.map((ev) => (
<span key={ev.title} className={`${styles.chip} ${TONE[ev.tone]}`}>{ev.title}</span>
))}
{rest.length ? (
<>
<button
type="button"
className={styles.morebtn}
aria-expanded={isOpen}
aria-controls={`ecc-more-${n}`}
onClick={(e: ClickEvt) => { e.stopPropagation(); setOpen(isOpen ? null : n); }}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.stopPropagation(); setOpen(isOpen ? null : n);
}
}}
>
+{rest.length}
</button>
<span className={styles.morewrap} id={`ecc-more-${n}`}>
<span className={styles.more}>
{rest.map((ev) => (
<span key={ev.title} className={`${styles.chip} ${TONE[ev.tone]}`}>{ev.title}</span>
))}
</span>
</span>
</>
) : null}
</div>
);
})}
</div>
))}
</div>
</div>
);
}03 · 이벤트 칩과 더보기 +N — 측정 누적면적 1.95% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
펼쳐지는 목록은 절대배치 카드라 격자 높이를 밀지 않아서, 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(anchor, head) 한 함수가 양 끝 인덱스만 받아 범위 안 칸을 양 끝까지 모두 is-in으로 칠하고 거기에 is-start·is-end를 덧붙이는 식이라, 드래그 방향을 뒤집어도 같은 코드가 그대로 동작합니다.

<main class="stage">
<div class="rc is-demo">
<div class="rc__head">
<span class="rc__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 48v40H40V48a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 32h-24v-8a8 8 0 0 0-16 0v8H88v-8a8 8 0 0 0-16 0v8H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16M72 48v8a8 8 0 0 0 16 0v-8h80v8a8 8 0 0 0 16 0v-8h24v32H48V48Zm136 160H48V96h160z"/></g></svg></span>
<span class="rc__title">2026. 9</span>
<span class="rc__out" id="rc-live" role="status" aria-live="polite">13 – 19</span>
</div>
<div class="rc__dow" aria-hidden="true"><span>일</span><span>월</span><span>화</span><span>수</span><span>목</span><span>금</span><span>토</span></div>
<div class="rc__grid" id="rc-grid" role="grid" aria-label="기간 선택 달력" aria-multiselectable="true">
<div class="rc__r" role="row">
<div class="rc__c is-out" role="gridcell" tabindex="-1" aria-selected="false" aria-disabled="true" data-d="30">30</div>
<div class="rc__c is-out" role="gridcell" tabindex="-1" aria-selected="false" aria-disabled="true" data-d="31">31</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="1">1</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="2">2</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="3">3</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="4">4</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="5">5</div>
</div>
<div class="rc__r" role="row">
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="6">6</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="7">7</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="8">8</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="9">9</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="10">10</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="11">11</div>
<div class="rc__c is-today" role="gridcell" tabindex="0" aria-selected="false" aria-current="date" data-d="12">12</div>
</div>
<div class="rc__r" role="row">
<div class="rc__c d d1 is-in is-start" role="gridcell" tabindex="-1" aria-selected="true" data-d="13">13</div>
<div class="rc__c d d2 is-in" role="gridcell" tabindex="-1" aria-selected="true" data-d="14">14</div>
<div class="rc__c d d3 is-in" role="gridcell" tabindex="-1" aria-selected="true" data-d="15">15</div>
<div class="rc__c d d4 is-in" role="gridcell" tabindex="-1" aria-selected="true" data-d="16">16</div>
<div class="rc__c d d5 is-in" role="gridcell" tabindex="-1" aria-selected="true" data-d="17">17</div>
<div class="rc__c d d6 is-in" role="gridcell" tabindex="-1" aria-selected="true" data-d="18">18</div>
<div class="rc__c d d7 is-in is-end" role="gridcell" tabindex="-1" aria-selected="true" data-d="19">19</div>
</div>
<div class="rc__r" role="row">
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="20">20</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="21">21</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="22">22</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="23">23</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="24">24</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="25">25</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="26">26</div>
</div>
<div class="rc__r" role="row">
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="27">27</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="28">28</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="29">29</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="30">30</div>
<div class="rc__c is-out" role="gridcell" tabindex="-1" aria-selected="false" aria-disabled="true" data-d="1">1</div>
<div class="rc__c is-out" role="gridcell" tabindex="-1" aria-selected="false" aria-disabled="true" data-d="2">2</div>
<div class="rc__c is-out" role="gridcell" tabindex="-1" aria-selected="false" aria-disabled="true" data-d="3">3</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
.rc {
box-sizing: border-box;
width: min(320px, 90vw);
color: $subject-ink; // 주황 무대 위 글자 — AA 4.5 (크림은 3.11:1)
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.rc__head {
display: flex;
align-items: center;
gap: $sp-2;
margin: 0 0 $sp-2;
}
.rc__ico { display: inline-flex; opacity: .8; }
.rc__title {
font-size: clamp(11px, 3.4vw, 14px);
font-weight: 700;
letter-spacing: .02em;
}
.rc__out {
margin-left: auto;
padding: 0 $sp-2;
border-radius: $r-pill;
background: rgba(255, 255, 255, .18);
font-size: clamp(9px, 2.6vw, 11px);
font-weight: 700;
line-height: 1.6;
white-space: nowrap;
}
.rc__dow {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: $sp-1;
margin: 0 0 $sp-1;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
text-align: center;
color: $subject-ink; // 주황 면 위 글자는 알파 없는 잉크 — opacity .72 는 면과 섞여 3.76:1 이었다 (AA 4.5 → 5.50:1)
}
.rc__grid { display: flex; flex-direction: column; gap: $sp-1; }
.rc__r { display: grid; grid-template-columns: repeat(7, 1fr); gap: $sp-1; }
.rc__c {
box-sizing: border-box;
height: clamp(20px, 6.4vw, 30px);
display: flex;
align-items: center;
justify-content: center;
border-radius: $r-sm;
background: rgba(255, 255, 255, .14);
color: $subject-ink; // AA 4.5 (크림은 2.73:1)
font-size: clamp(9px, 2.9vw, 12px);
font-weight: 600;
cursor: pointer;
user-select: none;
touch-action: none;
transition: background $duration $easing, color $duration $easing, border-radius $duration $easing;
}
.rc__c.is-out { opacity: .4; }
.rc__c.is-today { box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .7); }
.rc__c:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// 범위 — 가운데는 각진 띠, 양 끝 칸만 둥근 모서리
.rc__c.is-in { background: $subject-cream; color: $subject-orange-deep; border-radius: 0; } // 밝은 면 위 주황 글자 — 5.04:1
.rc__c.is-start { border-radius: $r-sm 0 0 $r-sm; }
.rc__c.is-end { border-radius: 0 $r-sm $r-sm 0; }
.rc__c.is-start.is-end { border-radius: $r-sm; }
// ── 자동 루프: 왼쪽 칸부터 차례로 칠해졌다 차례로 접힌다 (2s) ──
// animation 단축은 animation-delay 를 0s 로 리셋한다 → 낱개 속성으로만 쓴다
.rc.is-demo .rc__c.d {
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
// backwards 가 없으면 delay 동안 기본 스타일(is-in = 칠해진 상태)이 보이다가 애니메이션 시작에 튄다
animation-fill-mode: backwards;
}
.rc.is-demo .d1 { animation-name: rc-l; animation-delay: 0s; }
.rc.is-demo .d2 { animation-name: rc-m; animation-delay: .09s; }
.rc.is-demo .d3 { animation-name: rc-m; animation-delay: .18s; }
.rc.is-demo .d4 { animation-name: rc-m; animation-delay: .27s; }
.rc.is-demo .d5 { animation-name: rc-m; animation-delay: .36s; }
.rc.is-demo .d6 { animation-name: rc-m; animation-delay: .45s; }
.rc.is-demo .d7 { animation-name: rc-r; animation-delay: .54s; }
@keyframes rc-l {
0%, 6% { background: rgba(255, 255, 255, .14); color: $subject-cream; border-radius: $r-sm; }
14%, 46% { background: $subject-cream; color: $stage-orange; border-radius: $r-sm 0 0 $r-sm; }
54%, 100% { background: rgba(255, 255, 255, .14); color: $subject-cream; border-radius: $r-sm; }
}
@keyframes rc-m {
0%, 6% { background: rgba(255, 255, 255, .14); color: $subject-cream; border-radius: $r-sm; }
14%, 46% { background: $subject-cream; color: $stage-orange; border-radius: 0; }
54%, 100% { background: rgba(255, 255, 255, .14); color: $subject-cream; border-radius: $r-sm; }
}
@keyframes rc-r {
0%, 6% { background: rgba(255, 255, 255, .14); color: $subject-cream; border-radius: $r-sm; }
14%, 46% { background: $subject-cream; color: $stage-orange; border-radius: 0 $r-sm $r-sm 0; }
54%, 100% { background: rgba(255, 255, 255, .14); color: $subject-cream; border-radius: $r-sm; }
}
@media (prefers-reduced-motion: reduce) {
.rc.is-demo .rc__c.d { animation: none !important; }
.rc__c { transition: none; }
}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);
}
.rc {
box-sizing: border-box;
width: min(320px, 90vw);
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.rc__head {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px;
}
.rc__ico {
display: inline-flex;
opacity: 0.8;
}
.rc__title {
font-size: clamp(11px, 3.4vw, 14px);
font-weight: 700;
letter-spacing: 0.02em;
}
.rc__out {
margin-left: auto;
padding: 0 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
font-size: clamp(9px, 2.6vw, 11px);
font-weight: 700;
line-height: 1.6;
white-space: nowrap;
}
.rc__dow {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin: 0 0 4px;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
text-align: center;
color: #17141a;
}
.rc__grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.rc__r {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.rc__c {
box-sizing: border-box;
height: clamp(20px, 6.4vw, 30px);
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: rgba(255, 255, 255, 0.14);
color: #17141a;
font-size: clamp(9px, 2.9vw, 12px);
font-weight: 600;
cursor: pointer;
user-select: none;
touch-action: none;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), border-radius 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rc__c.is-out {
opacity: 0.4;
}
.rc__c.is-today {
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.7);
}
.rc__c:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.rc__c.is-in {
background: #fff7e6;
color: #c93000;
border-radius: 0;
}
.rc__c.is-start {
border-radius: 8px 0 0 8px;
}
.rc__c.is-end {
border-radius: 0 8px 8px 0;
}
.rc__c.is-start.is-end {
border-radius: 8px;
}
.rc.is-demo .rc__c.d {
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rc.is-demo .d1 {
animation-name: rc-l;
animation-delay: 0s;
}
.rc.is-demo .d2 {
animation-name: rc-m;
animation-delay: 0.09s;
}
.rc.is-demo .d3 {
animation-name: rc-m;
animation-delay: 0.18s;
}
.rc.is-demo .d4 {
animation-name: rc-m;
animation-delay: 0.27s;
}
.rc.is-demo .d5 {
animation-name: rc-m;
animation-delay: 0.36s;
}
.rc.is-demo .d6 {
animation-name: rc-m;
animation-delay: 0.45s;
}
.rc.is-demo .d7 {
animation-name: rc-r;
animation-delay: 0.54s;
}
@keyframes rc-l {
0%, 6% {
background: rgba(255, 255, 255, 0.14);
color: #fff7e6;
border-radius: 8px;
}
14%, 46% {
background: #fff7e6;
color: #ff4d1f;
border-radius: 8px 0 0 8px;
}
54%, 100% {
background: rgba(255, 255, 255, 0.14);
color: #fff7e6;
border-radius: 8px;
}
}
@keyframes rc-m {
0%, 6% {
background: rgba(255, 255, 255, 0.14);
color: #fff7e6;
border-radius: 8px;
}
14%, 46% {
background: #fff7e6;
color: #ff4d1f;
border-radius: 0;
}
54%, 100% {
background: rgba(255, 255, 255, 0.14);
color: #fff7e6;
border-radius: 8px;
}
}
@keyframes rc-r {
0%, 6% {
background: rgba(255, 255, 255, 0.14);
color: #fff7e6;
border-radius: 8px;
}
14%, 46% {
background: #fff7e6;
color: #ff4d1f;
border-radius: 0 8px 8px 0;
}
54%, 100% {
background: rgba(255, 255, 255, 0.14);
color: #fff7e6;
border-radius: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rc.is-demo .rc__c.d {
animation: none !important;
}
.rc__c {
transition: none;
}
}// pointerdown 으로 시작 칸을 잡고 pointermove 로 끌면 지나간 칸이 순서대로 칠해진다.
// Shift+ArrowLeft/Right(Up/Down) 로도 같은 범위를 늘린다. 범위 안 칸은 aria-selected="true".
var root = document.querySelector('.rc');
var grid = document.getElementById('rc-grid');
var live = document.getElementById('rc-live');
var cells = [].slice.call(grid.querySelectorAll('.rc__c'));
var COLS = 7;
var anchor = 14, head = 20, dragging = false;
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');
}
function rove(i) {
for (var k = 0; k < cells.length; k++) cells[k].setAttribute('tabindex', k === i ? '0' : '-1');
cells[i].focus({ preventScroll: true });
}
function hand() { root.classList.remove('is-demo'); }
function cellAt(x, y) {
var el = document.elementFromPoint(x, y);
return el && el.closest ? el.closest('.rc__c') : null;
}
grid.addEventListener('pointerdown', function (e) {
var c = e.target.closest ? e.target.closest('.rc__c') : null;
if (!c) return;
hand();
dragging = true;
anchor = head = cells.indexOf(c);
paint(anchor, head);
rove(head);
e.preventDefault();
});
document.addEventListener('pointermove', function (e) {
if (!dragging) return;
var c = cellAt(e.clientX, e.clientY);
if (!c) return;
var i = cells.indexOf(c);
if (i >= 0 && i !== head) { head = i; paint(anchor, head); }
});
document.addEventListener('pointerup', function () { dragging = false; });
document.addEventListener('pointercancel', function () { dragging = false; });
grid.addEventListener('keydown', function (e) {
var i = cells.indexOf(document.activeElement);
if (i < 0) return;
var n = i;
if (e.key === 'ArrowLeft') n = i - 1;
else if (e.key === 'ArrowRight') n = i + 1;
else if (e.key === 'ArrowUp') n = i - COLS;
else if (e.key === 'ArrowDown') n = i + COLS;
else if (e.key === 'Home') n = i - (i % COLS);
else if (e.key === 'End') n = i - (i % COLS) + COLS - 1;
else if (e.key === ' ' || e.key === 'Enter') { hand(); anchor = head = i; paint(i, i); e.preventDefault(); return; }
else return;
if (n < 0 || n >= cells.length) return;
e.preventDefault();
hand();
if (e.shiftKey) { head = n; paint(anchor, head); }
else { anchor = head = n; paint(n, n); }
rove(n);
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RangeDragCalendar.module.scss";
export type RangeDragCalendarProps = {
/** 격자에 깔 날짜 라벨. 기본값은 5주 × 7열 = 35칸 */
days?: string[];
/** 처음 선택돼 있는 범위 [시작 index, 끝 index] */
initial?: [number, number];
duration?: number;
easing?: string;
color?: string;
onChange?: (from: number, to: number) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; shiftKey: boolean; preventDefault: () => void };
type PtrEvt = { clientX: number; clientY: number; preventDefault: () => void };
const COLS = 7;
function defaultDays(): string[] {
// 30·31 → 1..30 → 1·2·3 (앞뒤 달 흘림칸 포함 35칸)
const out: string[] = ["30", "31"];
for (let d = 1; d <= 30; d += 1) out.push(String(d));
return out.concat(["1", "2", "3"]);
}
export default function RangeDragCalendar({
days = defaultDays(),
initial = [14, 20],
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
onChange,
}: RangeDragCalendarProps) {
const [anchor, setAnchor] = useState(initial[0]);
const [head, setHead] = useState(initial[1]);
const [focus, setFocus] = useState(initial[0]);
const dragging = useRef(false);
const cellRefs = useRef<(HTMLDivElement | null)[]>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const lo = Math.min(anchor, head);
const hi = Math.max(anchor, head);
function commit(a: number, h: number) {
setAnchor(a);
setHead(h);
if (onChange) onChange(Math.min(a, h), Math.max(a, h));
}
function move(i: number) {
setFocus(i);
const el = cellRefs.current ? cellRefs.current[i] : null;
if (el) el.focus({ preventScroll: true });
}
function indexFromPoint(x: number, y: number): number {
const el = document.elementFromPoint(x, y);
const cell = el && (el as HTMLElement).closest ? (el as HTMLElement).closest("[data-i]") : null;
return cell ? Number((cell as HTMLElement).dataset.i) : -1;
}
function onPointerDown(i: number, e: PtrEvt) {
dragging.current = true;
commit(i, i);
move(i);
e.preventDefault();
const onMove = (ev: { clientX: number; clientY: number }) => {
if (!dragging.current) return;
const n = indexFromPoint(ev.clientX, ev.clientY);
if (n >= 0) setHead(n);
};
const onUp = () => {
dragging.current = false;
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onUp);
if (onChange) onChange(Math.min(anchor, head), Math.max(anchor, head));
};
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerup", onUp);
}
function onKeyDown(i: number, e: KeyEvt) {
let n = i;
if (e.key === "ArrowLeft") n = i - 1;
else if (e.key === "ArrowRight") n = i + 1;
else if (e.key === "ArrowUp") n = i - COLS;
else if (e.key === "ArrowDown") n = i + COLS;
else if (e.key === "Home") n = i - (i % COLS);
else if (e.key === "End") n = i - (i % COLS) + COLS - 1;
else if (e.key === " " || e.key === "Enter") { commit(i, i); e.preventDefault(); return; }
else return;
if (n < 0 || n >= days.length) return;
e.preventDefault();
if (e.shiftKey) { setHead(n); if (onChange) onChange(Math.min(anchor, n), Math.max(anchor, n)); }
else commit(n, n);
move(n);
}
const rows: number[][] = [];
for (let r = 0; r * COLS < days.length; r += 1) {
const row: number[] = [];
for (let c = 0; c < COLS && r * COLS + c < days.length; c += 1) row.push(r * COLS + c);
rows.push(row);
}
return (
<div className={styles.rc} style={vars}>
<div className={styles.head}>
<span className={styles.title}>2026. 9</span>
<span className={styles.out} role="status" aria-live="polite">
{days[lo]} – {days[hi]}
</span>
</div>
<div className={styles.grid} role="grid" aria-multiselectable="true">
{rows.map((row) => (
<div className={styles.row} role="row" key={row[0]}>
{row.map((i) => {
const on = i >= lo && i <= hi;
const cls = [
styles.cell,
on ? styles.isIn : "",
on && i === lo ? styles.isStart : "",
on && i === hi ? styles.isEnd : "",
].join(" ");
return (
<div
key={i}
data-i={i}
ref={(el: HTMLDivElement | null) => { if (cellRefs.current) cellRefs.current[i] = el; }}
className={cls}
role="gridcell"
aria-selected={on}
tabIndex={i === focus ? 0 : -1}
onPointerDown={(e: PtrEvt) => onPointerDown(i, e)}
onKeyDown={(e: KeyEvt) => onKeyDown(i, e)}
>
{days[i]}
</div>
);
})}
</div>
))}
</div>
</div>
);
}04 · 드래그로 칠하는 기간 선택 — 측정 누적면적 6.149% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
키보드는 처음 고른 칸을 고정한 채 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시간 축 위의 주간 타임라인
화면에서 일어나는 일
왼쪽 시간 눈금에 맞춰 일정 블록이 자기 시작 시각 위치에 놓입니다. 같은 시각에 겹치는 두 블록은 폭을 반씩 나눠 나란히 서서, 월간 격자에서는 안 보이던 충돌이 한눈에 드러납니다.

<main class="stage">
<div class="wt is-demo">
<div class="wt__body">
<div class="wt__side" aria-hidden="true">
<span class="wt__ico"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m64-88a8 8 0 0 1-8 8h-56a8 8 0 0 1-8-8V72a8 8 0 0 1 16 0v48h48a8 8 0 0 1 8 8"/></g></svg></span>
<span class="wt__hr">9</span>
<span class="wt__hr">10</span>
<span class="wt__hr">11</span>
<span class="wt__hr">12</span>
<span class="wt__hr">13</span>
</div>
<div class="wt__grid" id="wt-grid" role="grid" aria-label="주간 일정 타임라인">
<div class="wt__r wt__r--h" role="row">
<span class="wt__h" role="columnheader">14</span>
<span class="wt__h" role="columnheader">15</span>
<span class="wt__h is-today" role="columnheader" aria-current="date">16</span>
<span class="wt__h" role="columnheader">17</span>
<span class="wt__h" role="columnheader">18</span>
</div>
<div class="wt__r" role="row">
<div class="wt__c" role="gridcell" tabindex="0" data-h="9"><span class="wt__ev" style="--n:1.5">회의</span></div>
<div class="wt__c" role="gridcell" tabindex="-1" data-h="9"></div>
<div class="wt__c is-today" role="gridcell" tabindex="-1" data-h="9"></div>
<div class="wt__c" role="gridcell" tabindex="-1" data-h="9"></div>
<div class="wt__c" role="gridcell" tabindex="-1" data-h="9"><span class="wt__ev" style="--n:2">회의</span></div>
</div>
<div class="wt__r" role="row">
<div class="wt__c" role="gridcell" tabindex="-1" data-h="10"></div>
<div class="wt__c" role="gridcell" tabindex="-1" data-h="10"><span class="wt__ev wt__ev--b" style="--n:1">리뷰</span></div>
<div class="wt__c is-today" role="gridcell" tabindex="-1" data-h="10"></div>
<div class="wt__c" role="gridcell" tabindex="-1" data-h="10"></div>
<div class="wt__c" role="gridcell" tabindex="-1" data-h="10"></div>
</div>
<div class="wt__r" role="row">
<div class="wt__c" role="gridcell" tabindex="-1" data-h="11"></div>
<div class="wt__c" role="gridcell" tabindex="-1" data-h="11"></div>
<div class="wt__c is-today" role="gridcell" tabindex="-1" data-h="11"><span class="wt__ev wt__ev--l" style="--n:1">리뷰</span><span class="wt__ev wt__ev--b wt__ev--r" style="--n:1">회의</span></div>
<div class="wt__c" role="gridcell" tabindex="-1" data-h="11"></div>
<div class="wt__c" role="gridcell" tabindex="-1" data-h="11"></div>
</div>
<div class="wt__r" role="row">
<div class="wt__c" role="gridcell" tabindex="-1" data-h="12"></div>
<div class="wt__c" role="gridcell" tabindex="-1" data-h="12"></div>
<div class="wt__c is-today" role="gridcell" tabindex="-1" data-h="12"></div>
<div class="wt__c" role="gridcell" tabindex="-1" data-h="12"><span class="wt__ev" style="--n:1">점심</span></div>
<div class="wt__c" role="gridcell" tabindex="-1" data-h="12"></div>
</div>
<div class="wt__r" role="row">
<div class="wt__c" role="gridcell" tabindex="-1" data-h="13"></div>
<div class="wt__c" role="gridcell" tabindex="-1" data-h="13"></div>
<div class="wt__c is-today" role="gridcell" tabindex="-1" data-h="13"></div>
<div class="wt__c" role="gridcell" tabindex="-1" data-h="13"></div>
<div class="wt__c" role="gridcell" tabindex="-1" data-h="13"></div>
</div>
</div>
<div class="wt__now" aria-hidden="true"><i class="wt__dot"></i></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-ink);
.wt {
--rowh: clamp(24px, 8.5vw, 32px);
--headh: clamp(12px, 4vw, 16px);
box-sizing: border-box;
width: min(320px, 90vw);
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.wt__body { position: relative; display: flex; gap: $sp-1; }
// 왼쪽 시간 눈금 — 머리에 clock 아이콘
.wt__side {
flex: 0 0 auto;
width: clamp(16px, 5vw, 22px);
display: flex;
flex-direction: column;
text-align: right;
}
.wt__ico {
height: var(--headh);
display: flex;
align-items: center;
justify-content: flex-end;
opacity: .7;
}
.wt__hr {
height: var(--rowh);
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 600;
line-height: 1.1;
opacity: .55;
}
.wt__grid { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
.wt__r {
height: var(--rowh);
display: grid;
grid-template-columns: repeat(5, 1fr);
position: relative;
}
.wt__r--h { height: var(--headh); }
.wt__h {
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
opacity: .6;
}
.wt__h.is-today { opacity: 1; color: $subject-blue-light; } // 먹 무대 위 파란 글자 — 5.69:1
.wt__c {
position: relative;
border-top: 1px solid rgba(255, 255, 255, .12);
border-left: 1px solid rgba(255, 255, 255, .06);
}
.wt__c.is-today { background: rgba(255, 255, 255, .05); }
.wt__c:focus-visible { outline: 2px solid $subject-cream; outline-offset: -2px; }
// 일정 블록 — 시작 시각의 칸 안에서 자기 길이(--n)만큼 아래로 자란다
.wt__ev {
position: absolute;
left: 2px; right: 2px; top: 1px;
z-index: 2;
box-sizing: border-box;
height: calc(var(--rowh) * var(--n) - 2px);
padding: 0 $sp-1;
border-radius: $r-xs;
background: $color;
color: $subject-cream;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: box-shadow $duration $easing;
}
.wt__c:focus-visible .wt__ev { box-shadow: 0 0 0 2px rgba(255, 255, 255, .85); }
.wt__ev--b { background: $subject-cream; color: $stage-ink; }
// 겹치는 두 블록은 폭을 반씩 나눠 나란히 — 폭이 절반이니 글자도 한 눈금 줄인다(ja 가 가장 길다)
.wt__ev--l, .wt__ev--r { font-size: clamp(7px, 2.1vw, 9px); }
.wt__ev--l { right: 50%; }
.wt__ev--r { left: 50%; }
// ── 지금 시각 선: 위에서 아래로 천천히. 0%·100% 를 모두 투명으로 두어 이어 붙인다 ──
.wt__now {
position: absolute;
left: 0; right: 0;
top: var(--headh);
height: 2px;
z-index: 3;
background: $subject-cream;
pointer-events: none;
opacity: 0;
}
.wt__dot {
position: absolute;
left: 0; top: -3px;
display: block;
width: 8px; height: 8px;
border-radius: 50%;
background: $subject-cream;
}
.wt.is-demo .wt__now {
animation-name: wt-now;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
// 첫·끝 프레임을 넉넉히 투명하게 잡아 둔다 — 헤드리스 캡처가 시작 두 프레임을
// 컴포지터 지연으로 늦게 잡는 일이 있어(실측), 0 구간이 짧으면 첫 프레임에 선이 남는다
// transform 은 0%·100% 에만 적어 선이 일정 속도로 내려오게 두고, opacity 만 끝을 물린다
@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; }
}
@media (prefers-reduced-motion: reduce) {
.wt.is-demo .wt__now { animation: none !important; opacity: 1; transform: translateY(calc(var(--rowh) * 2.4)); }
.wt__c { 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);
}
.wt {
--rowh: clamp(24px, 8.5vw, 32px);
--headh: clamp(12px, 4vw, 16px);
box-sizing: border-box;
width: min(320px, 90vw);
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.wt__body {
position: relative;
display: flex;
gap: 4px;
}
.wt__side {
flex: 0 0 auto;
width: clamp(16px, 5vw, 22px);
display: flex;
flex-direction: column;
text-align: right;
}
.wt__ico {
height: var(--headh);
display: flex;
align-items: center;
justify-content: flex-end;
opacity: 0.7;
}
.wt__hr {
height: var(--rowh);
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 600;
line-height: 1.1;
opacity: 0.55;
}
.wt__grid {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
}
.wt__r {
height: var(--rowh);
display: grid;
grid-template-columns: repeat(5, 1fr);
position: relative;
}
.wt__r--h {
height: var(--headh);
}
.wt__h {
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
opacity: 0.6;
}
.wt__h.is-today {
opacity: 1;
color: #5b8cf8;
}
.wt__c {
position: relative;
border-top: 1px solid rgba(255, 255, 255, 0.12);
border-left: 1px solid rgba(255, 255, 255, 0.06);
}
.wt__c.is-today {
background: rgba(255, 255, 255, 0.05);
}
.wt__c:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: -2px;
}
.wt__ev {
position: absolute;
left: 2px;
right: 2px;
top: 1px;
z-index: 2;
box-sizing: border-box;
height: calc(var(--rowh) * var(--n) - 2px);
padding: 0 4px;
border-radius: 4px;
background: #0a4fe8;
color: #fff7e6;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: box-shadow 300ms ease-out;
}
.wt__c:focus-visible .wt__ev {
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85);
}
.wt__ev--b {
background: #fff7e6;
color: #17141a;
}
.wt__ev--l, .wt__ev--r {
font-size: clamp(7px, 2.1vw, 9px);
}
.wt__ev--l {
right: 50%;
}
.wt__ev--r {
left: 50%;
}
.wt__now {
position: absolute;
left: 0;
right: 0;
top: var(--headh);
height: 2px;
z-index: 3;
background: #fff7e6;
pointer-events: none;
opacity: 0;
}
.wt__dot {
position: absolute;
left: 0;
top: -3px;
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff7e6;
}
.wt.is-demo .wt__now {
animation-name: wt-now;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes wt-now {
0% {
transform: translateY(calc(var(--rowh) * 0.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;
}
}
@media (prefers-reduced-motion: reduce) {
.wt.is-demo .wt__now {
animation: none !important;
opacity: 1;
transform: translateY(calc(var(--rowh) * 2.4));
}
.wt__c {
transition: none;
}
}// role="grid" 위에서 화살표 키로 블록 사이를 옮겨 다닌다.
// 지금 시각 선은 사람이 만져도 계속 내려와야 하므로 .is-demo 를 떼지 않는다 (시계지 연출이 아니다).
var grid = document.getElementById('wt-grid');
var cells = [].slice.call(grid.querySelectorAll('.wt__c'));
var COLS = 5;
for (var i = 0; i < cells.length; i++) {
var t = cells[i].textContent.replace(/\s+/g, ' ').trim();
cells[i].setAttribute('aria-label', cells[i].getAttribute('data-h') + ':00' + (t ? ' ' + t : ''));
}
function rove(i) {
for (var k = 0; k < cells.length; k++) cells[k].setAttribute('tabindex', k === i ? '0' : '-1');
cells[i].focus({ preventScroll: true });
}
grid.addEventListener('pointerdown', function (e) {
var c = e.target.closest ? e.target.closest('.wt__c') : null;
if (!c) return;
rove(cells.indexOf(c));
});
grid.addEventListener('keydown', function (e) {
var i = cells.indexOf(document.activeElement);
if (i < 0) return;
var n = i;
if (e.key === 'ArrowLeft') n = i - 1;
else if (e.key === 'ArrowRight') n = i + 1;
else if (e.key === 'ArrowUp') n = i - COLS;
else if (e.key === 'ArrowDown') n = i + COLS;
else if (e.key === 'Home') n = i - (i % COLS);
else if (e.key === 'End') n = i - (i % COLS) + COLS - 1;
else return;
if (n < 0 || n >= cells.length) return;
e.preventDefault();
rove(n);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./WeekTimelineCalendar.module.scss";
export type TimelineEvent = {
/** 열 index (0 = 첫 요일) */
col: number;
/** 시작 시각 — hours 배열의 값 */
hour: number;
/** 길이(시간 단위) */
span: number;
title: string;
/** 같은 시각에 겹치는 두 블록: "l" = 왼쪽 절반, "r" = 오른쪽 절반 */
half?: "l" | "r";
tone?: "a" | "b";
};
export type WeekTimelineCalendarProps = {
hours?: number[];
cols?: string[];
events?: TimelineEvent[];
/** 지금 시각 선이 한 바퀴 도는 시간(ms) */
duration?: number;
easing?: string;
color?: string;
todayCol?: number;
};
type Vars = CSSProperties & {
"--duration"?: string; "--easing"?: string; "--color"?: string; "--n"?: number;
};
type KeyEvt = { key: string; preventDefault: () => void };
const DEFAULT_EVENTS: TimelineEvent[] = [
{ col: 0, hour: 9, span: 1.5, title: "회의" },
{ col: 4, hour: 9, span: 2, title: "회의" },
{ col: 1, hour: 10, span: 1, title: "리뷰", tone: "b" },
{ col: 2, hour: 11, span: 1, title: "리뷰", half: "l" },
{ col: 2, hour: 11, span: 1, title: "회의", half: "r", tone: "b" },
{ col: 3, hour: 12, span: 1, title: "점심" },
];
export default function WeekTimelineCalendar({
hours = [9, 10, 11, 12, 13],
cols = ["14", "15", "16", "17", "18"],
events = DEFAULT_EVENTS,
duration = 2000,
easing = "linear",
color = "#2f6df6",
todayCol = 2,
}: WeekTimelineCalendarProps) {
const [focus, setFocus] = useState(0);
const cellRefs = useRef<(HTMLDivElement | null)[]>([]);
const moved = useRef(false);
const n = cols.length;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
if (!moved.current) return;
const el = cellRefs.current ? cellRefs.current[focus] : null;
if (el) el.focus({ preventScroll: true });
}, [focus]);
function onKeyDown(i: number, e: KeyEvt) {
let k = i;
if (e.key === "ArrowLeft") k = i - 1;
else if (e.key === "ArrowRight") k = i + 1;
else if (e.key === "ArrowUp") k = i - n;
else if (e.key === "ArrowDown") k = i + n;
else if (e.key === "Home") k = i - (i % n);
else if (e.key === "End") k = i - (i % n) + n - 1;
else return;
if (k < 0 || k >= hours.length * n) return;
e.preventDefault();
moved.current = true;
setFocus(k);
}
return (
<div className={styles.wt} style={vars}>
<div className={styles.body}>
<div className={styles.side} aria-hidden="true">
<span className={styles.ico}>
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m64-88a8 8 0 0 1-8 8h-56a8 8 0 0 1-8-8V72a8 8 0 0 1 16 0v48h48a8 8 0 0 1 8 8" />
</g>
</svg>
</span>
{hours.map((h) => <span className={styles.hr} key={h}>{h}</span>)}
</div>
<div className={styles.grid} role="grid">
<div className={`${styles.row} ${styles.rowHead}`} role="row">
{cols.map((c, i) => (
<span
key={c}
className={`${styles.h} ${i === todayCol ? styles.isToday : ""}`}
role="columnheader"
aria-current={i === todayCol ? "date" : undefined}
>
{c}
</span>
))}
</div>
{hours.map((h, r) => (
<div className={styles.row} role="row" key={h}>
{cols.map((c, ci) => {
const i = r * n + ci;
const here = events.filter((e) => e.col === ci && e.hour === h);
return (
<div
key={c}
ref={(el: HTMLDivElement | null) => { if (cellRefs.current) cellRefs.current[i] = el; }}
className={`${styles.cell} ${ci === todayCol ? styles.isToday : ""}`}
role="gridcell"
tabIndex={i === focus ? 0 : -1}
aria-label={`${h}:00 ${here.map((e) => e.title).join(" ")}`.trim()}
onKeyDown={(e: KeyEvt) => onKeyDown(i, e)}
>
{here.map((e, k) => (
<span
key={`${e.title}-${k}`}
className={[
styles.ev,
e.tone === "b" ? styles.evB : "",
e.half === "l" ? styles.evL : "",
e.half === "r" ? styles.evR : "",
].join(" ")}
style={{ "--n": e.span } as Vars}
>
{e.title}
</span>
))}
</div>
);
})}
</div>
))}
</div>
<div className={styles.now} aria-hidden="true"><i className={styles.dot} /></div>
</div>
</div>
);
}05 · 시간 축 위의 주간 타임라인 — 측정 누적면적 11.513% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
지금 시각 선은 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로 그 칸에 실제로 붙습니다.

<main class="stage">
<div class="dd is-demo">
<div class="dd__head">
<span class="dd__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 108v44a80 80 0 0 1-160 0v-12a20 20 0 0 1 20-20h20V92a20 20 0 0 1 40 0a20 20 0 0 1 40 0v16a20 20 0 0 1 40 0" opacity=".2"/><path d="M188 80a27.8 27.8 0 0 0-13.36 3.4a28 28 0 0 0-46.64-11A28 28 0 0 0 80 92v20H68a28 28 0 0 0-28 28v12a88 88 0 0 0 176 0v-44a28 28 0 0 0-28-28m12 72a72 72 0 0 1-144 0v-12a12 12 0 0 1 12-12h12v24a8 8 0 0 0 16 0V92a12 12 0 0 1 24 0v28a8 8 0 0 0 16 0V92a12 12 0 0 1 24 0v28a8 8 0 0 0 16 0v-12a12 12 0 0 1 24 0Z"/></g></svg></span>
<span class="dd__title">2026. 9</span>
<span class="dd__live" id="dd-live" role="status" aria-live="polite"></span>
</div>
<div class="dd__dow" aria-hidden="true"><span>월</span><span>화</span><span>수</span><span>목</span><span>금</span></div>
<div class="dd__grid" id="dd-grid" role="grid" aria-label="일정 옮기기 달력">
<div class="dd__r" role="row">
<div class="dd__c" role="gridcell" data-d="7"><span class="dd__n">7</span></div>
<div class="dd__c dd__c--from" role="gridcell" data-d="8"><span class="dd__n">8</span><button class="dd__chip dd__chip--fly" type="button" aria-grabbed="false"><span class="dd__grip" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg></span><span class="dd__t">리뷰</span></button></div>
<div class="dd__c dd__c--to" role="gridcell" data-d="9"><span class="dd__n">9</span></div>
<div class="dd__c" role="gridcell" data-d="10"><span class="dd__n">10</span></div>
<div class="dd__c" role="gridcell" data-d="11"><span class="dd__n">11</span></div>
</div>
<div class="dd__r" role="row">
<div class="dd__c" role="gridcell" data-d="14"><span class="dd__n">14</span></div>
<div class="dd__c" role="gridcell" data-d="15" aria-current="date"><span class="dd__n is-today">15</span></div>
<div class="dd__c" role="gridcell" data-d="16"><span class="dd__n">16</span><button class="dd__chip" type="button" aria-grabbed="false"><span class="dd__grip" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg></span><span class="dd__t">배포</span></button></div>
<div class="dd__c" role="gridcell" data-d="17"><span class="dd__n">17</span></div>
<div class="dd__c" role="gridcell" data-d="18"><span class="dd__n">18</span></div>
</div>
<div class="dd__r" role="row">
<div class="dd__c" role="gridcell" data-d="21"><span class="dd__n">21</span></div>
<div class="dd__c" role="gridcell" data-d="22"><span class="dd__n">22</span></div>
<div class="dd__c" role="gridcell" data-d="23"><span class="dd__n">23</span></div>
<div class="dd__c" role="gridcell" data-d="24"><span class="dd__n">24</span><button class="dd__chip" type="button" aria-grabbed="false"><span class="dd__grip" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg></span><span class="dd__t">회고</span></button></div>
<div class="dd__c" role="gridcell" data-d="25"><span class="dd__n">25</span></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.dd {
// 한 칸 옆으로 옮기는 거리 = 칸 폭 + gap. 격자 폭이 min(320px, 90vw) 이므로 계산으로 얻는다
--step: calc((min(320px, 90vw) + #{$sp-1}) / 5);
box-sizing: border-box;
width: min(320px, 90vw);
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dd__head { display: flex; align-items: center; gap: $sp-2; margin: 0 0 $sp-2; }
.dd__ico { display: inline-flex; color: $color; }
.dd__title { font-size: clamp(11px, 3.4vw, 14px); font-weight: 700; }
.dd__live {
margin-left: auto;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
color: $subject-blue-deep; // 밝은 면 위 파란 글자 — 5.74:1
white-space: nowrap;
}
.dd__dow {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: $sp-1;
margin: 0 0 $sp-1;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
text-align: center;
color: rgba(23, 20, 26, .72); // AA 4.5 (.45 는 2.97:1 이었다)
}
.dd__grid { display: flex; flex-direction: column; gap: $sp-1; }
.dd__r { display: grid; grid-template-columns: repeat(5, 1fr); gap: $sp-1; }
.dd__c {
position: relative;
box-sizing: border-box;
height: clamp(34px, 10.5vw, 42px);
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
}
// 받을 준비 — 점선 테두리
.dd__c::after {
content: "";
position: absolute;
inset: 0;
border: 2px dashed $color;
border-radius: $r-sm;
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing;
}
.dd__c.is-over::after { opacity: 1; }
.dd__n {
position: absolute;
top: $sp-1; left: $sp-1;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
color: rgba(23, 20, 26, .72); // AA 4.5 (.5 는 3.37:1 이었다)
}
.dd__n.is-today {
color: $subject-blue-deep; // 밝은 면 위 파란 글자 — 5.74:1
text-decoration: underline;
text-underline-offset: 2px;
}
.dd__chip {
position: absolute;
left: $sp-1; right: $sp-1; bottom: $sp-1;
box-sizing: border-box;
height: clamp(14px, 4.4vw, 18px);
display: flex;
align-items: center;
gap: $sp-1;
padding: 0 $sp-1;
border: 0;
border-radius: $r-xs;
background: $subject-blue-deep;
color: $subject-cream;
font-family: inherit;
font-size: clamp(8px, 2.3vw, 9px);
font-weight: 700;
line-height: 1;
cursor: grab;
touch-action: none;
overflow: hidden;
white-space: nowrap;
transition: box-shadow $duration $easing, transform $duration $easing;
}
.dd__grip { display: inline-flex; flex: 0 0 auto; opacity: .75; }
.dd__t { overflow: hidden; text-overflow: ellipsis; }
.dd__chip:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// 집힌 상태 — 떠서 기울고 그림자가 깊어진다. pointer-events 를 끊어야 밑의 칸을 집어낼 수 있다
.dd__chip.is-grab {
z-index: 5;
transform: translateY(-6px) scale(1.06) rotate(-4deg);
box-shadow: $shadow-raised;
cursor: grabbing;
pointer-events: none;
}
// ── 자동 루프: 칩 하나가 옆 칸으로 떠서 옮겨갔다 돌아온다 (2s) ──
.dd.is-demo .dd__chip--fly {
z-index: 5;
animation-name: dd-fly;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.dd.is-demo .dd__c--to::after {
animation-name: dd-ring;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dd.is-demo .dd__c--from::after {
animation-name: dd-ring-back;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dd-fly {
0%, 6% { transform: translate(0, 0) scale(1) rotate(0deg); box-shadow: 0 0 0 rgba(23, 20, 26, 0); }
20% { transform: translate(0, -6px) scale(1.06) rotate(-4deg); box-shadow: $shadow-raised; }
40% { transform: translate(var(--step), -6px) scale(1.06) rotate(-4deg); box-shadow: $shadow-raised; }
50%, 58% { transform: translate(var(--step), 0) scale(1) rotate(0deg); box-shadow: 0 0 0 rgba(23, 20, 26, 0); }
70% { transform: translate(var(--step), -6px) scale(1.06) rotate(4deg); box-shadow: $shadow-raised; }
88% { transform: translate(0, -6px) scale(1.06) rotate(4deg); box-shadow: $shadow-raised; }
100% { transform: translate(0, 0) scale(1) rotate(0deg); box-shadow: 0 0 0 rgba(23, 20, 26, 0); }
}
@keyframes dd-ring {
0%, 13% { opacity: 0; }
14%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
@keyframes dd-ring-back {
0%, 61% { opacity: 0; }
62%, 93% { opacity: 1; }
94%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.dd.is-demo .dd__chip--fly,
.dd.is-demo .dd__c--to::after,
.dd.is-demo .dd__c--from::after { animation: none !important; }
.dd__chip, .dd__c::after { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dd {
--step: calc((min(320px, 90vw) + 4px) / 5);
box-sizing: border-box;
width: min(320px, 90vw);
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dd__head {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px;
}
.dd__ico {
display: inline-flex;
color: #2f6df6;
}
.dd__title {
font-size: clamp(11px, 3.4vw, 14px);
font-weight: 700;
}
.dd__live {
margin-left: auto;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
color: #0a4fe8;
white-space: nowrap;
}
.dd__dow {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
margin: 0 0 4px;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
text-align: center;
color: rgba(23, 20, 26, 0.72);
}
.dd__grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.dd__r {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
}
.dd__c {
position: relative;
box-sizing: border-box;
height: clamp(34px, 10.5vw, 42px);
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.dd__c::after {
content: "";
position: absolute;
inset: 0;
border: 2px dashed #2f6df6;
border-radius: 8px;
opacity: 0;
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dd__c.is-over::after {
opacity: 1;
}
.dd__n {
position: absolute;
top: 4px;
left: 4px;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
color: rgba(23, 20, 26, 0.72);
}
.dd__n.is-today {
color: #0a4fe8;
text-decoration: underline;
text-underline-offset: 2px;
}
.dd__chip {
position: absolute;
left: 4px;
right: 4px;
bottom: 4px;
box-sizing: border-box;
height: clamp(14px, 4.4vw, 18px);
display: flex;
align-items: center;
gap: 4px;
padding: 0 4px;
border: 0;
border-radius: 4px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-size: clamp(8px, 2.3vw, 9px);
font-weight: 700;
line-height: 1;
cursor: grab;
touch-action: none;
overflow: hidden;
white-space: nowrap;
transition: box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dd__grip {
display: inline-flex;
flex: 0 0 auto;
opacity: 0.75;
}
.dd__t {
overflow: hidden;
text-overflow: ellipsis;
}
.dd__chip:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.dd__chip.is-grab {
z-index: 5;
transform: translateY(-6px) scale(1.06) rotate(-4deg);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grabbing;
pointer-events: none;
}
.dd.is-demo .dd__chip--fly {
z-index: 5;
animation-name: dd-fly;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.dd.is-demo .dd__c--to::after {
animation-name: dd-ring;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dd.is-demo .dd__c--from::after {
animation-name: dd-ring-back;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dd-fly {
0%, 6% {
transform: translate(0, 0) scale(1) rotate(0deg);
box-shadow: 0 0 0 rgba(23, 20, 26, 0);
}
20% {
transform: translate(0, -6px) scale(1.06) rotate(-4deg);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
40% {
transform: translate(var(--step), -6px) scale(1.06) rotate(-4deg);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
50%, 58% {
transform: translate(var(--step), 0) scale(1) rotate(0deg);
box-shadow: 0 0 0 rgba(23, 20, 26, 0);
}
70% {
transform: translate(var(--step), -6px) scale(1.06) rotate(4deg);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
88% {
transform: translate(0, -6px) scale(1.06) rotate(4deg);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
100% {
transform: translate(0, 0) scale(1) rotate(0deg);
box-shadow: 0 0 0 rgba(23, 20, 26, 0);
}
}
@keyframes dd-ring {
0%, 13% {
opacity: 0;
}
14%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
@keyframes dd-ring-back {
0%, 61% {
opacity: 0;
}
62%, 93% {
opacity: 1;
}
94%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.dd.is-demo .dd__chip--fly,
.dd.is-demo .dd__c--to::after,
.dd.is-demo .dd__c--from::after {
animation: none !important;
}
.dd__chip, .dd__c::after {
transition: none;
}
}// 포인터: pointerdown 으로 집고 pointermove 로 끌고 pointerup 에서 그 칸에 appendChild 한다.
// 키보드: 칩에 포커스 → Space 로 집기 → 화살표로 칸 이동 → Space 로 놓기 (Escape 취소).
var root = document.querySelector('.dd');
var grid = document.getElementById('dd-grid');
var live = document.getElementById('dd-live');
var cells = [].slice.call(grid.querySelectorAll('.dd__c'));
var COLS = 5;
var MSG_GRAB = '집었습니다';
var MSG_DROP = '놓았습니다';
var drag = null, held = null, cur = 0;
function armCells(on) {
for (var i = 0; i < cells.length; i++) {
if (on) cells[i].setAttribute('aria-dropeffect', 'move');
else { cells[i].removeAttribute('aria-dropeffect'); cells[i].classList.remove('is-over'); }
}
}
function drop(chip, cell) {
if (cell && cell !== chip.parentNode) { cell.appendChild(chip); live.textContent = MSG_DROP + ' ' + cell.getAttribute('data-d'); }
else live.textContent = MSG_DROP;
}
// ── 포인터 ──
grid.addEventListener('pointerdown', function (e) {
var chip = e.target.closest ? e.target.closest('.dd__chip') : null;
if (!chip) return;
root.classList.remove('is-demo');
drag = { chip: chip, x: e.clientX, y: e.clientY, over: null };
chip.classList.add('is-grab');
chip.setAttribute('aria-grabbed', 'true');
armCells(true);
live.textContent = MSG_GRAB;
e.preventDefault();
});
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');
}
});
document.addEventListener('pointerup', function () {
if (!drag) return;
var chip = drag.chip, cell = drag.over;
chip.style.transform = '';
chip.classList.remove('is-grab');
chip.setAttribute('aria-grabbed', 'false');
armCells(false);
drop(chip, cell);
drag = null;
chip.focus({ preventScroll: true });
});
// ── 키보드 ──
grid.addEventListener('keydown', function (e) {
var chip = e.target.closest ? e.target.closest('.dd__chip') : null;
if (!chip) return;
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
root.classList.remove('is-demo');
if (held === chip) {
drop(chip, cells[cur]);
chip.classList.remove('is-grab');
chip.setAttribute('aria-grabbed', 'false');
armCells(false);
held = null;
chip.focus({ preventScroll: true });
} else {
held = chip;
cur = cells.indexOf(chip.parentNode);
chip.classList.add('is-grab');
chip.setAttribute('aria-grabbed', 'true');
armCells(true);
cells[cur].classList.add('is-over');
live.textContent = MSG_GRAB;
}
return;
}
if (e.key === 'Escape' && held === chip) {
chip.classList.remove('is-grab');
chip.setAttribute('aria-grabbed', 'false');
armCells(false);
held = null;
return;
}
if (held !== chip) return;
var n = cur;
if (e.key === 'ArrowLeft') n = cur - 1;
else if (e.key === 'ArrowRight') n = cur + 1;
else if (e.key === 'ArrowUp') n = cur - COLS;
else if (e.key === 'ArrowDown') n = cur + COLS;
else return;
if (n < 0 || n >= cells.length) return;
e.preventDefault();
cells[cur].classList.remove('is-over');
cur = n;
cells[cur].classList.add('is-over');
live.textContent = cells[cur].getAttribute('data-d');
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DragDropCalendar.module.scss";
export type DragDropEvent = { id: string; title: string; day: number };
export type DragDropCalendarProps = {
/** 칸에 붙일 날짜 라벨 (3행 × 5열 = 15칸) */
days?: number[];
events?: DragDropEvent[];
today?: number;
duration?: number;
easing?: string;
color?: string;
onMove?: (id: string, day: number) => void;
/** aria-live 안내 문구 — 언어팩에서 넣는다 */
labels?: { grabbed: string; dropped: string };
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type PtrEvt = { clientX: number; clientY: number; preventDefault: () => void };
const COLS = 5;
const DEFAULT_DAYS = [7, 8, 9, 10, 11, 14, 15, 16, 17, 18, 21, 22, 23, 24, 25];
const DEFAULT_EVENTS: DragDropEvent[] = [
{ id: "a", title: "리뷰", day: 8 },
{ id: "b", title: "배포", day: 16 },
{ id: "c", title: "회고", day: 24 },
];
function Grip() {
return (
<span className={styles.grip} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2" />
<path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12" />
</g>
</svg>
</span>
);
}
export default function DragDropCalendar({
days = DEFAULT_DAYS,
events = DEFAULT_EVENTS,
today = 15,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
onMove,
labels = { grabbed: "집었습니다", dropped: "놓았습니다" },
}: DragDropCalendarProps) {
const [placed, setPlaced] = useState<DragDropEvent[]>(events);
const [grabbed, setGrabbed] = useState<string | null>(null);
const [over, setOver] = useState<number>(-1);
const [offset, setOffset] = useState<{ x: number; y: number } | null>(null);
const [say, setSay] = useState("");
const chipRefs = useRef<{ [id: string]: HTMLButtonElement | null }>({});
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
if (!grabbed) return;
const el = chipRefs.current ? chipRefs.current[grabbed] : null;
if (el) el.focus({ preventScroll: true });
}, [grabbed]);
function place(id: string, day: number) {
setPlaced((ps) => ps.map((p) => (p.id === id ? { ...p, day } : p)));
setSay(`${labels.dropped} ${day}`);
if (onMove) onMove(id, day);
}
function dayFromPoint(x: number, y: number): number {
const el = document.elementFromPoint(x, y);
const cell = el && (el as HTMLElement).closest ? (el as HTMLElement).closest("[data-day]") : null;
return cell ? Number((cell as HTMLElement).dataset.day) : -1;
}
function onPointerDown(id: string, e: PtrEvt) {
e.preventDefault();
setGrabbed(id);
setSay(labels.grabbed);
const x0 = e.clientX;
const y0 = e.clientY;
setOffset({ x: 0, y: 0 });
let last = -1;
const onMovePtr = (ev: { clientX: number; clientY: number }) => {
setOffset({ x: ev.clientX - x0, y: ev.clientY - y0 });
last = dayFromPoint(ev.clientX, ev.clientY);
setOver(last);
};
const onUp = () => {
document.removeEventListener("pointermove", onMovePtr);
document.removeEventListener("pointerup", onUp);
if (last >= 0) place(id, last);
setGrabbed(null);
setOver(-1);
setOffset(null);
};
document.addEventListener("pointermove", onMovePtr);
document.addEventListener("pointerup", onUp);
}
function onKeyDown(id: string, day: number, e: KeyEvt) {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
if (grabbed === id) {
if (over >= 0) place(id, days[over]);
setGrabbed(null);
setOver(-1);
} else {
setGrabbed(id);
setOver(days.indexOf(day));
setSay(labels.grabbed);
}
return;
}
if (e.key === "Escape" && grabbed === id) { setGrabbed(null); setOver(-1); return; }
if (grabbed !== id || over < 0) return;
let n = over;
if (e.key === "ArrowLeft") n = over - 1;
else if (e.key === "ArrowRight") n = over + 1;
else if (e.key === "ArrowUp") n = over - COLS;
else if (e.key === "ArrowDown") n = over + COLS;
else return;
if (n < 0 || n >= days.length) return;
e.preventDefault();
setOver(n);
setSay(String(days[n]));
}
const rows: number[][] = [];
for (let r = 0; r * COLS < days.length; r += 1) rows.push(days.slice(r * COLS, r * COLS + COLS));
return (
<div className={styles.dd} style={vars}>
<div className={styles.head}>
<span className={styles.title}>2026. 9</span>
<span className={styles.live} role="status" aria-live="polite">{say}</span>
</div>
<div className={styles.grid} role="grid">
{rows.map((row) => (
<div className={styles.row} role="row" key={row[0]}>
{row.map((d) => {
const i = days.indexOf(d);
const isOver = grabbed !== null && over === i;
return (
<div
key={d}
data-day={d}
className={`${styles.cell} ${isOver ? styles.isOver : ""}`}
role="gridcell"
aria-current={d === today ? "date" : undefined}
aria-dropeffect={grabbed ? "move" : undefined}
>
<span className={`${styles.n} ${d === today ? styles.isToday : ""}`}>{d}</span>
{placed.filter((p) => p.day === d).map((p) => {
const held = grabbed === p.id;
const style: CSSProperties = held && offset
? { transform: `translate(${offset.x}px, ${offset.y - 6}px) scale(1.06) rotate(-4deg)` }
: {};
return (
<button
key={p.id}
ref={(el: HTMLButtonElement | null) => { if (chipRefs.current) chipRefs.current[p.id] = el; }}
type="button"
className={`${styles.chip} ${held ? styles.isGrab : ""}`}
style={style}
aria-grabbed={held}
onPointerDown={(e: PtrEvt) => onPointerDown(p.id, e)}
onKeyDown={(e: KeyEvt) => onKeyDown(p.id, d, e)}
>
<Grip />
<span className={styles.t}>{p.title}</span>
</button>
);
})}
</div>
);
})}
</div>
))}
</div>
</div>
);
}06 · 집어서 옮기는 일정 카드 — 측정 누적면적 4.262% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
칩을 Space나 Enter로 집고 화살표로 칸을 고른 뒤 다시 눌러 놓는 키보드 조작도 함께 있어서, 마우스 없이도 일정이 옮겨집니다.
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열의 작은 네모로 깔리고 활동이 많은 날일수록 진해집니다. 색 단계는 무대색에 잉크를 섞어 가다 마지막 하나만 잉크 그대로인 다섯 단계이고, 한 행이 한 요일·한 열이 한 주라 53열만 명시하고 나머지는 기본 행 흐름에 맡깁니다.

<main class="stage">
<section class="hm is-demo" id="hm" aria-label="2026년 활동 히트맵">
<div class="hm__months" id="hm-months" aria-hidden="true"></div>
<div class="hm__board" id="hm-board" role="grid" aria-label="2026년 활동"></div>
<footer class="hm__foot">
<span class="hm__year"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M216 48v40H40V48a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 32h-24v-8a8 8 0 0 0-16 0v8H88v-8a8 8 0 0 0-16 0v8H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16M72 48v8a8 8 0 0 0 16 0v-8h80v8a8 8 0 0 0 16 0v-8h24v32H48V48Zm136 160H48V96h160zm-68-76a12 12 0 1 1-12-12a12 12 0 0 1 12 12m44 0a12 12 0 1 1-12-12a12 12 0 0 1 12 12m-88 40a12 12 0 1 1-12-12a12 12 0 0 1 12 12m44 0a12 12 0 1 1-12-12a12 12 0 0 1 12 12m44 0a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg>2026</span>
<p class="hm__legend"><span>적음</span><i class="hm__sw hm__sw--1" aria-hidden="true"></i><i class="hm__sw hm__sw--2" aria-hidden="true"></i><i class="hm__sw hm__sw--3" aria-hidden="true"></i><i class="hm__sw hm__sw--4" aria-hidden="true"></i><span>많음</span></p>
</footer>
<span class="hm__live" role="status" id="hm-live"></span>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@use "sass:color";
$duration: $dur-quick; // 칸 팝 하나의 길이 (참조용 — 루프는 $dur-loop)
$easing: $ease-spring;
$color: $subject-ink; // 옐로 무대 위 잉크 피사체
@include stage($stage-yellow);
.hm {
width: min(100%, 432px);
}
// 월 눈금 — 아래 격자와 같은 53열
.hm__months {
display: grid;
grid-template-columns: repeat(53, 1fr);
margin: 0 0 $sp-1;
color: color.mix($color, $stage-yellow, 85%); // 60% 는 4.22:1 이었다 — AA 4.5 (8.96:1)
font-size: clamp(7px, 1.8vw, 9px);
line-height: 1;
font-weight: 700;
}
// 7행 × 53열 — 한 행이 한 요일, 한 열이 한 주
// 행(role="row")이 요일이라 DOM 도 요일 단위다. 열 우선 흐름(grid-auto-flow: column)을 쓰면
// DOM 순서 그대로 같은 요일 7칸이 한 열에 쌓여 격자가 전치된다 — 53열을 명시하고 기본 행 흐름으로 채운다.
.hm__board {
display: grid;
grid-template-columns: repeat(53, 1fr);
}
.hm__row { display: contents; } // 시맨틱 행은 남기고 격자에는 칸만 참여
.hm__cell,
.hm__blank {
aspect-ratio: 1;
border-radius: 0;
}
.hm__cell {
background: color.mix($color, $stage-yellow, 14%);
box-shadow: inset 0 0 0 1px $stage-yellow; // 칸 사이 1px 무대색 실린더
transform-origin: center;
}
.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; }
.hm__cell:focus-visible {
outline: 2px solid $color;
outline-offset: 1px;
}
.hm__cell.is-today {
outline: 2px solid $color;
outline-offset: 1px;
}
.hm__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
margin: $sp-2 0 0;
}
.hm__year {
display: inline-flex;
align-items: center;
gap: $sp-1;
font-size: 11px;
line-height: 1;
font-weight: 800;
color: $color;
}
.hm__legend {
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: 0;
font-size: 10px;
line-height: 1;
font-weight: 700;
color: color.mix($color, $stage-yellow, 85%);
}
.hm__sw {
width: 10px;
height: 10px;
border-radius: $r-xs;
box-shadow: inset 0 0 0 1px color.mix($color, $stage-yellow, 30%);
}
.hm__sw--1 { background: color.mix($color, $stage-yellow, 30%); }
.hm__sw--2 { background: color.mix($color, $stage-yellow, 48%); }
.hm__sw--3 { background: color.mix($color, $stage-yellow, 70%); }
.hm__sw--4 { background: $color; }
.hm__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
// ── 자동 루프: 왼쪽 열부터 차례로 찬다 → 전부 찬다 → 같은 순서로 빠진다.
// 0% 와 100% 가 같은 상태(빈 판)라 이어 붙여도 텔레포트가 없다.
// 시차는 칸마다 인라인 animation-delay — 속성은 낱개로만 쓴다(단축은 delay 를 0s 로 리셋).
.hm.is-demo .hm__cell {
animation-name: hm-cell;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes hm-cell {
0%, 4.5% { transform: scale(0); }
9%, 52% { transform: scale(1); }
56.5%, 100% { transform: scale(0); }
}
@media (prefers-reduced-motion: reduce) {
.hm.is-demo .hm__cell { 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hm {
width: min(100%, 432px);
}
.hm__months {
display: grid;
grid-template-columns: repeat(53, 1fr);
margin: 0 0 4px;
color: rgb(22.6666666667%, 19.0196078431%, 12.3725490196%);
font-size: clamp(7px, 1.8vw, 9px);
line-height: 1;
font-weight: 700;
}
.hm__board {
display: grid;
grid-template-columns: repeat(53, 1fr);
}
.hm__row {
display: contents;
}
.hm__cell,
.hm__blank {
aspect-ratio: 1;
border-radius: 0;
}
.hm__cell {
background: rgb(87.262745098%, 71.9215686275%, 22.6745098039%);
box-shadow: inset 0 0 0 1px #ffd23f;
transform-origin: center;
}
.hm__cell.lv1 {
background: rgb(72.7058823529%, 60%, 20.3529411765%);
}
.hm__cell.lv2 {
background: rgb(56.3294117647%, 46.5882352941%, 17.7411764706%);
}
.hm__cell.lv3 {
background: rgb(36.3137254902%, 30.1960784314%, 14.5490196078%);
}
.hm__cell.lv4 {
background: #17141a;
}
.hm__cell:focus-visible {
outline: 2px solid #17141a;
outline-offset: 1px;
}
.hm__cell.is-today {
outline: 2px solid #17141a;
outline-offset: 1px;
}
.hm__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 8px 0 0;
}
.hm__year {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
line-height: 1;
font-weight: 800;
color: #17141a;
}
.hm__legend {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
font-size: 10px;
line-height: 1;
font-weight: 700;
color: rgb(22.6666666667%, 19.0196078431%, 12.3725490196%);
}
.hm__sw {
width: 10px;
height: 10px;
border-radius: 4px;
box-shadow: inset 0 0 0 1px rgb(72.7058823529%, 60%, 20.3529411765%);
}
.hm__sw--1 {
background: rgb(72.7058823529%, 60%, 20.3529411765%);
}
.hm__sw--2 {
background: rgb(56.3294117647%, 46.5882352941%, 17.7411764706%);
}
.hm__sw--3 {
background: rgb(36.3137254902%, 30.1960784314%, 14.5490196078%);
}
.hm__sw--4 {
background: #17141a;
}
.hm__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.hm.is-demo .hm__cell {
animation-name: hm-cell;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes hm-cell {
0%, 4.5% {
transform: scale(0);
}
9%, 52% {
transform: scale(1);
}
56.5%, 100% {
transform: scale(0);
}
}
@media (prefers-reduced-motion: reduce) {
.hm.is-demo .hm__cell {
animation: none !important;
}
}// 실제 동작: 1년치 칸을 만들고, 화살표(←→ 주 · ↑↓ 요일)로 옮기고, 포커스를 읽어 준다.
var root = document.getElementById('hm');
var board = document.getElementById('hm-board');
var months = document.getElementById('hm-months');
var live = document.getElementById('hm-live');
var START = new Date(2025, 11, 29); // 1월 1일이 속한 주의 월요일 — 격자의 첫 칸
var NCOLS = 53, TODAY = new Date(2026, 8, 12);
var cells = []; // DOM 순서(행=요일 × 53열), 빈칸은 null
var seed = 7;
function rnd() { seed = (seed * 48271) % 2147483647; return seed / 2147483647; }
function levelOf(d) {
var v = rnd(), dow = d.getDay(), m = d.getMonth();
if (dow === 0) v *= .35; // 일요일은 쉰다
else if (dow === 6) v *= .55;
if (m === 6 || (m === 7 && d.getDate() < 15)) v *= .2; // 여름 공백
if (m === 11) v = Math.min(1, v * 1.4 + .1); // 12월 마감 몰림
if (rnd() < .12) v = Math.min(1, v + .45); // 가끔 폭발
return { level: v < .16 ? 0 : v < .36 ? 1 : v < .56 ? 2 : v < .76 ? 3 : 4, count: Math.round(v * 9) };
}
function pad(n) { return n < 10 ? '0' + n : '' + n; }
for (var d = 0; d < 7; d++) {
var row = document.createElement('div');
row.className = 'hm__row';
row.setAttribute('role', 'row');
for (var w = 0; w < NCOLS; w++) {
var date = new Date(START.getFullYear(), START.getMonth(), START.getDate() + w * 7 + d);
if (date.getFullYear() !== 2026) {
var blank = document.createElement('span');
blank.className = 'hm__blank';
blank.setAttribute('aria-hidden', 'true');
row.appendChild(blank); cells.push(null); continue;
}
var r = levelOf(date), cell = document.createElement('span');
cell.className = 'hm__cell lv' + r.level;
cell.setAttribute('role', 'gridcell');
cell.setAttribute('aria-label', '2026-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + ' · ' + r.count);
cell.tabIndex = -1;
// 시차: 왼쪽 열부터 0.7초에 걸쳐 차례로 (낱개 속성 — animation 단축은 delay 를 0s 로 리셋한다)
cell.style.animationDelay = (w * 13.5).toFixed(1) + 'ms';
if (+date === +TODAY) { cell.classList.add('is-today'); cell.setAttribute('aria-current', 'date'); }
row.appendChild(cell); cells.push(cell);
}
board.appendChild(row);
}
for (var m = 1; m <= 12; m++) { // 월 눈금 — 열 위치에 맞춰 놓는다
var w0 = Math.floor((new Date(2026, m - 1, 1) - START) / 6048e5);
var w1 = m < 12 ? Math.floor((new Date(2026, m, 1) - START) / 6048e5) : NCOLS;
var tick = document.createElement('span');
tick.textContent = m;
tick.style.gridColumn = (w0 + 1) + ' / ' + (w1 + 1);
months.appendChild(tick);
}
var cur = 0;
while (cur < cells.length && !cells[cur]) cur++;
cells[cur].tabIndex = 0;
function move(n) {
cells[cur].tabIndex = -1;
cur = n;
cells[cur].tabIndex = 0;
cells[cur].focus({ preventScroll: true });
live.textContent = cells[cur].getAttribute('aria-label');
}
function step(i, delta) { // 빈칸은 건너뛴다
i += delta;
while (i >= 0 && i < cells.length && !cells[i]) i += delta;
return i >= 0 && i < cells.length ? i : null;
}
board.addEventListener('keydown', function (e) {
var k = e.key, n = null, base = cur - (cur % NCOLS);
if (k === 'ArrowRight') n = step(cur, 1);
else if (k === 'ArrowLeft') n = step(cur, -1);
else if (k === 'ArrowDown') n = step(cur, NCOLS);
else if (k === 'ArrowUp') n = step(cur, -NCOLS);
else if (k === 'Home') n = step(base - 1, 1);
else if (k === 'End') n = step(base + NCOLS, -1);
if (n !== null && n !== cur) { e.preventDefault(); root.classList.remove('is-demo'); move(n); }
});
board.addEventListener('click', function (e) {
var t = e.target.closest ? e.target.closest('.hm__cell') : null;
if (!t) return;
root.classList.remove('is-demo');
for (var i = 0; i < cells.length; i++) if (cells[i] === t) { move(i); break; }
});import { useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HeatmapCalendar.module.scss";
export type HeatmapCalendarProps = {
year?: number;
demo?: boolean;
duration?: number; // 루프 한 바퀴 ms — 칸 시차 폭이 이 값에 비례한다
easing?: string;
color?: string; // 칸 색 (투명도 단계로 진하기를 낸다)
stage?: string; // 칸 사이 이격 실린더 색 (무대 배경색)
};
type KeyEvt = { key: string; preventDefault: () => void };
type Cell = { week: number; level: number; label: string; today: boolean };
type Pos = [number, number];
type Vars = CSSProperties & { "--color"?: string; "--stage"?: string };
const LEVEL_OPACITY = [0.14, 0.3, 0.48, 0.7, 1];
function pad(n: number): string {
return n < 10 ? "0" + n : "" + n;
}
export default function HeatmapCalendar({
year = 2026,
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
stage = "#ffd23f",
}: HeatmapCalendarProps) {
const [demoOn, setDemoOn] = useState(demo);
const cellRefs = useRef<(HTMLSpanElement | null)[][]>([]);
const [cur, setCur] = useState<Pos | null>(null);
const { rows, months, first } = useMemo(() => {
const jan1 = new Date(year, 0, 1);
const start = new Date(jan1);
start.setDate(jan1.getDate() - ((jan1.getDay() + 6) % 7)); // 1월 1일이 속한 주의 월요일
let seed = 7;
const rnd = () => {
seed = (seed * 48271) % 2147483647;
return seed / 2147483647;
};
const now = new Date();
const grid: (Cell | null)[][] = [];
for (let d = 0; d < 7; d++) {
const row: (Cell | null)[] = [];
for (let w = 0; w < 53; w++) {
const date = new Date(start.getFullYear(), start.getMonth(), start.getDate() + w * 7 + d);
if (date.getFullYear() !== year) {
row.push(null);
continue;
}
let v = rnd();
const dow = date.getDay(), m = date.getMonth();
if (dow === 0) v *= 0.35; // 일요일은 쉰다
else if (dow === 6) v *= 0.55;
if (m === 6 || (m === 7 && date.getDate() < 15)) v *= 0.2; // 여름 공백
if (m === 11) v = Math.min(1, v * 1.4 + 0.1); // 12월 마감 몰림
if (rnd() < 0.12) v = Math.min(1, v + 0.45); // 가끔 폭발
row.push({
week: w,
level: v < 0.16 ? 0 : v < 0.36 ? 1 : v < 0.56 ? 2 : v < 0.76 ? 3 : 4,
label: year + "-" + pad(date.getMonth() + 1) + "-" + pad(date.getDate()) + " · " + Math.round(v * 9),
today: date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate(),
});
}
grid.push(row);
}
let firstCell: Pos = [0, 2];
for (let d = 0; d < 7; d++) {
const w = grid[d].findIndex((c) => c !== null);
if (w >= 0) { firstCell = [d, w]; break; }
}
const ticks: { m: number; col: string }[] = [];
for (let m = 1; m <= 12; m++) {
const w0 = Math.floor((new Date(year, m - 1, 1).getTime() - start.getTime()) / 604800000);
const w1 = m < 12 ? Math.floor((new Date(year, m, 1).getTime() - start.getTime()) / 604800000) : 53;
ticks.push({ m, col: (w0 + 1) + " / " + (w1 + 1) });
}
return { rows: grid, months: ticks, first: firstCell };
}, [year]);
const active: Pos = cur ?? first;
function cellEl(d: number, w: number): HTMLSpanElement | null {
const grid = cellRefs.current;
if (!grid || d < 0 || d > 6 || w < 0 || w > 52) return null;
return grid[d] ? grid[d][w] : null;
}
function walk(d: number, w: number, dd: number, dw: number): Pos | null {
let nd = d + dd, nw = w + dw;
while (nd >= 0 && nd < 7 && nw >= 0 && nw < 53) {
if (rows[nd][nw]) return [nd, nw];
nd += dd;
nw += dw;
}
return null;
}
function move(d: number, w: number) {
setDemoOn(false);
setCur([d, w]);
const el = cellEl(d, w);
if (el) el.focus({ preventScroll: true });
}
function onKeyDown(e: KeyEvt) {
const d = active[0], w = active[1];
let next: Pos | null = null;
if (e.key === "ArrowRight") next = walk(d, w, 0, 1); // ←→ 주 단위
else if (e.key === "ArrowLeft") next = walk(d, w, 0, -1);
else if (e.key === "ArrowDown") next = walk(d, w, 1, 0); // ↑↓ 요일 단위
else if (e.key === "ArrowUp") next = walk(d, w, -1, 0);
else if (e.key === "Home") next = walk(d, 0, 0, 1);
else if (e.key === "End") next = walk(d, 52, 0, -1);
if (next) {
e.preventDefault();
move(next[0], next[1]);
}
}
const vars: Vars = { "--color": color, "--stage": stage };
return (
<section
className={`${styles.hm} ${demoOn ? styles.demo : ""}`}
style={vars}
aria-label={year + " activity heatmap"}
>
<div className={styles.months} aria-hidden="true">
{months.map((t) => (
<span key={t.m} style={{ gridColumn: t.col }}>{t.m}</span>
))}
</div>
<div className={styles.board} role="grid" aria-label={year + " activity"} onKeyDown={onKeyDown}>
{rows.map((row, d) => (
<div key={d} className={styles.row} role="row">
{row.map((c, w) =>
c ? (
<span
key={w}
ref={(el: HTMLSpanElement | null) => {
const grid = cellRefs.current;
if (!grid) return;
if (!grid[d]) grid[d] = [];
grid[d][w] = el;
}}
className={styles.cell}
role="gridcell"
aria-label={c.label}
aria-current={c.today ? "date" : undefined}
tabIndex={active[0] === d && active[1] === w ? 0 : -1}
style={{
opacity: LEVEL_OPACITY[c.level],
animationDelay: ((c.week / 52) * duration * 0.35).toFixed(1) + "ms",
animationTimingFunction: easing,
}}
onClick={() => move(d, w)}
/>
) : (
<span key={w} className={styles.blank} aria-hidden="true" />
)
)}
</div>
))}
</div>
<footer className={styles.foot}>
<span className={styles.year}>{year}</span>
<p className={styles.legend}>
<span>Less</span>
{LEVEL_OPACITY.slice(1).map((o, i) => (
<i key={i} className={styles.sw} style={{ opacity: o }} aria-hidden="true" />
))}
<span>More</span>
</p>
</footer>
</section>
);
}07 · 진하기로 읽는 히트맵 달력 — 측정 누적면적 10.424% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
루프에서는 왼쪽 열부터 차례로 칸이 차오르는데, 칸마다 시차를 주려고 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개월
화면에서 일어나는 일
지난달·이번달·다음달이 축소된 격자 셋으로 나란히 섭니다. 글자 크기를 cqw 단위로 주어서 상자 폭이 줄면 셋이 함께 줄어들기 때문에, 320px에서도 셋이 모두 들어갑니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
선택 범위는 카드가 바뀌어도 같은 상태로 칠해져서, 8월 말에서 10월 초까지 세 달을 건너 하나로 이어집니다.
.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 사이에서 실제로 바꿉니다.

<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">미팅</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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
아이콘 두 개는 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);
어디서 깨지는가 — 함정
첫 번째 함정은 04번에서 측정으로 잡혔습니다. 칸마다 시차를 줄 때 animation-delay만 설정하면, 지연이 풀리는 동안 칸이 이미 칠해진 기본 상태로 서 있다가 애니메이션이 시작되는 순간 빈 칸으로 튀었습니다. 프레임을 겹쳐 비교해 보니 첫 프레임과 마지막 프레임이 한눈에 보일 만큼 어긋나 있었고, animation-fill-mode: backwards를 붙이자 차이가 거의 사라졌습니다. 시차를 준 요소에는 지연뿐 아니라 채움 모드까지 세트로 들어가야 합니다.
두 번째 함정은 무대 크기에서 왔습니다. 320×200 고정 뷰포트에서 문서가 화면보다 길어지지 않으려면 주 행이 다섯 줄 이하여야 하는데, 6주가 필요한 달이 있습니다. 2026년 8월은 토요일에 시작해 31일이라 여섯째 주에 30일과 31일이 남는데, 다섯 줄 고정 격자는 그 이틀을 아예 그리지 않습니다. 데모는 9월을 기준 달로 잡아 이 문제를 피했지만 실제 화면에 붙일 때는 여섯 줄을 감안한 높이 예산을 잡아야 합니다. 달력이 좁은 화면에서 잘리는 문제는 로그인 화면 디자인의 카드가 같은 폭에서 만나는 케이스로 그대로 옮겨옵니다. 이렇게 잡은 아홉 벌 소스는 압축 비밀번호 4zdx6kfa를 넣으면 열리는 zip에 그대로 들어 있습니다. 세 번째로, 05번의 지금 시각 선은 투명 구간이 3%일 때 캡처 첫 프레임에 선이 반쯤 남았습니다. 렌더러가 시작 두 프레임을 컴포지터 지연으로 늦게 잡는 일이 있어서, 투명 구간을 10%와 90%로 넓혔습니다.
접근성
아홉 개 전부 prefers-reduced-motion: reduce에서 자동 재생 루프를 끄고 상태는 남겨 둡니다. 링은 퍼지지 않고 오늘 원만 서 있고, 달은 미끄러지지 않고 즉시 바뀌며, 히트맵은 색을 채운 채 멈춥니다. 격자에는 role="grid", 주 행에는 role="row", 날짜 칸에는 role="gridcell"이 붙고, 선택이 있는 다섯 개는 상태를 aria-selected로, 오늘을 가리키는 여덟 개는 aria-current="date"로 알립니다. 칸 사이 이동은 칩 자체를 집어 옮기는 06번을 빼면 로빙 tabindex 하나로 처리해서 월간 격자에서는 화살표 ±1일·±7일과 Home·End가 실제로 동작하고, 01번의 월 이름은 aria-live="polite" 영역이 읽어 줍니다. 07번은 네모 하나하나에 날짜와 활동 수를 담은 aria-label이 붙습니다. 포커스를 되돌릴 때 focus({ preventScroll: true })를 쓰는 건 이 데모들이 글 속에 iframe으로 박혀 있어서, 그냥 focus()를 부르면 읽는 사람의 화면이 끌려가기 때문입니다. 여기 쓴 역할 이름의 정의는 MDN의 grid 역할 문서에 있습니다.
대시보드에 붙이는 달력 부품은 대시보드 카테고리에, 눌러서 반응하는 부품은 클릭 카테고리에 모아 두었습니다.
FAQ
날짜 고르기 창이 이미 있는데 굳이 캘린더 ui 디자인을 직접 만드는 이유가 있나요?
하루를 고르는 창이라면 없습니다. 직접 만들 필요는 예약·일정 화면처럼 한 달 전체를 보여 주고 일정 칩을 얹거나 며칠을 묶어 골라야 할 때 생깁니다. 그 경우에도 키보드 이동과 역할 이름은 브라우저가 주던 만큼 직접 채워 넣어야 합니다. 달력 없이 명령으로 항목을 여는 방법은 css 검색창 디자인 Cmd+K 팔레트에서 다른 각도로 풀립니다.
폰에서는 7열 격자가 너무 좁아지지 않나요?
320px에서 칸 하나가 40px 안팎으로 줄어드는 건 피할 수 없어서, 09번은 상자 폭에 반응해 일자별 리스트로 접힙니다. 리스트에서도 같은 데이터를 쓰고 화살표 이동도 그 축에 맞게 바뀌므로, 격자를 그대로 줄여 놓는 것보다 읽히는 정보가 많습니다.
일정이 하루에 열 개 몰리면 어떻게 되나요?
03번처럼 두 개까지 칩으로 보이고 나머지는 +N 한 줄로 접는 게 격자 높이를 지키는 방법입니다. 더보기를 누르면 절대배치 카드가 그 칸 아래로 겹쳐 펼쳐지므로, 격자 높이는 그대로이고 이웃한 줄도 움직이지 않습니다.









