05今日表示切替 — 今日の値を持つマスの周りでリングが周期的に広がりながら薄くなる方式
今日の値を持つマスの周りでリングの値がゆっくり広がりながら薄くなり、バッジ自体もわずかに拡大縮小して、選択値と重なっても両者を見分けられます。
使いどころ出席チェックカレンダー、締切が近い予定の確認
pulseAt()DURZETA

<main class="stage">
<div class="tb is-demo" id="tb">
<div class="tb__head"><span class="tb__label">2026年3月</span></div>
<div class="tb__weekday" aria-hidden="true">
<span>日</span><span>月</span><span>火</span><span>水</span><span>木</span><span>金</span><span>土</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=日 ~ 6=土
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 今日表示切替 — 今日の値を持つマスの周りでリングが周期的に広がりながら薄くなる方式 — 計測 累積面積 3.242% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · css 日付選択 9選 — ポップオーバーで開閉する9つの方式
このデモが属する記事
css 日付選択 9選 — ポップオーバーで開閉する9つの方式css 日付選択:9つのうち1つは入力欄の焦点値がオンになるとカレンダーがポップオーバーとして開き、1つは許可範囲外の日付の霞み値が大きくなって選べない状態が現れ、もう1つは画面幅の値が狭くなるとポップオーバーが下から上がるシートに変わりま この記事のデモをすべて見る


