03Event chips with +N overflow
Each cell stacks up to two coloured chips, folds the rest into a +N row, and clicking that row really expands them.
Best forFor calendars where five events on one day stretch that row taller than the rest
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>S</span><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</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">Standup</span>
<span class="cal__chip cal__chip--ink">Dentist</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">Gym</span>
<span class="cal__chip cal__chip--blue">Report</span>
<span class="cal__chip cal__chip--ink">Dinner</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 Event chips with +N overflow — Measured area 1.95% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · 9 css calendar design patterns that really move
The post this demo lives in
9 css calendar design patterns that really movecss calendar design often stops at restyling the date picker the browser gives you. These nine build the whole month gri See all demos in this post


