02오늘 링과 숫자 배지
오늘 칸에만 링이 한 번 퍼졌다 가라앉고, 그 날의 일정 수가 작은 배지로 칸 모서리에 올라앉습니다.
쓰임달력을 열자마자 '오늘이 어디인지'를 눈으로 1초 안에 찾게 해야 할 때
box-shadowaria-current=datescale

<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 포함 · css 달력 만들기 9종 — 진짜 넘어가는 월간 캘린더
이 데모가 속한 글
css 달력 만들기 9종 — 진짜 넘어가는 월간 캘린더css 달력 만들기는 브라우저가 주는 조그만 날짜 고르기 창 대신, 한 달을 통째로 펼쳐 놓는 격자를 직접 짜는 일입니다. 아홉 개 중 하나는 달을 넘길 때 두 장의 격자가 나란히 미끄러지고, 또 하나는 1년을 네모 이 글의 데모 전부 보기


