05Today-marker value toggle — a ring around the cell holding today's value periodically grows while fading
A ring around the cell holding today's value grows and fades on a slow cycle while the badge itself pulses slightly in scale, staying distinct even when it overlaps a selected value.
Best forAn attendance-check calendar, a schedule check near a due date
pulseAt()DURZETA

<main class="stage">
<div class="tb is-demo" id="tb">
<div class="tb__head"><span class="tb__label">March 2026</span></div>
<div class="tb__weekday" aria-hidden="true">
<span>Sun</span><span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span><span>Sat</span>
</div>
<div class="tb__grid" id="tbGrid"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html loop() 이 감쇠 진동(지수 감쇠 x 코사인) 공식으로
// 배지 스케일과 링 확장/투명도를 매 프레임 계산한다 — 키프레임 반복이 아니다.
@include stage($stage-ink);
.tb { width: 100%; max-width: 220px; background: $subject-cream; border-radius: $r-card; padding: $sp-2; box-shadow: $shadow-raised; box-sizing: border-box; }
.tb__head { text-align: center; margin-bottom: $sp-1; }
.tb__label { font-size: 11px; font-weight: 700; color: $subject-ink; }
.tb__weekday, .tb__grid { display: grid; grid-template-columns: repeat(7, 1fr); }
.tb__weekday span { font-size: 9px; text-align: center; color: rgba($subject-ink, .72); padding-bottom: $sp-1; }
.tb__cellWrap { position: relative; display: grid; place-items: center; height: 21px; }
.tb__cell { display: grid; place-items: center; width: 100%; height: 100%; font-size: 11px; color: $subject-ink; border-radius: $r-xs; position: relative; z-index: 2; border: none; background: transparent; }
.tb__cell--out { visibility: hidden; }
.tb__cell--today { background: $subject-blue-deep; color: $subject-cream; font-weight: 700; }
.tb__ring {
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
border-radius: $r-xs;
background: $subject-blue-deep;
pointer-events: none;
opacity: 0;
will-change: transform, opacity;
}
.tb__badge { position: relative; z-index: 2; }
@media (max-width: 340px) {
.tb { max-width: 180px; }
.tb__cellWrap { height: 17px; }
.tb__cell { font-size: 10px; }
.tb__weekday span { font-size: 8px; padding-bottom: 0; }
}
@media (prefers-reduced-motion: reduce) {
.tb__ring { display: 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);
}
.tb {
width: 100%;
max-width: 220px;
background: #fff7e6;
border-radius: 16px;
padding: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.tb__head {
text-align: center;
margin-bottom: 4px;
}
.tb__label {
font-size: 11px;
font-weight: 700;
color: #17141a;
}
.tb__weekday, .tb__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.tb__weekday span {
font-size: 9px;
text-align: center;
color: rgba(23, 20, 26, 0.72);
padding-bottom: 4px;
}
.tb__cellWrap {
position: relative;
display: grid;
place-items: center;
height: 21px;
}
.tb__cell {
display: grid;
place-items: center;
width: 100%;
height: 100%;
font-size: 11px;
color: #17141a;
border-radius: 4px;
position: relative;
z-index: 2;
border: none;
background: transparent;
}
.tb__cell--out {
visibility: hidden;
}
.tb__cell--today {
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
}
.tb__ring {
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
border-radius: 4px;
background: #0a4fe8;
pointer-events: none;
opacity: 0;
will-change: transform, opacity;
}
.tb__badge {
position: relative;
z-index: 2;
}
@media (max-width: 340px) {
.tb {
max-width: 180px;
}
.tb__cellWrap {
height: 17px;
}
.tb__cell {
font-size: 10px;
}
.tb__weekday span {
font-size: 8px;
padding-bottom: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.tb__ring {
display: none;
}
}(function () {
var BASE = { y: 2026, m: 3, d: 14 }; // 계산 겹 상수: 고정 오늘, new Date() 미사용
var CELL_TOTAL = 42;
var CYCLE = 1500; // ms, 자동재생이 펄스를 다시 트는 간격
var DUR = 1100; // ms, 감쇠 진동 한 회가 잦아드는 데 걸리는 시간
var FREQ = 2 * Math.PI * 2.3 / DUR; // 계산 겹: 진동 각속도
var ZETA = 4.2; // 계산 겹: 감쇠 상수 — 클수록 빨리 잦아든다
function isLeap(y) { return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; }
function daysInMonth(y, m) {
var t = [31, isLeap(y) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return t[m - 1];
}
function dow(y, m, d) { // Zeller 합동식 — 0=Sun ~ 6=Sat
var yy = y, mm = m;
if (mm < 3) { yy -= 1; mm += 12; }
var K = yy % 100, J = Math.floor(yy / 100);
var h = (d + Math.floor(13 * (mm + 1) / 5) + K + Math.floor(K / 4) + Math.floor(J / 4) - 2 * J) % 7;
h = ((h % 7) + 7) % 7;
return (h + 6) % 7;
}
var tb = document.getElementById('tb');
var grid = document.getElementById('tbGrid');
var n = daysInMonth(BASE.y, BASE.m);
var lead = dow(BASE.y, BASE.m, 1);
var trail = CELL_TOTAL - lead - n;
var i, wrap, cell;
var ring = null, badgeCell = null;
for (i = 0; i < lead; i++) {
wrap = document.createElement('span');
wrap.className = 'tb__cellWrap';
cell = document.createElement('span');
cell.className = 'tb__cell tb__cell--out';
wrap.appendChild(cell);
grid.appendChild(wrap);
}
for (var d = 1; d <= n; d++) {
wrap = document.createElement('span');
wrap.className = 'tb__cellWrap';
if (d === BASE.d) {
ring = document.createElement('span');
ring.className = 'tb__ring';
wrap.appendChild(ring);
cell = document.createElement('button');
cell.type = 'button';
cell.className = 'tb__cell tb__cell--today tb__badge';
cell.textContent = String(d);
wrap.appendChild(cell);
badgeCell = cell;
} else {
cell = document.createElement('span');
cell.className = 'tb__cell';
cell.textContent = String(d);
wrap.appendChild(cell);
}
grid.appendChild(wrap);
}
for (i = 0; i < trail; i++) {
wrap = document.createElement('span');
wrap.className = 'tb__cellWrap';
cell = document.createElement('span');
cell.className = 'tb__cell tb__cell--out';
wrap.appendChild(cell);
grid.appendChild(wrap);
}
var isDemo = true;
var triggerTime = 0;
var rafId = null;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function pulseAt(t) {
// 계산 겹: 지수 감쇠 x 코사인 진동 — 스프링이 잦아드는 물리량을 그대로 계산한다
if (t < 0 || t > DUR) return { ring: 0, ringAlpha: 0, badge: 1 };
var x = t / DUR;
var amp = Math.exp(-ZETA * x);
var osc = Math.cos(FREQ * t);
var ringScale = 1 + 1.9 * (1 - amp);
var ringAlpha = Math.max(0, amp * .55);
var badgeScale = 1 + .16 * amp * osc;
return { ring: ringScale, ringAlpha: ringAlpha, badge: badgeScale };
}
function human() {
tb.classList.remove('is-demo');
isDemo = false;
}
function trigger(ts) {
triggerTime = ts;
}
function loop(ts) {
if (!triggerTime) triggerTime = ts;
var elapsed = ts - triggerTime;
if (isDemo && !reduce && elapsed > CYCLE) {
triggerTime = ts;
elapsed = 0;
}
var p = pulseAt(elapsed);
if (ring) {
ring.style.transform = 'scale(' + p.ring.toFixed(3) + ')';
ring.style.opacity = String(p.ringAlpha.toFixed(3));
}
if (badgeCell) {
badgeCell.style.transform = 'scale(' + p.badge.toFixed(3) + ')';
}
rafId = requestAnimationFrame(loop);
}
badgeCell.addEventListener('click', function (ev) {
human();
trigger(performance.now());
});
badgeCell.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
human();
trigger(performance.now());
});
if (!reduce) {
rafId = requestAnimationFrame(loop);
} else if (ring) {
ring.style.display = 'none';
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './DatePickerTodayPulse.module.scss';
type Box<T> = { current: T };
const BASE = { y: 2026, m: 3, d: 14 }; // 계산 겹 상수: 고정 오늘, new Date() 미사용
const CELL_TOTAL = 42;
const CYCLE = 1500; // ms, 자동재생이 펄스를 다시 트는 간격
const DUR = 1100; // ms, 감쇠 진동 한 회가 잦아드는 데 걸리는 시간
const FREQ = (2 * Math.PI * 2.3) / DUR; // 계산 겹: 진동 각속도
const ZETA = 4.2; // 계산 겹: 감쇠 상수 — 클수록 빨리 잦아든다
const WEEKDAY = ['일', '월', '화', '수', '목', '금', '토'];
function isLeap(y: number) {
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
}
function daysInMonth(y: number, m: number) {
const t = [31, isLeap(y) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return t[m - 1];
}
function dow(y: number, m: number, d: number) { // Zeller 합동식 — 0=일 ~ 6=토
let yy = y;
let mm = m;
if (mm < 3) { yy -= 1; mm += 12; }
const K = yy % 100;
const J = Math.floor(yy / 100);
let h = (d + Math.floor((13 * (mm + 1)) / 5) + K + Math.floor(K / 4) + Math.floor(J / 4) - 2 * J) % 7;
h = ((h % 7) + 7) % 7;
return (h + 6) % 7;
}
const N_DAYS = daysInMonth(BASE.y, BASE.m);
const LEAD = dow(BASE.y, BASE.m, 1);
const TRAIL = CELL_TOTAL - LEAD - N_DAYS;
function pulseAt(t: number) {
// 계산 겹: 지수 감쇠 x 코사인 진동 — 스프링이 잦아드는 물리량을 그대로 계산한다
if (t < 0 || t > DUR) return { ring: 0, ringAlpha: 0, badge: 1 };
const x = t / DUR;
const amp = Math.exp(-ZETA * x);
const osc = Math.cos(FREQ * t);
const ringScale = 1 + 1.9 * (1 - amp);
const ringAlpha = Math.max(0, amp * .55);
const badgeScale = 1 + .16 * amp * osc;
return { ring: ringScale, ringAlpha, badge: badgeScale };
}
export default function DatePickerTodayPulse() {
const ringRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const badgeRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const triggerRef = useRef(0) as unknown as Box<number>;
const [isDemo, setIsDemo] = useState(true);
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return;
let rafId = 0;
function loop(ts: number) {
if (!triggerRef.current) triggerRef.current = ts;
let elapsed = ts - triggerRef.current;
if (isDemoRef.current && elapsed > CYCLE) {
triggerRef.current = ts;
elapsed = 0;
}
const p = pulseAt(elapsed);
const ring = ringRef.current;
const badge = badgeRef.current;
if (ring) {
ring.style.transform = 'scale(' + p.ring.toFixed(3) + ')';
ring.style.opacity = String(p.ringAlpha.toFixed(3));
}
if (badge) {
badge.style.transform = 'scale(' + p.badge.toFixed(3) + ')';
}
rafId = requestAnimationFrame(loop);
}
rafId = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rafId);
}, []);
function retrigger() {
human();
triggerRef.current = performance.now();
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
retrigger();
}
const lead = Array.from({ length: LEAD }, (_, i) => (
<span className={styles.tbCellWrap} key={'l' + i}><span className={styles.tbCell} /></span>
));
const days = Array.from({ length: N_DAYS }, (_, i) => {
const d = i + 1;
if (d === BASE.d) {
return (
<span className={styles.tbCellWrap} key={'d' + d}>
<span className={styles.tbRing} ref={ringRef} />
<button
type="button"
className={`${styles.tbCell} ${styles.tbCellToday} ${styles.tbBadge}`}
ref={badgeRef}
onClick={retrigger}
onKeyDown={onKeyDown}
>
{d}
</button>
</span>
);
}
return (
<span className={styles.tbCellWrap} key={'d' + d}><span className={styles.tbCell}>{d}</span></span>
);
});
const trail = Array.from({ length: TRAIL }, (_, i) => (
<span className={styles.tbCellWrap} key={'t' + i}><span className={styles.tbCell} /></span>
));
return (
<div className={`${styles.tb} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.tbHead}><span className={styles.tbLabel}>2026년 3월</span></div>
<div className={styles.tbWeekday} aria-hidden="true">
{WEEKDAY.map((w) => <span key={w}>{w}</span>)}
</div>
<div className={styles.tbGrid}>{lead}{days}{trail}</div>
</div>
);
}05 Today-marker value toggle — a ring around the cell holding today's value periodically grows while fading — Measured area 3.242% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · Date Picker UI: 9 Popover & Calendar Patterns
The post this demo lives in
Date Picker UI: 9 Popover & Calendar PatternsDate picker UI: One of nine opens a calendar popover when a focus value turns on, one fades out-of-range dates to unsele See all demos in this post
If you like this effect - similar demos
Pickers02 Today ring with count badge9 css calendar design patterns that really move
Pickers04 Picker Whose Donut Segment Grows With Its Value Ratio9 Graph Animation CSS Pickers With Live Recompute
Pickers07 A Polar Scene Where the Waveform Wraps Around a Ring and Lights Up Behind a Turning Playhead NeedleCSS Waveform: 9 Peak Columns and Trim Pickers