02Today ring with count badge
A ring pulses once on today's cell only, and that day's event count rides on the corner as a small badge.
Best forWhen opening the calendar must show where today is within a second
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>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"><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 event">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 events">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 events">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 Today ring with count badge — Measured area 0.972% · moving frames 13/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
If you like this effect - similar demos
Pickers02 Count-badge cluster burst9 Map UI Design Patterns — Click-to-Drop Pickers
Pickers05 Today-marker value toggle — a ring around the cell holding today's value periodically grows while fadingDate Picker UI: 9 Popover & Calendar Patterns
Pickers06 Emoji burst count badge9 Segmented Control UI Patterns — Copy-Paste