03予定チップと「他N件」
1マスに色付きチップが2つまで積まれ、残りは「他N件」に畳まれ、その行を押すと実際に開きます。
使いどころ1日に予定が5件も入ってマスの高さがバラバラに伸びるカレンダーを付けるとき
aria-expandedgrid-template-rows@keyframes more-open

<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 オリジナル · SCSS と React は zip に同梱 · カレンダー css デザイン 9選 — 実際に月が動く
このデモが属する記事
カレンダー css デザイン 9選 — 実際に月が動くカレンダー css デザイン というと、ブラウザが用意する小さな日付ポップアップの色を変えるところで終わりがちです。ここで作る9つは1か月をまるごと広げる月間グリッドそのもので、どれも本当に動きます。1つは月を送るときに2枚のグリッドが一緒 この記事のデモをすべて見る


