css 日付選択 9選 — ポップオーバーで開閉する9つの方式
css 日付選択:9つのうち1つは入力欄の焦点値がオンになるとカレンダーがポップオーバーとして開き、1つは許可範囲外の日付の霞み値が大きくなって選べない状態が現れ、もう1つは画面幅の値が狭くなるとポップオーバーが下から上がるシートに変わります。
01 date-picker-input-popover-open
02 date-picker-month-year-quick-jump
03 date-picker-keyboard-arrow-navigate
04 date-picker-min-max-fade-disabled
05 date-picker-today-pulse-badge
06 date-picker-preset-shortcut-chips
07 date-picker-mobile-sheet-swap
08 date-picker-invalid-shake-deny
09 date-picker-reduced-motion
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 入力ポップオーバー切替 — 入力欄の焦点値がオンになるとカレンダーがポップオーバーとして開き、オフになると閉じる方式
- 02 月年単位切替 — 表示単位の値が月から年に変わると複数年を一度に飛ばせる方式
- 03 キーボード移動切替 — 方向値が変わるたびハイライトセルの値がグリッド上を移動する方式
- 04 範囲外霞み切替 — 許可範囲の値を外れた日付の霞み値が大きくなり選べない状態に変わる方式
- 05 今日表示切替 — 今日の値を持つマスの周りでリングが周期的に広がりながら薄くなる方式
- 06 クイック選択切替 — チップの値を選ぶと格子を経ずに日付の値が直接決まる方式
- 07 シート切替 — 画面幅の値が狭くなるとポップオーバーの値が下から上がるシートの値に変わる方式
- 08 拒否振動切替 — ブロックされた値に触れるとそのマスの位置の値が短く左右に揺れる方式
- 09 モーション削減代替切替 — モーション削減の値がオンになるとポップオーバー・シート・振動の値が即座に切り替わる方式
これら9つのデモはいずれもサーバーと通信せず、localStorage にも触れず、乱数も使いません。それぞれが1つの真偽値・文字列・数値の状態を軸に、その値の変化がどう見えるかを少数の定数だけで決めている小さな html カレンダー 日付選択 の実装です。この狙いの狭さのおかげで、焦点・範囲・モード・幅のどの値が切り替わるのかを先に確認すれば、コードを開く前にパターンの名前が自然と分かります。
9つのパターンの解剖 — どの値がいつ変わるか
9つのうち requestAnimationFrame ループを使うのは2つだけです。05番の pulseAt() は指数減衰にコサイン振動を掛け合わせてフレームごとに今日リングの拡大率と不透明度、バッジ自身の小さな拍動を求め、08番の shakeAt() は同じ減衰×振動の式をセルの translateX に使います。他の7つは単一のCSSトランジションで収まるため、STAGGER・FADE_STEP・SHAKE_ZETA のような定数は、フレームごとに再計算される値ではなく意図した質感を表すだけです。9つのうち4つは描画前に実際のレイアウトを実測します。01番の measureAnchor() は入力欄の getBoundingClientRect() を読み、その実測した下端から ANCHOR_GAP 分だけ下にポップオーバーを置きます。02番の slideTo() はビューポートの実測幅を読んで月送りのスライド距離を決め、03番の moveRingTo() はフォーカスされたセルの位置を読んでキーボードリングをそこへ動かし、07番の measureMode() はビューポートの実測幅を THRESHOLD と比べてポップオーバーかシートかを選びます。残りの04・05・06・08・09番は固定定数だけで動きます(FADE_STEP/FADE_FLOOR、DUR/ZETA/FREQ、PRESETS/STAGGER、SHAKE_ZETA/BANNER_MS、単純な真偽値の切替)。DOM実測は一度も呼ばれないため、実測する4つのようにビューポートのサイズに応じて計算が変わることはありません。リスナーはどれも実際の操作に答えます。04・06・07・08・09番は click だけを聞き、01番は入力欄の click に加えて Escape 用の keydown と、ポップオーバー外をクリックしたときに閉じるdocument側の click を組み合わせ、02番はヘッダーとナビゲーションの click に左右矢印キー用の keydown を組み合わせ、03番は focusin・左右矢印で1、上下矢印で7動かす keydown・click を組み合わせ、05番はバッジの click に keydown を組み合わせます。9つのどれも new Date() を使わず、01・03・04・05・06・08番は固定の BASE = { y: 2026, m: 3, d: 14 } を基準にし、02番は同じ考え方の YEAR = 2026 を基準にします(07番と09番は今日の値そのものを持たない仕組みです)。そのため、ページをいつ開いても「今日」は変わりません。
パターン別コード
01入力ポップオーバー切替 — 入力欄の焦点値がオンになるとカレンダーがポップオーバーとして開き、オフになると閉じる方式
入力欄の焦点値がオンになると、カレンダーが入力欄の直下にポップオーバーとして開き、高さがわずかに目標値を超えてから収まります。オフになると同じポップオーバーが閉じます。予約フォームの日付欄や、絞り込みパネルの期間入力に向く方式で、ポップオーバー自身がどの入力欄から開いたのかを見た目で示す必要がある場面に合います。

<main class="stage">
<div class="dp is-demo" id="dp">
<button type="button" class="dp__field" id="dpField" aria-haspopup="dialog" aria-expanded="false">
<span class="dp__fieldLabel">チェックイン日</span>
<span class="dp__fieldValue" id="dpValue">2026-03-14</span>
</button>
<div class="dp__pop" id="dpPop" role="dialog" aria-label="カレンダー" aria-hidden="true">
<div class="dp__head"><span class="dp__label">2026年3月</span></div>
<div class="dp__weekday" aria-hidden="true">
<span>日</span><span>月</span><span>火</span><span>水</span><span>木</span><span>金</span><span>土</span>
</div>
<div class="dp__grid" id="dpGrid"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 팝오버 위치는 index.html measureAnchor() 의 getBoundingClientRect 실측값으로 정해진다.
// 애니메이션은 그 자리에서 transform(scale)·opacity 만 — top/left 는 실측 뒤 한 번만 세팅되고 트윈하지 않는다.
@include stage($stage-ink);
// 계산 겹: 팝오버가 필드 아래로 실제 높이만큼 펼쳐지므로 무대는 위쪽에 붙여
// 열림 상태의 전체 높이가 480x300·320x200 두 화면 모두에 들어가게 한다.
.stage {
align-items: start;
padding-top: $sp-4;
}
.dp {
position: relative;
width: 100%;
max-width: 220px;
}
.dp__field {
display: grid;
gap: $sp-1;
width: 100%;
padding: $sp-2 $sp-3;
border: none;
border-radius: $r-control;
background: $subject-blue-deep;
color: $subject-cream;
text-align: left;
cursor: pointer;
box-shadow: $shadow-raised;
}
.dp__fieldLabel {
font-size: 10px;
opacity: .8;
}
.dp__fieldValue {
font-size: 14px;
font-weight: 700;
}
.dp__pop {
position: absolute;
z-index: 2;
box-sizing: border-box;
padding: $sp-2;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
transform-origin: top center;
transform: scale(.85);
opacity: 0;
pointer-events: none;
transition: transform $dur-enter $ease-elastic, opacity $dur-quick $ease-out;
}
.dp--open .dp__pop {
transform: scale(1);
opacity: 1;
pointer-events: auto;
}
.dp__head {
text-align: center;
margin-bottom: $sp-1;
}
.dp__label {
font-size: 11px;
font-weight: 700;
color: $subject-ink;
}
.dp__weekday,
.dp__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.dp__weekday span {
font-size: 9px;
text-align: center;
color: rgba($subject-ink, .72);
padding-bottom: $sp-1;
}
.dp__cell {
display: grid;
place-items: center;
height: 21px;
font-size: 11px;
color: $subject-ink;
border-radius: $r-xs;
}
.dp__cell--out {
visibility: hidden;
}
.dp__cell--today {
background: $subject-blue;
color: $subject-cream;
font-weight: 700;
}
@media (max-width: 340px) {
.stage { padding-top: $sp-2; }
.dp { max-width: 180px; }
.dp__field { padding: $sp-1 $sp-2; }
.dp__pop { padding: $sp-1; }
.dp__cell { height: 17px; font-size: 10px; }
.dp__weekday span { font-size: 8px; padding-bottom: 0; }
}
@media (prefers-reduced-motion: reduce) {
.dp__pop { transition: 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);
}
.stage {
align-items: start;
padding-top: 16px;
}
.dp {
position: relative;
width: 100%;
max-width: 220px;
}
.dp__field {
display: grid;
gap: 4px;
width: 100%;
padding: 8px 12px;
border: none;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
text-align: left;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.dp__fieldLabel {
font-size: 10px;
opacity: 0.8;
}
.dp__fieldValue {
font-size: 14px;
font-weight: 700;
}
.dp__pop {
position: absolute;
z-index: 2;
box-sizing: border-box;
padding: 8px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: top center;
transform: scale(0.85);
opacity: 0;
pointer-events: none;
transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 220ms ease-out;
}
.dp--open .dp__pop {
transform: scale(1);
opacity: 1;
pointer-events: auto;
}
.dp__head {
text-align: center;
margin-bottom: 4px;
}
.dp__label {
font-size: 11px;
font-weight: 700;
color: #17141a;
}
.dp__weekday,
.dp__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.dp__weekday span {
font-size: 9px;
text-align: center;
color: rgba(23, 20, 26, 0.72);
padding-bottom: 4px;
}
.dp__cell {
display: grid;
place-items: center;
height: 21px;
font-size: 11px;
color: #17141a;
border-radius: 4px;
}
.dp__cell--out {
visibility: hidden;
}
.dp__cell--today {
background: #2f6df6;
color: #fff7e6;
font-weight: 700;
}
@media (max-width: 340px) {
.stage {
padding-top: 8px;
}
.dp {
max-width: 180px;
}
.dp__field {
padding: 4px 8px;
}
.dp__pop {
padding: 4px;
}
.dp__cell {
height: 17px;
font-size: 10px;
}
.dp__weekday span {
font-size: 8px;
padding-bottom: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.dp__pop {
transition: none;
}
}(function () {
var BASE = { y: 2026, m: 3, d: 14 }; // 계산 겹 상수: 고정 오늘(月 1~12), new Date() 미사용
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 ANCHOR_GAP = 8; // 계산 겹: 필드 아래 팝오버 간격(px)
var dp = document.getElementById('dp');
var field = document.getElementById('dpField');
var pop = document.getElementById('dpPop');
var grid = document.getElementById('dpGrid');
var n = daysInMonth(BASE.y, BASE.m);
var lead = dow(BASE.y, BASE.m, 1);
for (var i = 0; i < lead; i++) {
var blank = document.createElement('span');
blank.className = 'dp__cell dp__cell--out';
grid.appendChild(blank);
}
for (var d = 1; d <= n; d++) {
var cell = document.createElement('span');
cell.className = 'dp__cell';
cell.textContent = String(d);
if (d === BASE.d) cell.classList.add('dp__cell--today');
grid.appendChild(cell);
}
function measureAnchor() {
// 계산 겹: 실측 위치 — 필드의 실제 렌더 폭·좌표를 읽어 팝오버를 그 자리에 앵커한다
var fr = field.getBoundingClientRect();
var pr = dp.getBoundingClientRect();
pop.style.left = (fr.left - pr.left) + 'px';
pop.style.top = (fr.bottom - pr.top + ANCHOR_GAP) + 'px';
pop.style.width = fr.width + 'px';
}
function setOpen(v) {
dp.classList.toggle('dp--open', v);
field.setAttribute('aria-expanded', v ? 'true' : 'false');
pop.setAttribute('aria-hidden', v ? 'false' : 'true');
if (v) measureAnchor();
}
function human() {
dp.classList.remove('is-demo');
stopAutoplay();
setOpen(!dp.classList.contains('dp--open'));
}
field.addEventListener('click', human);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && dp.classList.contains('dp--open')) { human(); }
});
document.addEventListener('click', function (e) {
if (dp.classList.contains('dp--open') && !dp.contains(e.target)) { human(); }
});
var open = false;
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
setOpen(false);
if (!reduce) {
autoTimer = setInterval(function () {
open = !open;
setOpen(open);
}, 1400);
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './DatePickerPopoverOpen.module.scss';
type Box<T> = { current: T };
const BASE = { y: 2026, m: 3, d: 14 }; // 계산 겹 상수: 고정 오늘(월 1~12), new Date() 미사용
const ANCHOR_GAP = 8; // 계산 겹: 필드 아래 팝오버 간격(px)
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;
}
function buildCells() {
const n = daysInMonth(BASE.y, BASE.m);
const lead = dow(BASE.y, BASE.m, 1);
const cells: { key: string; d: number | null; today: boolean }[] = [];
for (let i = 0; i < lead; i++) cells.push({ key: 'out' + i, d: null, today: false });
for (let d = 1; d <= n; d++) cells.push({ key: 'd' + d, d, today: d === BASE.d });
return cells;
}
export default function DatePickerPopoverOpen() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const fieldRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const popRef = useRef(null) as unknown as Box<HTMLDivElement>;
const [isDemo, setIsDemo] = useState(true);
const [open, setOpen] = useState(false);
const cells = buildCells();
function measureAnchor() {
// 계산 겹: 실측 위치 — 필드의 실제 렌더 폭·좌표를 읽어 팝오버를 그 자리에 앵커한다
const field = fieldRef.current;
const root = rootRef.current;
const pop = popRef.current;
if (!field || !root || !pop) return;
const fr = field.getBoundingClientRect();
const pr = root.getBoundingClientRect();
pop.style.left = fr.left - pr.left + 'px';
pop.style.top = fr.bottom - pr.top + ANCHOR_GAP + 'px';
pop.style.width = fr.width + 'px';
}
function human() {
setIsDemo(false);
setOpen((v) => !v);
}
useEffect(() => {
if (open) measureAnchor();
}, [open]);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && open) human();
}
function onDoc(e: MouseEvent) {
const root = rootRef.current;
if (open && root && !root.contains(e.target as Node)) human();
}
document.addEventListener('keydown', onKey);
document.addEventListener('click', onDoc);
return () => {
document.removeEventListener('keydown', onKey);
document.removeEventListener('click', onDoc);
};
}, [open]);
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!isDemo || reduce) return;
const timer = window.setInterval(() => {
setOpen((v) => !v);
}, 1400);
return () => window.clearInterval(timer);
}, [isDemo]);
return (
<div className={`${styles.dp} ${isDemo ? styles.isDemo : ''} ${open ? styles.dpOpen : ''}`} ref={rootRef}>
<button
type="button"
className={styles.dpField}
ref={fieldRef}
aria-haspopup="dialog"
aria-expanded={open}
onClick={human}
>
<span className={styles.dpFieldLabel}>체크인 날짜</span>
<span className={styles.dpFieldValue}>2026-03-14</span>
</button>
<div className={styles.dpPop} ref={popRef} role="dialog" aria-label="달력" aria-hidden={!open}>
<div className={styles.dpHead}><span className={styles.dpLabel}>2026년 3월</span></div>
<div className={styles.dpWeekday} aria-hidden="true">
{WEEKDAY.map((w) => <span key={w}>{w}</span>)}
</div>
<div className={styles.dpGrid}>
{cells.map((c) => (
<span
key={c.key}
className={`${styles.dpCell} ${c.d === null ? styles.dpCellOut : ''} ${c.today ? styles.dpCellToday : ''}`}
>
{c.d ?? ''}
</span>
))}
</div>
</div>
</div>
);
}01 · 入力ポップオーバー切替 — 入力欄の焦点値がオンになるとカレンダーがポップオーバーとして開き、オフになると閉じる方式 — 計測 累積面積 25.682% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function measureAnchor() {
// 計算層: 実測位置 — 入力欄の実際のレンダー矩形を読み、その位置にポップオーバーを固定する
var fr = field.getBoundingClientRect();
var pr = dp.getBoundingClientRect();
pop.style.left = (fr.left - pr.left) + 'px';
pop.style.top = (fr.bottom - pr.top + ANCHOR_GAP) + 'px';
pop.style.width = fr.width + 'px';
}
function setOpen(v) {
dp.classList.toggle('dp--open', v);
field.setAttribute('aria-expanded', v ? 'true' : 'false');
pop.setAttribute('aria-hidden', v ? 'false' : 'true');
if (v) measureAnchor();
}
数値が語ること
01番は累積面積25.68%・平均強度84.3を23サンプル中8フレームで示し、目標値をわずかに超えてから収まるポップオーバーの高さの動きが、単純な表示・非表示の切替に一拍加えています。
02月年単位切替 — 表示単位の値が月から年に変わると複数年を一度に飛ばせる方式
中央のラベル値を操作するたびに表示単位の値が月と年の間で切り替わり、年モードでのジャンプは一度に大きな期間を移動します。生年月日の入力や、古い記録まで遡る期間選択に役立つ方式で、何十年も前の日付まで月ごとにページ送りする手間を省きます。

<main class="stage">
<div class="mj is-demo" id="mj">
<div class="mj__bar">
<button type="button" class="mj__nav" id="mjPrev" aria-label="前の月">‹</button>
<button type="button" class="mj__head" id="mjHead">2026年3月</button>
<button type="button" class="mj__nav" id="mjNext" aria-label="次の月">›</button>
</div>
<div class="mj__weekday" aria-hidden="true">
<span>日</span><span>月</span><span>火</span><span>水</span><span>木</span><span>金</span><span>土</span>
</div>
<div class="mj__viewport" id="mjViewport">
<div class="mj__grid" id="mjMonthGrid"></div>
</div>
<div class="mj__yearGrid" id="mjYearGrid"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: slideTo() 가 뷰포트 실측 폭(getBoundingClientRect)을 슬라이드 거리로,
// 목표-현재 월 오프셋의 부호를 슬라이드 방향으로 써서 translateX 를 계산한다.
@include stage($stage-yellow);
.mj {
width: 100%;
max-width: 230px;
background: $stage-paper;
border-radius: $r-card;
padding: $sp-2;
box-shadow: $shadow-raised;
box-sizing: border-box;
}
.mj__bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $sp-1;
}
.mj__nav {
width: 20px;
height: 20px;
display: grid;
place-items: center;
border: none;
border-radius: $r-xs;
background: transparent;
color: $subject-ink;
font-size: 14px;
cursor: pointer;
}
.mj__head {
border: none;
background: transparent;
color: $subject-ink;
font-size: 12px;
font-weight: 700;
cursor: pointer;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
}
.mj__weekday {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: $sp-1;
}
.mj__weekday span {
font-size: 9px;
text-align: center;
color: rgba($subject-ink, .72);
}
.mj__viewport {
overflow: hidden;
}
.mj__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: 21px;
transition: transform $dur-base $ease-spring;
}
.mj__cell {
display: grid;
place-items: center;
font-size: 11px;
color: $subject-ink;
border-radius: $r-xs;
}
.mj__cell--out {
visibility: hidden;
}
.mj__yearGrid {
display: none;
grid-template-columns: repeat(4, 1fr);
gap: $sp-1;
}
.mj--year .mj__weekday,
.mj--year .mj__viewport {
display: none;
}
.mj--year .mj__yearGrid {
display: grid;
}
.mj__ycell {
height: 26px;
display: grid;
place-items: center;
border: none;
border-radius: $r-xs;
background: $subject-blue;
color: $subject-cream;
font-size: 10px;
cursor: pointer;
}
.mj__ycell--now {
background: $subject-blue-deep;
}
@media (max-width: 340px) {
.mj { max-width: 190px; }
.mj__grid { grid-auto-rows: 17px; }
.mj__cell { font-size: 10px; }
.mj__ycell { height: 21px; font-size: 9px; }
.mj__weekday span { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.mj__grid { 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);
}
.mj {
width: 100%;
max-width: 230px;
background: #ffffff;
border-radius: 16px;
padding: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.mj__bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.mj__nav {
width: 20px;
height: 20px;
display: grid;
place-items: center;
border: none;
border-radius: 4px;
background: transparent;
color: #17141a;
font-size: 14px;
cursor: pointer;
}
.mj__head {
border: none;
background: transparent;
color: #17141a;
font-size: 12px;
font-weight: 700;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.mj__weekday {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 4px;
}
.mj__weekday span {
font-size: 9px;
text-align: center;
color: rgba(23, 20, 26, 0.72);
}
.mj__viewport {
overflow: hidden;
}
.mj__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: 21px;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mj__cell {
display: grid;
place-items: center;
font-size: 11px;
color: #17141a;
border-radius: 4px;
}
.mj__cell--out {
visibility: hidden;
}
.mj__yearGrid {
display: none;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
}
.mj--year .mj__weekday,
.mj--year .mj__viewport {
display: none;
}
.mj--year .mj__yearGrid {
display: grid;
}
.mj__ycell {
height: 26px;
display: grid;
place-items: center;
border: none;
border-radius: 4px;
background: #2f6df6;
color: #fff7e6;
font-size: 10px;
cursor: pointer;
}
.mj__ycell--now {
background: #0a4fe8;
}
@media (max-width: 340px) {
.mj {
max-width: 190px;
}
.mj__grid {
grid-auto-rows: 17px;
}
.mj__cell {
font-size: 10px;
}
.mj__ycell {
height: 21px;
font-size: 9px;
}
.mj__weekday span {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.mj__grid {
transition: none;
}
}(function () {
var YEAR = 2026; // 계산 겹 상수: 연도 고정, new Date() 미사용
var MONTH_LABEL = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
var HEADER_LABEL = [
'2026年1月', '2026年2月', '2026年3月', '2026年4月', '2026年5月', '2026年6月',
'2026年7月', '2026年8月', '2026年9月', '2026年10月', '2026年11月', '2026年12月'
];
var AUTO_SEQ = [3, 7, 11, 3]; // 계산 겹: 자동재생이 도는 목표 月 순서, 오프셋 부호로 슬라이드 방향을 정한다
var CELL_TOTAL = 42; // 계산 겹: 6행 고정 — 월마다 칸 수가 달라도 뷰포트 높이가 흔들리지 않는다
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 root = document.getElementById('mj');
var head = document.getElementById('mjHead');
var prevBtn = document.getElementById('mjPrev');
var nextBtn = document.getElementById('mjNext');
var viewport = document.getElementById('mjViewport');
var monthGrid = document.getElementById('mjMonthGrid');
var yearGrid = document.getElementById('mjYearGrid');
var current = 3;
var mode = 'month';
function renderMonth(m) {
monthGrid.innerHTML = '';
var n = daysInMonth(YEAR, m);
var lead = dow(YEAR, m, 1);
var trail = CELL_TOTAL - lead - n;
var i, cell;
for (i = 0; i < lead; i++) {
cell = document.createElement('span');
cell.className = 'mj__cell mj__cell--out';
monthGrid.appendChild(cell);
}
for (var d = 1; d <= n; d++) {
cell = document.createElement('span');
cell.className = 'mj__cell';
cell.textContent = String(d);
monthGrid.appendChild(cell);
}
for (i = 0; i < trail; i++) {
cell = document.createElement('span');
cell.className = 'mj__cell mj__cell--out';
monthGrid.appendChild(cell);
}
}
function renderYear(highlight) {
yearGrid.innerHTML = '';
for (var i = 0; i < 12; i++) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'mj__ycell' + (i + 1 === highlight ? ' mj__ycell--now' : '');
btn.textContent = MONTH_LABEL[i];
btn.dataset.m = String(i + 1);
btn.addEventListener('click', function (e) {
human();
slideTo(parseInt(e.currentTarget.dataset.m, 10));
});
yearGrid.appendChild(btn);
}
}
function setHead(m) { head.textContent = HEADER_LABEL[m - 1]; }
function slideTo(target) {
// 계산 겹: 뷰포트 실측 폭을 슬라이드 거리로, 목표-현재 오프셋 부호를 슬라이드 방향으로 쓴다
var dist = viewport.getBoundingClientRect().width;
var dir = target >= current ? 1 : -1;
current = target;
renderMonth(current);
setHead(current);
root.classList.remove('mj--year');
mode = 'month';
monthGrid.style.transition = 'none';
monthGrid.style.transform = 'translateX(' + (dir * dist) + 'px)';
void monthGrid.offsetWidth;
monthGrid.style.transition = '';
monthGrid.style.transform = 'translateX(0)';
}
function stepMonth(delta) {
var target = current + delta;
if (target < 1) target = 12;
if (target > 12) target = 1;
slideTo(target);
}
function toggleYear() {
mode = mode === 'year' ? 'month' : 'year';
root.classList.toggle('mj--year', mode === 'year');
if (mode === 'year') renderYear(current);
}
function human() {
root.classList.remove('is-demo');
stopAutoplay();
}
head.addEventListener('click', function () { human(); toggleYear(); });
prevBtn.addEventListener('click', function () { human(); stepMonth(-1); });
nextBtn.addEventListener('click', function () { human(); stepMonth(1); });
document.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { human(); stepMonth(-1); }
if (e.key === 'ArrowRight') { human(); stepMonth(1); }
});
var seqIdx = 0;
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
function tick() {
// 자동재생은 슬라이드(transform 트랜지션)만 반복한다 — 연·月 전환 자체는
// display 즉시 전환이라 캡처 프레임에 순간 컷으로만 찍혀 움직인 프레임이 안 는다.
seqIdx = (seqIdx + 1) % AUTO_SEQ.length;
slideTo(AUTO_SEQ[seqIdx]);
}
renderMonth(current);
setHead(current);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(tick, 480); }
})();import { useEffect, useRef, useState } from 'react';
import styles from './DatePickerMonthYearJump.module.scss';
type Box<T> = { current: T };
const YEAR = 2026; // 계산 겹 상수: 연도 고정, new Date() 미사용
const MONTH_LABEL = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
const HEADER_LABEL = [
'2026년 1월', '2026년 2월', '2026년 3월', '2026년 4월', '2026년 5월', '2026년 6월',
'2026년 7월', '2026년 8월', '2026년 9월', '2026년 10월', '2026년 11월', '2026년 12월',
];
const AUTO_SEQ = [3, 7, 11, 3]; // 계산 겹: 자동재생이 도는 목표 월 순서, 오프셋 부호로 슬라이드 방향을 정한다
const CELL_TOTAL = 42; // 계산 겹: 6행 고정 — 월마다 칸 수가 달라도 뷰포트 높이가 흔들리지 않는다
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;
}
function buildCells(m: number) {
const n = daysInMonth(YEAR, m);
const lead = dow(YEAR, m, 1);
const trail = CELL_TOTAL - lead - n;
const cells: { key: string; d: number | null }[] = [];
for (let i = 0; i < lead; i++) cells.push({ key: 'l' + i, d: null });
for (let d = 1; d <= n; d++) cells.push({ key: 'd' + d, d });
for (let i = 0; i < trail; i++) cells.push({ key: 't' + i, d: null });
return cells;
}
const WEEKDAY = ['일', '월', '화', '수', '목', '금', '토'];
export default function DatePickerMonthYearJump() {
const viewportRef = useRef(null) as unknown as Box<HTMLDivElement>;
const gridRef = useRef(null) as unknown as Box<HTMLDivElement>;
const [isDemo, setIsDemo] = useState(true);
const [mode, setMode] = useState<'month' | 'year'>('month');
const [current, setCurrent] = useState(3);
const seqIdxRef = useRef(0) as unknown as Box<number>;
function human() {
setIsDemo(false);
}
function slideTo(target: number) {
// 계산 겹: 뷰포트 실측 폭을 슬라이드 거리로, 목표-현재 월 오프셋 부호를 슬라이드 방향으로 쓴다
const viewport = viewportRef.current;
const grid = gridRef.current;
if (!viewport || !grid) { setCurrent(target); setMode('month'); return; }
const dist = viewport.getBoundingClientRect().width;
const dir = target >= current ? 1 : -1;
setCurrent(target);
setMode('month');
grid.style.transition = 'none';
grid.style.transform = 'translateX(' + dir * dist + 'px)';
void grid.offsetWidth;
grid.style.transition = '';
grid.style.transform = 'translateX(0)';
}
function stepMonth(delta: number) {
let target = current + delta;
if (target < 1) target = 12;
if (target > 12) target = 1;
slideTo(target);
}
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'ArrowLeft') { human(); stepMonth(-1); }
if (e.key === 'ArrowRight') { human(); stepMonth(1); }
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
});
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!isDemo || reduce) return;
const timer = window.setInterval(() => {
seqIdxRef.current = (seqIdxRef.current + 1) % AUTO_SEQ.length;
slideTo(AUTO_SEQ[seqIdxRef.current]);
}, 480);
return () => window.clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDemo]);
const cells = buildCells(current);
return (
<div className={`${styles.mj} ${isDemo ? styles.isDemo : ''} ${mode === 'year' ? styles.mjYear : ''}`}>
<div className={styles.mjBar}>
<button type="button" className={styles.mjNav} aria-label="이전 달" onClick={() => { human(); stepMonth(-1); }}>‹</button>
<button
type="button"
className={styles.mjHead}
onClick={() => { human(); setMode((v) => (v === 'year' ? 'month' : 'year')); }}
>
{HEADER_LABEL[current - 1]}
</button>
<button type="button" className={styles.mjNav} aria-label="다음 달" onClick={() => { human(); stepMonth(1); }}>›</button>
</div>
<div className={styles.mjWeekday} aria-hidden="true">
{WEEKDAY.map((w) => <span key={w}>{w}</span>)}
</div>
<div className={styles.mjViewport} ref={viewportRef}>
<div className={styles.mjGrid} ref={gridRef}>
{cells.map((c) => (
<span key={c.key} className={`${styles.mjCell} ${c.d === null ? styles.mjCellOut : ''}`}>{c.d ?? ''}</span>
))}
</div>
</div>
<div className={styles.mjYearGrid}>
{MONTH_LABEL.map((label, i) => (
<button
type="button"
key={label}
className={`${styles.mjYcell} ${i + 1 === current ? styles.mjYcellNow : ''}`}
onClick={() => { human(); slideTo(i + 1); }}
>
{label}
</button>
))}
</div>
</div>
);
}02 · 月年単位切替 — 表示単位の値が月から年に変わると複数年を一度に飛ばせる方式 — 計測 累積面積 5.122% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function stepMonth(delta) {
var target = current + delta;
if (target < 1) target = 12;
if (target > 12) target = 1;
slideTo(target);
}
function toggleYear() {
mode = mode === 'year' ? 'month' : 'year';
root.classList.toggle('mj--year', mode === 'year');
if (mode === 'year') renderYear(current);
}
数値が語ること
02番は累積面積5.12%・平均強度109.6を23サンプル中19フレームで示し、月表示のスライドと年グリッドへの切替が、1フレームあたりの面積は小さくてもほぼ全サンプルにわたって動きを保っています。
03キーボード移動切替 — 方向値が変わるたびハイライトセルの値がグリッド上を移動する方式
方向値が変わるたびにハイライトセルの値が隣のセルへ移動し、行の端では次の行へ滑らかに折り返します。キーボード操作を主とする入力フォームや、アクセシビリティを重視するダッシュボードに向く方式で、マウスに触れずグリッド上のどの日付にも到達できます。

<main class="stage">
<div class="kb is-demo" id="kb">
<div class="kb__head"><span class="kb__label">2026年3月</span></div>
<div class="kb__weekday" aria-hidden="true">
<span>日</span><span>月</span><span>火</span><span>水</span><span>木</span><span>金</span><span>土</span>
</div>
<div class="kb__gridWrap">
<div class="kb__ring" id="kbRing"></div>
<div class="kb__grid" id="kbGrid" role="grid" aria-label="カレンダー"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: moveRingTo() 가 포커스된 셀의 getBoundingClientRect 좌표를 읽어
// 링을 그 좌표로 translate 이동시킨다 — 셀 사이를 좌표값으로 보간하며 옮겨간다.
@include stage($stage-orange);
.kb {
width: 100%;
max-width: 220px;
background: $subject-cream;
border-radius: $r-card;
padding: $sp-2;
box-shadow: $shadow-raised;
box-sizing: border-box;
}
.kb__head {
text-align: center;
margin-bottom: $sp-1;
}
.kb__label {
font-size: 11px;
font-weight: 700;
color: $subject-ink;
}
.kb__weekday {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: $sp-1;
}
.kb__weekday span {
font-size: 9px;
text-align: center;
color: rgba($subject-ink, .72);
}
.kb__gridWrap {
position: relative;
}
.kb__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: 21px;
position: relative;
z-index: 1;
}
.kb__cell {
display: grid;
place-items: center;
border: none;
background: transparent;
font-size: 11px;
color: $subject-ink;
border-radius: $r-xs;
cursor: pointer;
}
.kb__cell:focus {
outline: none;
}
.kb__cell--out {
visibility: hidden;
}
.kb__ring {
position: absolute;
top: 0;
left: 0;
z-index: 0;
border: 2px solid $subject-blue-deep;
border-radius: $r-xs;
box-sizing: border-box;
transition: transform $dur-base $ease-spring;
pointer-events: none;
}
@media (max-width: 340px) {
.kb { max-width: 180px; }
.kb__grid { grid-auto-rows: 17px; }
.kb__cell { font-size: 10px; }
.kb__weekday span { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.kb__ring { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.kb {
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;
}
.kb__head {
text-align: center;
margin-bottom: 4px;
}
.kb__label {
font-size: 11px;
font-weight: 700;
color: #17141a;
}
.kb__weekday {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 4px;
}
.kb__weekday span {
font-size: 9px;
text-align: center;
color: rgba(23, 20, 26, 0.72);
}
.kb__gridWrap {
position: relative;
}
.kb__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: 21px;
position: relative;
z-index: 1;
}
.kb__cell {
display: grid;
place-items: center;
border: none;
background: transparent;
font-size: 11px;
color: #17141a;
border-radius: 4px;
cursor: pointer;
}
.kb__cell:focus {
outline: none;
}
.kb__cell--out {
visibility: hidden;
}
.kb__ring {
position: absolute;
top: 0;
left: 0;
z-index: 0;
border: 2px solid #0a4fe8;
border-radius: 4px;
box-sizing: border-box;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
pointer-events: none;
}
@media (max-width: 340px) {
.kb {
max-width: 180px;
}
.kb__grid {
grid-auto-rows: 17px;
}
.kb__cell {
font-size: 10px;
}
.kb__weekday span {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.kb__ring {
transition: none;
}
}(function () {
var BASE = { y: 2026, m: 3, d: 14 }; // 계산 겹 상수: 고정 오늘, new Date() 미사용
var CELL_TOTAL = 42; // 6행 고정
var FOCUS_SEQ = [14, 15, 16, 23, 22, 21, 14]; // 계산 겹: 자동재생이 훑는 日付 순서
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 kb = document.getElementById('kb');
var grid = document.getElementById('kbGrid');
var ring = document.getElementById('kbRing');
var n = daysInMonth(BASE.y, BASE.m);
var lead = dow(BASE.y, BASE.m, 1);
var cellByDay = {};
var i, cell;
for (i = 0; i < lead; i++) {
cell = document.createElement('span');
cell.className = 'kb__cell kb__cell--out';
grid.appendChild(cell);
}
for (var d = 1; d <= n; d++) {
cell = document.createElement('button');
cell.type = 'button';
cell.className = 'kb__cell';
cell.textContent = String(d);
cell.dataset.day = String(d);
cell.tabIndex = d === BASE.d ? 0 : -1;
grid.appendChild(cell);
cellByDay[d] = cell;
}
var trail = CELL_TOTAL - lead - n;
for (i = 0; i < trail; i++) {
cell = document.createElement('span');
cell.className = 'kb__cell kb__cell--out';
grid.appendChild(cell);
}
var focusedDay = BASE.d;
var ringMeasured = false;
function moveRingTo(cellEl) {
// 계산 겹: 셀의 실제 렌더 좌표·크기를 실측해 링을 그 자리로 옮긴다(좌표 lerp 이동)
var cr = cellEl.getBoundingClientRect();
var gr = grid.getBoundingClientRect();
if (!ringMeasured) {
ring.style.width = cr.width + 'px';
ring.style.height = cr.height + 'px';
ringMeasured = true;
}
ring.style.transform = 'translate(' + (cr.left - gr.left) + 'px,' + (cr.top - gr.top) + 'px)';
}
grid.addEventListener('focusin', function (e) {
var cellEl = e.target.closest('.kb__cell');
if (!cellEl || !cellEl.dataset.day) return;
moveRingTo(cellEl);
});
function focusDay(target) {
if (target < 1) target = 1;
if (target > n) target = n;
var prev = cellByDay[focusedDay];
if (prev) prev.tabIndex = -1;
focusedDay = target;
var next = cellByDay[focusedDay];
next.tabIndex = 0;
next.focus({ preventScroll: true });
}
function human() {
kb.classList.remove('is-demo');
stopAutoplay();
}
grid.addEventListener('keydown', function (e) {
var delta = 0;
if (e.key === 'ArrowRight') delta = 1;
else if (e.key === 'ArrowLeft') delta = -1;
else if (e.key === 'ArrowDown') delta = 7;
else if (e.key === 'ArrowUp') delta = -7;
else return;
e.preventDefault();
human();
focusDay(focusedDay + delta);
});
grid.addEventListener('click', function (e) {
var cellEl = e.target.closest('.kb__cell');
if (!cellEl || !cellEl.dataset.day) return;
human();
focusDay(parseInt(cellEl.dataset.day, 10));
});
var seqIdx = 0;
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
function tick() {
seqIdx = (seqIdx + 1) % FOCUS_SEQ.length;
focusDay(FOCUS_SEQ[seqIdx]);
}
cellByDay[BASE.d].focus({ preventScroll: true });
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(tick, 500); }
})();import { useEffect, useRef, useState } from 'react';
import styles from './DatePickerKeyboardArrow.module.scss';
type Box<T> = { current: T };
const BASE = { y: 2026, m: 3, d: 14 }; // 계산 겹 상수: 고정 오늘, new Date() 미사용
const CELL_TOTAL = 42;
const FOCUS_SEQ = [14, 15, 16, 23, 22, 21, 14]; // 계산 겹: 자동재생이 훑는 날짜 순서
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;
export default function DatePickerKeyboardArrow() {
const gridRef = useRef(null) as unknown as Box<HTMLDivElement>;
const ringRef = useRef(null) as unknown as Box<HTMLDivElement>;
const cellRefs = useRef({}) as unknown as Box<Record<number, HTMLButtonElement>>;
const ringMeasuredRef = useRef(false) as unknown as Box<boolean>;
const [isDemo, setIsDemo] = useState(true);
const [focusedDay, setFocusedDay] = useState(BASE.d);
function human() {
setIsDemo(false);
}
function moveRingTo(cellEl: HTMLButtonElement) {
// 계산 겹: 셀의 실제 렌더 좌표·크기를 실측해 링을 그 자리로 옮긴다(좌표 lerp 이동)
const grid = gridRef.current;
const ring = ringRef.current;
if (!grid || !ring) return;
const cr = cellEl.getBoundingClientRect();
const gr = grid.getBoundingClientRect();
if (!ringMeasuredRef.current) {
ring.style.width = cr.width + 'px';
ring.style.height = cr.height + 'px';
ringMeasuredRef.current = true;
}
ring.style.transform = 'translate(' + (cr.left - gr.left) + 'px,' + (cr.top - gr.top) + 'px)';
}
function focusDay(target: number) {
let t = target;
if (t < 1) t = 1;
if (t > N_DAYS) t = N_DAYS;
setFocusedDay(t);
const el = cellRefs.current[t];
if (el) el.focus({ preventScroll: true });
}
useEffect(() => {
const el = cellRefs.current[focusedDay];
if (el) moveRingTo(el);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusedDay]);
useEffect(() => {
const el = cellRefs.current[BASE.d];
if (el) el.focus({ preventScroll: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!isDemo || reduce) return;
let seqIdx = 0;
const timer = window.setInterval(() => {
seqIdx = (seqIdx + 1) % FOCUS_SEQ.length;
focusDay(FOCUS_SEQ[seqIdx]);
}, 500);
return () => window.clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDemo]);
function onKeyDown(e: { key: string; preventDefault: () => void }) {
let delta = 0;
if (e.key === 'ArrowRight') delta = 1;
else if (e.key === 'ArrowLeft') delta = -1;
else if (e.key === 'ArrowDown') delta = 7;
else if (e.key === 'ArrowUp') delta = -7;
else return;
e.preventDefault();
human();
focusDay(focusedDay + delta);
}
const lead = Array.from({ length: LEAD }, (_, i) => <span key={'l' + i} className={`${styles.kbCell} ${styles.kbCellOut}`} />);
const days = Array.from({ length: N_DAYS }, (_, i) => {
const d = i + 1;
return (
<button
type="button"
key={'d' + d}
className={styles.kbCell}
tabIndex={d === focusedDay ? 0 : -1}
ref={(el: HTMLButtonElement | null) => { if (el) cellRefs.current[d] = el; }}
onClick={() => { human(); focusDay(d); }}
>
{d}
</button>
);
});
const trail = Array.from({ length: TRAIL }, (_, i) => <span key={'t' + i} className={`${styles.kbCell} ${styles.kbCellOut}`} />);
return (
<div className={`${styles.kb} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.kbHead}><span className={styles.kbLabel}>2026년 3월</span></div>
<div className={styles.kbWeekday} aria-hidden="true">
{WEEKDAY.map((w) => <span key={w}>{w}</span>)}
</div>
<div className={styles.kbGridWrap}>
<div className={styles.kbRing} ref={ringRef} />
<div className={styles.kbGrid} ref={gridRef} role="grid" aria-label="달력" onKeyDown={onKeyDown}>
{lead}{days}{trail}
</div>
</div>
</div>
);
}03 · キーボード移動切替 — 方向値が変わるたびハイライトセルの値がグリッド上を移動する方式 — 計測 累積面積 2.131% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
grid.addEventListener('keydown', function (e) {
var delta = 0;
if (e.key === 'ArrowRight') delta = 1;
else if (e.key === 'ArrowLeft') delta = -1;
else if (e.key === 'ArrowDown') delta = 7;
else if (e.key === 'ArrowUp') delta = -7;
else return;
e.preventDefault();
human();
focusDay(focusedDay + delta);
});
grid.addEventListener('click', function (e) {
var cellEl = e.target.closest('.kb__cell');
if (!cellEl || !cellEl.dataset.day) return;
human();
focusDay(parseInt(cellEl.dataset.day, 10));
});
数値が語ること
03番は累積面積2.13%・平均強度111.3を23サンプル中8フレームで示し、リングがセルからセルへ移るだけでグリッド自体は再描画されないため、面積は薄く保たれます。
04範囲外霞み切替 — 許可範囲の値を外れた日付の霞み値が大きくなり選べない状態に変わる方式
許可範囲の値を外れたセルは霞み値が上がり、押下時の反応値も失って他のセルから浮き上がります。予約可能期間が限られた予約ピッカーや、有効期間内に限られるクーポン利用日に向く方式で、範囲外の日付が一目で選べない状態だと分かる必要がある場面に合います。

<main class="stage">
<div class="mf is-demo" id="mf">
<div class="mf__head"><span class="mf__label">2026年3月</span></div>
<div class="mf__weekday" aria-hidden="true">
<span>日</span><span>月</span><span>火</span><span>水</span><span>木</span><span>金</span><span>土</span>
</div>
<div class="mf__grid" id="mfGrid"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: recompute() 가 각 날짜와 최소·최대 경계 사이의 거리를 계산해
// 범위 밖 셀의 opacity 를 그 거리만큼 낮춘다(선형 감쇠, 하한 있음).
@include stage($stage-paper);
.mf {
width: 100%;
max-width: 220px;
background: $subject-cream;
border-radius: $r-card;
padding: $sp-2;
box-shadow: $shadow-raised;
box-sizing: border-box;
}
.mf__head {
text-align: center;
margin-bottom: $sp-1;
}
.mf__label {
font-size: 11px;
font-weight: 700;
color: $subject-ink;
}
.mf__weekday {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: $sp-1;
}
.mf__weekday span {
font-size: 9px;
text-align: center;
color: rgba($subject-ink, .72);
}
.mf__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: 21px;
}
.mf__cell {
display: grid;
place-items: center;
border: none;
background: transparent;
font-size: 11px;
color: $subject-ink;
border-radius: $r-xs;
cursor: pointer;
transition: opacity $dur-base $ease-out;
}
.mf__cell--out {
visibility: hidden;
}
.mf__cell--out-range {
cursor: default;
}
.mf__cell--bound {
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 700;
}
@media (max-width: 340px) {
.mf { max-width: 180px; }
.mf__grid { grid-auto-rows: 17px; }
.mf__cell { font-size: 10px; }
.mf__weekday span { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.mf__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);
}
.mf {
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;
}
.mf__head {
text-align: center;
margin-bottom: 4px;
}
.mf__label {
font-size: 11px;
font-weight: 700;
color: #17141a;
}
.mf__weekday {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 4px;
}
.mf__weekday span {
font-size: 9px;
text-align: center;
color: rgba(23, 20, 26, 0.72);
}
.mf__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: 21px;
}
.mf__cell {
display: grid;
place-items: center;
border: none;
background: transparent;
font-size: 11px;
color: #17141a;
border-radius: 4px;
cursor: pointer;
transition: opacity 300ms ease-out;
}
.mf__cell--out {
visibility: hidden;
}
.mf__cell--out-range {
cursor: default;
}
.mf__cell--bound {
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
}
@media (max-width: 340px) {
.mf {
max-width: 180px;
}
.mf__grid {
grid-auto-rows: 17px;
}
.mf__cell {
font-size: 10px;
}
.mf__weekday span {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.mf__cell {
transition: none;
}
}(function () {
var BASE = { y: 2026, m: 3, d: 14 }; // 계산 겹 상수: 고정 오늘, new Date() 미사용
var CELL_TOTAL = 42;
var FADE_STEP = .12; // 계산 겹: 경계에서 하루 멀어질 때마다 불투명도가 이만큼 줄어든다
var FADE_FLOOR = .15; // 계산 겹: 아무리 멀어도 이 아래로는 안 내려간다
var RANGE_SEQ = [[5, 20], [10, 25], [1, 12], [17, 29], [5, 20]]; // 계산 겹: 자동재생이 도는 최소·최대 순서
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 mf = document.getElementById('mf');
var grid = document.getElementById('mfGrid');
var n = daysInMonth(BASE.y, BASE.m);
var lead = dow(BASE.y, BASE.m, 1);
var cellByDay = {};
var i, cell;
for (i = 0; i < lead; i++) {
cell = document.createElement('span');
cell.className = 'mf__cell mf__cell--out';
grid.appendChild(cell);
}
for (var d = 1; d <= n; d++) {
cell = document.createElement('button');
cell.type = 'button';
cell.className = 'mf__cell';
cell.textContent = String(d);
cell.dataset.day = String(d);
grid.appendChild(cell);
cellByDay[d] = cell;
}
var trail = CELL_TOTAL - lead - n;
for (i = 0; i < trail; i++) {
cell = document.createElement('span');
cell.className = 'mf__cell mf__cell--out';
grid.appendChild(cell);
}
var min = 5, max = 20, pickStage = 'min';
function recompute() {
// 계산 겹: 범위 밖 각 셀은 경계로부터의 거리만큼 불투명도를 계산해 낮춘다
for (var day = 1; day <= n; day++) {
var c = cellByDay[day];
var distOut = day < min ? (min - day) : (day > max ? (day - max) : 0);
var opacity = distOut === 0 ? 1 : Math.max(FADE_FLOOR, 1 - distOut * FADE_STEP);
c.style.opacity = String(opacity);
c.classList.toggle('mf__cell--bound', day === min || day === max);
c.classList.toggle('mf__cell--out-range', distOut > 0);
}
}
function human() {
mf.classList.remove('is-demo');
stopAutoplay();
}
grid.addEventListener('click', function (e) {
var cellEl = e.target.closest('.mf__cell');
if (!cellEl || !cellEl.dataset.day) return;
human();
var day = parseInt(cellEl.dataset.day, 10);
if (pickStage === 'min') {
min = day; max = day; pickStage = 'max';
} else if (day >= min) {
max = day; pickStage = 'min';
} else {
min = day; max = day;
}
recompute();
});
var seqIdx = 0;
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
function tick() {
seqIdx = (seqIdx + 1) % RANGE_SEQ.length;
min = RANGE_SEQ[seqIdx][0];
max = RANGE_SEQ[seqIdx][1];
recompute();
}
recompute();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(tick, 700); }
})();import { useEffect, useState } from 'react';
import styles from './DatePickerMinMaxFade.module.scss';
const BASE = { y: 2026, m: 3, d: 14 }; // 계산 겹 상수: 고정 오늘, new Date() 미사용
const FADE_STEP = .12; // 계산 겹: 경계에서 하루 멀어질 때마다 불투명도가 이만큼 줄어든다
const FADE_FLOOR = .15; // 계산 겹: 아무리 멀어도 이 아래로는 안 내려간다
const RANGE_SEQ: [number, number][] = [[5, 20], [10, 25], [1, 12], [17, 29], [5, 20]]; // 계산 겹: 자동재생이 도는 최소·최대 순서
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 CELL_TOTAL = 42;
const N_DAYS = daysInMonth(BASE.y, BASE.m);
const LEAD = dow(BASE.y, BASE.m, 1);
const TRAIL = CELL_TOTAL - LEAD - N_DAYS;
function fadeOf(day: number, min: number, max: number) {
// 계산 겹: 범위 밖 각 셀은 경계로부터의 거리만큼 불투명도를 계산해 낮춘다
const distOut = day < min ? min - day : day > max ? day - max : 0;
return distOut === 0 ? 1 : Math.max(FADE_FLOOR, 1 - distOut * FADE_STEP);
}
export default function DatePickerMinMaxFade() {
const [isDemo, setIsDemo] = useState(true);
const [min, setMin] = useState(5);
const [max, setMax] = useState(20);
const [pickStage, setPickStage] = useState<'min' | 'max'>('min');
function human() {
setIsDemo(false);
}
function onCellClick(day: number) {
human();
if (pickStage === 'min') {
setMin(day); setMax(day); setPickStage('max');
} else if (day >= min) {
setMax(day); setPickStage('min');
} else {
setMin(day); setMax(day);
}
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!isDemo || reduce) return;
let seqIdx = 0;
const timer = window.setInterval(() => {
seqIdx = (seqIdx + 1) % RANGE_SEQ.length;
setMin(RANGE_SEQ[seqIdx][0]);
setMax(RANGE_SEQ[seqIdx][1]);
}, 700);
return () => window.clearInterval(timer);
}, [isDemo]);
const lead = Array.from({ length: LEAD }, (_, i) => <span key={'l' + i} className={`${styles.mfCell} ${styles.mfCellOut}`} />);
const days = Array.from({ length: N_DAYS }, (_, i) => {
const d = i + 1;
const opacity = fadeOf(d, min, max);
const bound = d === min || d === max;
return (
<button
type="button"
key={'d' + d}
className={`${styles.mfCell} ${bound ? styles.mfCellBound : ''} ${opacity < 1 ? styles.mfCellOutRange : ''}`}
style={{ opacity }}
onClick={() => onCellClick(d)}
>
{d}
</button>
);
});
const trail = Array.from({ length: TRAIL }, (_, i) => <span key={'t' + i} className={`${styles.mfCell} ${styles.mfCellOut}`} />);
return (
<div className={`${styles.mf} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.mfHead}><span className={styles.mfLabel}>2026년 3월</span></div>
<div className={styles.mfWeekday} aria-hidden="true">
{WEEKDAY.map((w) => <span key={w}>{w}</span>)}
</div>
<div className={styles.mfGrid}>{lead}{days}{trail}</div>
</div>
);
}04 · 範囲外霞み切替 — 許可範囲の値を外れた日付の霞み値が大きくなり選べない状態に変わる方式 — 計測 累積面積 4.213% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function recompute() {
// 計算層: 範囲外の各セルは境界からの距離に応じて不透明度を下げる
for (var day = 1; day <= n; day++) {
var c = cellByDay[day];
var distOut = day < min ? (min - day) : (day > max ? (day - max) : 0);
var opacity = distOut === 0 ? 1 : Math.max(FADE_FLOOR, 1 - distOut * FADE_STEP);
c.style.opacity = String(opacity);
c.classList.toggle('mf__cell--bound', day === min || day === max);
c.classList.toggle('mf__cell--out-range', distOut > 0);
}
}
数値が語ること
04番は累積面積4.21%・平均強度83.8を23サンプル中14フレームで示し、範囲の最小値か最大値が変わるたびに霞みの再計算が新たに範囲外になったセルだけでなく月内のすべての日を触れています。
05今日表示切替 — 今日の値を持つマスの周りでリングが周期的に広がりながら薄くなる方式
今日の値を持つマスの周りでリングが緩やかな周期で広がって薄くなり、バッジ自身もわずかに拡大率が拍動しますが、選択値と重なっても紛れません。出勤確認カレンダーや、締切日近くの日程確認に役立つ方式で、今日を選択日と誤認させずに目立たせる必要がある場面に合います。

<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 に同梱 · パスワードを見る
function pulseAt(t) {
// 計算層: 指数減衰 × コサイン振動 — スプリングが収まる物理量をそのまま計算する
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 };
}
数値が語ること
05番は累積面積3.24%・平均強度38.3を23サンプル中16フレームで示します。バッジ大のエフェクトなので面積が小さいのは想定どおりで、フレーム数が多いのは実際に広く再描画しているからではなく、pulseAt() が1,100msの全サイクルにわたって減衰×振動の式を計算し続けているためです。
06クイック選択切替 — チップの値を選ぶと格子を経ずに日付の値が直接決まる方式
チップの値をオンにすると格子に触れずに日付の値が直接決まり、そのチップの背景値だけが塗られて選択を示します。レポートの期間クイック選択や、検索フィルターの直近期間ショートカットに向く方式で、範囲の両端をクリックするより言葉で期間を指定する方が速い場面に合います。

<main class="stage">
<div class="ps is-demo" id="ps">
<div class="ps__chips" id="psChips"></div>
<div class="ps__head"><span class="ps__label">2026年3月</span></div>
<div class="ps__weekday" aria-hidden="true">
<span>日</span><span>月</span><span>火</span><span>水</span><span>木</span><span>金</span><span>土</span>
</div>
<div class="ps__grid" id="psGrid"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: applyPreset() 이 선택 범위 시작으로부터 각 셀의 위치(pos)를 구해
// pos*STAGGER 만큼 transition-delay 를 매겨 순차 채움을 만든다.
@include stage($stage-yellow);
.ps { width: 100%; max-width: 230px; background: $subject-ink; border-radius: $r-card; padding: $sp-2; box-shadow: $shadow-raised; box-sizing: border-box; }
.ps__chips { display: flex; gap: $sp-1; margin-bottom: $sp-2; }
.ps__chip {
flex: 1 1 auto;
border: none;
border-radius: $r-pill;
padding: $sp-1 $sp-1;
font-size: 9px;
font-weight: 700;
color: $subject-cream;
background: rgba($subject-cream, .16);
cursor: pointer;
transition: background-color $dur-quick $ease-out;
}
.ps__chip--active { background: $subject-blue-deep; }
.ps__head { text-align: center; margin-bottom: $sp-1; }
.ps__label { font-size: 11px; font-weight: 700; color: $subject-cream; }
.ps__weekday, .ps__grid { display: grid; grid-template-columns: repeat(7, 1fr); }
.ps__weekday span { font-size: 9px; text-align: center; color: rgba($subject-cream, .72); padding-bottom: $sp-1; }
.ps__cell {
display: grid;
place-items: center;
height: 18px;
font-size: 10px;
color: $subject-cream;
border-radius: $r-xs;
border: none;
background: transparent;
transition: background-color $dur-base $ease-out, transform $dur-base $ease-out;
}
.ps__cell--out { visibility: hidden; }
.ps__cell--on { background: $subject-blue-deep; color: $subject-cream; font-weight: 700; transform: scale(1.05); }
@media (max-width: 340px) {
.ps { max-width: 190px; }
.ps__chip { font-size: 8px; padding: $sp-1 0; }
.ps__cell { height: 15px; font-size: 9px; }
.ps__weekday span { font-size: 8px; padding-bottom: 0; }
}
@media (prefers-reduced-motion: reduce) {
.ps__cell, .ps__chip { 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);
}
.ps {
width: 100%;
max-width: 230px;
background: #17141a;
border-radius: 16px;
padding: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.ps__chips {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.ps__chip {
flex: 1 1 auto;
border: none;
border-radius: 999px;
padding: 4px 4px;
font-size: 9px;
font-weight: 700;
color: #fff7e6;
background: rgba(255, 247, 230, 0.16);
cursor: pointer;
transition: background-color 220ms ease-out;
}
.ps__chip--active {
background: #0a4fe8;
}
.ps__head {
text-align: center;
margin-bottom: 4px;
}
.ps__label {
font-size: 11px;
font-weight: 700;
color: #fff7e6;
}
.ps__weekday, .ps__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.ps__weekday span {
font-size: 9px;
text-align: center;
color: rgba(255, 247, 230, 0.72);
padding-bottom: 4px;
}
.ps__cell {
display: grid;
place-items: center;
height: 18px;
font-size: 10px;
color: #fff7e6;
border-radius: 4px;
border: none;
background: transparent;
transition: background-color 300ms ease-out, transform 300ms ease-out;
}
.ps__cell--out {
visibility: hidden;
}
.ps__cell--on {
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
transform: scale(1.05);
}
@media (max-width: 340px) {
.ps {
max-width: 190px;
}
.ps__chip {
font-size: 8px;
padding: 4px 0;
}
.ps__cell {
height: 15px;
font-size: 9px;
}
.ps__weekday span {
font-size: 8px;
padding-bottom: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.ps__cell, .ps__chip {
transition: none;
}
}(function () {
var BASE = { y: 2026, m: 3, d: 14 }; // 계산 겹 상수: 고정 오늘, new Date() 미사용
var CELL_TOTAL = 42;
var STAGGER = 28; // ms, 계산 겹: 범위 시작에서 이 셀까지의 위치 x STAGGER = 전환 지연
var PRESETS = [
{ id: 'week', label: '今週', start: 8, end: 14 },
{ id: 'next7', label: '次の7日間', start: 14, end: 20 },
{ id: 'month', label: '今月', start: 1, end: 31 }
];
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 ps = document.getElementById('ps');
var chipsWrap = document.getElementById('psChips');
var grid = document.getElementById('psGrid');
var n = daysInMonth(BASE.y, BASE.m);
var lead = dow(BASE.y, BASE.m, 1);
var trail = CELL_TOTAL - lead - n;
var cellByDay = {};
var i, cell;
for (i = 0; i < lead; i++) {
cell = document.createElement('span');
cell.className = 'ps__cell ps__cell--out';
grid.appendChild(cell);
}
for (var d = 1; d <= n; d++) {
cell = document.createElement('span');
cell.className = 'ps__cell';
cell.textContent = String(d);
grid.appendChild(cell);
cellByDay[d] = cell;
}
for (i = 0; i < trail; i++) {
cell = document.createElement('span');
cell.className = 'ps__cell ps__cell--out';
grid.appendChild(cell);
}
var chipEls = PRESETS.map(function (p, idx) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'ps__chip';
b.textContent = p.label;
b.addEventListener('click', function () { human(); applyPreset(idx); });
chipsWrap.appendChild(b);
return b;
});
function applyPreset(idx) {
// 계산 겹: 선택 범위 시작으로부터의 위치(pos)만큼 전환 지연을 매겨 순차 채움을 만든다
var p = PRESETS[idx];
for (var day = 1; day <= n; day++) {
var c = cellByDay[day];
var inRange = day >= p.start && day <= p.end;
if (inRange) {
var pos = day - p.start;
c.style.transitionDelay = (pos * STAGGER) + 'ms';
} else {
c.style.transitionDelay = '0ms';
}
c.classList.toggle('ps__cell--on', inRange);
}
chipEls.forEach(function (el, i2) { el.classList.toggle('ps__chip--active', i2 === idx); });
}
function human() {
ps.classList.remove('is-demo');
stopAutoplay();
}
var seqIdx = -1;
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
function tick() {
seqIdx = (seqIdx + 1) % PRESETS.length;
applyPreset(seqIdx);
}
tick();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(tick, 650); }
})();import { useEffect, useRef, useState } from 'react';
import styles from './DatePickerPresetChips.module.scss';
type Box<T> = { current: T };
const BASE = { y: 2026, m: 3, d: 14 }; // 계산 겹 상수: 고정 오늘, new Date() 미사용
const CELL_TOTAL = 42;
const STAGGER = 28; // ms, 계산 겹: 범위 시작에서 이 셀까지의 위치 x STAGGER = 전환 지연
const PRESETS: { id: string; label: string; start: number; end: number }[] = [
{ id: 'week', label: '이번 주', start: 8, end: 14 },
{ id: 'next7', label: '다음 7일', start: 14, end: 20 },
{ id: 'month', label: '이번 달', start: 1, end: 31 },
];
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 delayOf(day: number, presetIdx: number) {
// 계산 겹: 선택 범위 시작으로부터의 위치(pos)만큼 전환 지연을 매겨 순차 채움을 만든다
const p = PRESETS[presetIdx];
if (day < p.start || day > p.end) return 0;
return (day - p.start) * STAGGER;
}
function inRangeOf(day: number, presetIdx: number) {
const p = PRESETS[presetIdx];
return day >= p.start && day <= p.end;
}
export default function DatePickerPresetChips() {
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const [isDemo, setIsDemo] = useState(true);
const [presetIdx, setPresetIdx] = useState(0);
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return;
let seqIdx = 0;
const timer = window.setInterval(() => {
if (!isDemoRef.current) return;
seqIdx = (seqIdx + 1) % PRESETS.length;
setPresetIdx(seqIdx);
}, 650);
return () => window.clearInterval(timer);
}, []);
function pickPreset(idx: number) {
human();
setPresetIdx(idx);
}
const lead = Array.from({ length: LEAD }, (_, i) => <span key={'l' + i} className={`${styles.psCell} ${styles.psCellOut}`} />);
const days = Array.from({ length: N_DAYS }, (_, i) => {
const d = i + 1;
const on = inRangeOf(d, presetIdx);
return (
<span
key={'d' + d}
className={`${styles.psCell} ${on ? styles.psCellOn : ''}`}
style={{ transitionDelay: delayOf(d, presetIdx) + 'ms' }}
>
{d}
</span>
);
});
const trail = Array.from({ length: TRAIL }, (_, i) => <span key={'t' + i} className={`${styles.psCell} ${styles.psCellOut}`} />);
return (
<div className={`${styles.ps} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.psChips}>
{PRESETS.map((p, idx) => (
<button
type="button"
key={p.id}
className={`${styles.psChip} ${idx === presetIdx ? styles.psChipActive : ''}`}
onClick={() => pickPreset(idx)}
>
{p.label}
</button>
))}
</div>
<div className={styles.psHead}><span className={styles.psLabel}>2026년 3월</span></div>
<div className={styles.psWeekday} aria-hidden="true">
{WEEKDAY.map((w) => <span key={w}>{w}</span>)}
</div>
<div className={styles.psGrid}>{lead}{days}{trail}</div>
</div>
);
}06 · クイック選択切替 — チップの値を選ぶと格子を経ずに日付の値が直接決まる方式 — 計測 累積面積 11.493% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function applyPreset(idx) {
// 計算層: 選択範囲の開始日からの位置(pos)分だけ遷移を遅らせ、順番に塗る動きを作る
var p = PRESETS[idx];
for (var day = 1; day <= n; day++) {
var c = cellByDay[day];
var inRange = day >= p.start && day <= p.end;
if (inRange) {
var pos = day - p.start;
c.style.transitionDelay = (pos * STAGGER) + 'ms';
} else {
c.style.transitionDelay = '0ms';
}
c.classList.toggle('ps__cell--on', inRange);
}
chipEls.forEach(function (el, i2) { el.classList.toggle('ps__chip--active', i2 === idx); });
}
数値が語ること
06番は累積面積11.49%・平均強度63.8を23サンプル中18フレームで示し、applyPreset() のセルごとの遷移遅延が、背景の塗りを一度に終わらせず、ほぼ全サンプルにわたって動きを広げています。
07シート切替 — 画面幅の値が狭くなるとポップオーバーの値が下から上がるシートの値に変わる方式
画面幅の値がしきい値を下回ると、同じカレンダーがポップオーバーではなく下から上がるシートとして現れ、ボタンを操作すると幅の値が切り替わります。モバイル予約アプリの日付選択や、狭い画面での絞り込みパネルに向く方式で、中央固定のポップオーバーが小さな画面の端からはみ出す事態を避けられます。

<main class="stage">
<div class="ms is-demo" id="ms">
<div class="ms__viewport" id="msViewport">
<button type="button" class="ms__field" id="msField">
<span class="ms__fieldLabel">日付</span>
<span class="ms__fieldValue">2026年3月14日</span>
</button>
<div class="ms__panel" id="msPanel">
<div class="ms__panelHead">2026年3月</div>
<div class="ms__strip">
<span class="ms__day">12</span><span class="ms__day">13</span><span class="ms__day ms__day--today">14</span><span class="ms__day">15</span><span class="ms__day">16</span>
</div>
</div>
</div>
<button type="button" class="ms__toggle" id="msToggle">幅切替</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: measureMode() 가 viewport 요소의 실측 폭(getBoundingClientRect)을 임계값과
// 비교해 popover(작은 카드)와 bottom-sheet(전체 폭) 두 형태 중 하나를 고른다.
@include stage($stage-orange);
.ms { position: relative; width: 100%; max-width: 230px; display: grid; gap: $sp-2; justify-items: center; }
.ms__viewport { position: relative; width: 200px; height: 168px; transition: width $dur-base $ease-spring; }
.ms__viewport--narrow { width: 92px; }
.ms__field {
position: absolute; top: 0; left: 0; right: 0;
display: grid; gap: $sp-1; padding: $sp-1 $sp-2;
border: none; border-radius: $r-control;
background: $subject-cream; color: $subject-ink; text-align: left; cursor: pointer;
box-shadow: $shadow-raised;
}
.ms__fieldLabel { font-size: 8px; opacity: .68; }
.ms__fieldValue { font-size: 10px; font-weight: 700; }
.ms__panel {
position: absolute;
left: 0; right: 0; bottom: 0;
box-sizing: border-box;
padding: $sp-2;
border-radius: $r-card $r-card 0 0;
background: $stage-paper;
box-shadow: $shadow-raised;
transform: scaleY(0);
transform-origin: bottom center;
opacity: 0;
pointer-events: none;
transition: transform $dur-enter $ease-elastic, opacity $dur-quick $ease-out;
}
.ms__panel.ms--open.ms__panel--popover { transform: scale(.6, .46); opacity: 1; pointer-events: auto; }
.ms__panel.ms--open.ms__panel--sheet { transform: scale(1, 1); opacity: 1; pointer-events: auto; }
.ms__panelHead { font-size: 9px; font-weight: 700; color: $subject-ink; text-align: center; margin-bottom: $sp-1; }
.ms__strip { display: flex; gap: $sp-1; justify-content: center; }
.ms__day { display: grid; place-items: center; width: 18px; height: 18px; font-size: 9px; color: $subject-ink; border-radius: $r-xs; }
.ms__day--today { background: $subject-blue-deep; color: $subject-cream; font-weight: 700; }
.ms__toggle {
border: none; border-radius: $r-pill; padding: $sp-1 $sp-3;
font-size: 10px; font-weight: 700; color: $subject-ink; background: $subject-cream; cursor: pointer;
}
@media (max-width: 340px) {
.ms { max-width: 170px; }
.ms__viewport { width: 150px; height: 140px; }
.ms__viewport--narrow { width: 76px; }
.ms__day { width: 15px; height: 15px; font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.ms__panel, .ms__viewport { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ms {
position: relative;
width: 100%;
max-width: 230px;
display: grid;
gap: 8px;
justify-items: center;
}
.ms__viewport {
position: relative;
width: 200px;
height: 168px;
transition: width 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ms__viewport--narrow {
width: 92px;
}
.ms__field {
position: absolute;
top: 0;
left: 0;
right: 0;
display: grid;
gap: 4px;
padding: 4px 8px;
border: none;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
text-align: left;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ms__fieldLabel {
font-size: 8px;
opacity: 0.68;
}
.ms__fieldValue {
font-size: 10px;
font-weight: 700;
}
.ms__panel {
position: absolute;
left: 0;
right: 0;
bottom: 0;
box-sizing: border-box;
padding: 8px;
border-radius: 16px 16px 0 0;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: scaleY(0);
transform-origin: bottom center;
opacity: 0;
pointer-events: none;
transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 220ms ease-out;
}
.ms__panel.ms--open.ms__panel--popover {
transform: scale(0.6, 0.46);
opacity: 1;
pointer-events: auto;
}
.ms__panel.ms--open.ms__panel--sheet {
transform: scale(1, 1);
opacity: 1;
pointer-events: auto;
}
.ms__panelHead {
font-size: 9px;
font-weight: 700;
color: #17141a;
text-align: center;
margin-bottom: 4px;
}
.ms__strip {
display: flex;
gap: 4px;
justify-content: center;
}
.ms__day {
display: grid;
place-items: center;
width: 18px;
height: 18px;
font-size: 9px;
color: #17141a;
border-radius: 4px;
}
.ms__day--today {
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
}
.ms__toggle {
border: none;
border-radius: 999px;
padding: 4px 12px;
font-size: 10px;
font-weight: 700;
color: #17141a;
background: #fff7e6;
cursor: pointer;
}
@media (max-width: 340px) {
.ms {
max-width: 170px;
}
.ms__viewport {
width: 150px;
height: 140px;
}
.ms__viewport--narrow {
width: 76px;
}
.ms__day {
width: 15px;
height: 15px;
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ms__panel, .ms__viewport {
transition: none;
}
}(function () {
var THRESHOLD = 140; // px, 계산 겹: 이 폭 미만이면 바텀시트로 본다
var ms = document.getElementById('ms');
var viewport = document.getElementById('msViewport');
var field = document.getElementById('msField');
var panel = document.getElementById('msPanel');
var toggleBtn = document.getElementById('msToggle');
var wide = true;
var open = false;
function measureMode() {
// 계산 겹: 뷰포트 요소의 실제 렌더 폭을 실측해 팝오버/시트 형태를 고른다
var w = viewport.getBoundingClientRect().width;
return w < THRESHOLD ? 'sheet' : 'popover';
}
function render() {
var mode = measureMode();
panel.classList.toggle('ms--open', open);
panel.classList.toggle('ms__panel--sheet', mode === 'sheet');
panel.classList.toggle('ms__panel--popover', mode === 'popover');
}
function human() {
ms.classList.remove('is-demo');
stopAutoplay();
}
field.addEventListener('click', function () {
human();
open = !open;
render();
});
toggleBtn.addEventListener('click', function () {
human();
wide = !wide;
viewport.classList.toggle('ms__viewport--narrow', !wide);
open = true;
render();
});
var steps = [
function () { wide = true; viewport.classList.remove('ms__viewport--narrow'); open = true; },
function () { wide = false; viewport.classList.add('ms__viewport--narrow'); open = true; },
function () { open = false; },
function () { wide = true; viewport.classList.remove('ms__viewport--narrow'); open = true; },
function () { wide = false; viewport.classList.add('ms__viewport--narrow'); open = true; },
function () { open = false; }
];
var stepIdx = -1;
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
function tick() {
stepIdx = (stepIdx + 1) % steps.length;
steps[stepIdx]();
render();
}
render();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(tick, 620); }
})();import { useEffect, useRef, useState } from 'react';
import styles from './DatePickerMobileSheetSwap.module.scss';
type Box<T> = { current: T };
const THRESHOLD = 140; // px, 계산 겹: 이 폭 미만이면 바텀시트로 본다
const DAYS = [12, 13, 14, 15, 16];
const TODAY = 14;
export default function DatePickerMobileSheetSwap() {
const viewportRef = useRef(null) as unknown as Box<HTMLDivElement>;
const wideRef = useRef(true) as unknown as Box<boolean>;
const openRef = useRef(false) as unknown as Box<boolean>;
const [isDemo, setIsDemo] = useState(true);
const [wide, setWide] = useState(true);
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<'popover' | 'sheet'>('popover');
function human() {
setIsDemo(false);
}
function measureMode(): 'popover' | 'sheet' {
// 계산 겹: 뷰포트 요소의 실제 렌더 폭을 실측해 팝오버/시트 형태를 고른다
const el = viewportRef.current;
if (!el) return 'popover';
const w = el.getBoundingClientRect().width;
return w < THRESHOLD ? 'sheet' : 'popover';
}
useEffect(() => {
setMode(measureMode());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wide]);
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return;
const steps: (() => void)[] = [
() => { wideRef.current = true; openRef.current = true; setWide(true); setOpen(true); },
() => { wideRef.current = false; openRef.current = true; setWide(false); setOpen(true); },
() => { openRef.current = false; setOpen(false); },
() => { wideRef.current = true; openRef.current = true; setWide(true); setOpen(true); },
() => { wideRef.current = false; openRef.current = true; setWide(false); setOpen(true); },
() => { openRef.current = false; setOpen(false); },
];
let stepIdx = -1;
const timer = window.setInterval(() => {
if (!isDemo) return;
stepIdx = (stepIdx + 1) % steps.length;
steps[stepIdx]();
}, 620);
return () => window.clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDemo]);
function onFieldClick() {
human();
setOpen((v) => !v);
}
function onToggleClick() {
human();
setWide((v) => !v);
setOpen(true);
}
return (
<div className={`${styles.ms} ${isDemo ? styles.isDemo : ''}`}>
<div className={`${styles.msViewport} ${!wide ? styles.msViewportNarrow : ''}`} ref={viewportRef}>
<button type="button" className={styles.msField} onClick={onFieldClick}>
<span className={styles.msFieldLabel}>날짜</span>
<span className={styles.msFieldValue}>2026년 3월 14일</span>
</button>
<div
className={`${styles.msPanel} ${open ? styles.isOpen : ''} ${mode === 'sheet' ? styles.msPanelSheet : styles.msPanelPopover}`}
>
<div className={styles.msPanelHead}>2026년 3월</div>
<div className={styles.msStrip}>
{DAYS.map((d) => (
<span key={d} className={`${styles.msDay} ${d === TODAY ? styles.msDayToday : ''}`}>{d}</span>
))}
</div>
</div>
</div>
<button type="button" className={styles.msToggle} onClick={onToggleClick}>폭 전환</button>
</div>
);
}07 · シート切替 — 画面幅の値が狭くなるとポップオーバーの値が下から上がるシートの値に変わる方式 — 計測 累積面積 17.928% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function measureMode() {
// 計算層: ビューポート要素の実際のレンダー幅を実測してポップオーバーかシートかを選ぶ
var w = viewport.getBoundingClientRect().width;
return w < THRESHOLD ? 'sheet' : 'popover';
}
function render() {
var mode = measureMode();
panel.classList.toggle('ms--open', open);
panel.classList.toggle('ms__panel--sheet', mode === 'sheet');
panel.classList.toggle('ms__panel--popover', mode === 'popover');
}
function human() {
ms.classList.remove('is-demo');
stopAutoplay();
}
field.addEventListener('click', function () {
human();
open = !open;
render();
});
数値が語ること
07番は累積面積17.93%・平均強度106.2を23サンプル中15フレームで示し、下から上がるシートの動きは、01番のポップオーバー表示を除けばこの9つの中で1フレームあたりの面積が最も広い動きです。
08拒否振動切替 — ブロックされた値に触れるとそのマスの位置の値が短く左右に揺れる方式
ブロックされた値に触れるとそのマスの位置の値が短く左右に揺れ、通知バナーの不透明度の値も一瞬上がってから消えます。既に予約済みの日付の再選択を防ぐ場合や、受付終了日を示す場合に向く方式で、揺れが特定のクリックへの直接的な返答として読み取れます。

<main class="stage">
<div class="kd is-demo" id="kd">
<div class="kd__head"><span class="kd__label">2026年3月</span></div>
<div class="kd__weekday" aria-hidden="true">
<span>日</span><span>月</span><span>火</span><span>水</span><span>木</span><span>金</span><span>土</span>
</div>
<div class="kd__grid" id="kdGrid"></div>
<div class="kd__banner" id="kdBanner">週末は選択できません</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 잘못된 날짜를 고르면 loop() 이 지수 감쇠 x 코사인 진동으로
// 그 셀의 translateX 를 흔들고(damped shake), 동시에 거부 배너를 띄운다.
@include stage($stage-paper);
.kd { width: 100%; max-width: 230px; background: $subject-cream; border-radius: $r-card; padding: $sp-2; box-shadow: $shadow-raised; box-sizing: border-box; }
.kd__head { text-align: center; margin-bottom: $sp-1; }
.kd__label { font-size: 11px; font-weight: 700; color: $subject-ink; }
.kd__weekday, .kd__grid { display: grid; grid-template-columns: repeat(7, 1fr); }
.kd__weekday span { font-size: 9px; text-align: center; color: rgba($subject-ink, .72); padding-bottom: $sp-1; }
.kd__cell {
display: grid; place-items: center; height: 19px; font-size: 10px; color: $subject-ink;
border-radius: $r-xs; border: none; background: transparent; cursor: pointer; will-change: transform;
}
.kd__cell--out { visibility: hidden; }
.kd__cell--invalid { color: rgba($subject-ink, .38); cursor: not-allowed; }
.kd__cell--selected { background: $subject-blue-deep; color: $subject-cream; font-weight: 700; }
.kd__banner {
margin-top: $sp-1;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: $error-soft;
color: $error;
font-size: 9px;
font-weight: 700;
text-align: center;
opacity: 0;
transition: opacity $dur-quick $ease-out;
}
.kd__banner--show { opacity: 1; }
@media (max-width: 340px) {
.kd { max-width: 190px; }
.kd__cell { height: 16px; font-size: 9px; }
.kd__weekday span { font-size: 8px; padding-bottom: 0; }
.kd__banner { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.kd__cell { transform: none !important; }
.kd__banner { 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);
}
.kd {
width: 100%;
max-width: 230px;
background: #fff7e6;
border-radius: 16px;
padding: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.kd__head {
text-align: center;
margin-bottom: 4px;
}
.kd__label {
font-size: 11px;
font-weight: 700;
color: #17141a;
}
.kd__weekday, .kd__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.kd__weekday span {
font-size: 9px;
text-align: center;
color: rgba(23, 20, 26, 0.72);
padding-bottom: 4px;
}
.kd__cell {
display: grid;
place-items: center;
height: 19px;
font-size: 10px;
color: #17141a;
border-radius: 4px;
border: none;
background: transparent;
cursor: pointer;
will-change: transform;
}
.kd__cell--out {
visibility: hidden;
}
.kd__cell--invalid {
color: rgba(23, 20, 26, 0.38);
cursor: not-allowed;
}
.kd__cell--selected {
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
}
.kd__banner {
margin-top: 4px;
padding: 4px 8px;
border-radius: 4px;
background: #ffe0d8;
color: #c2361a;
font-size: 9px;
font-weight: 700;
text-align: center;
opacity: 0;
transition: opacity 220ms ease-out;
}
.kd__banner--show {
opacity: 1;
}
@media (max-width: 340px) {
.kd {
max-width: 190px;
}
.kd__cell {
height: 16px;
font-size: 9px;
}
.kd__weekday span {
font-size: 8px;
padding-bottom: 0;
}
.kd__banner {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.kd__cell {
transform: none !important;
}
.kd__banner {
transition: none;
}
}(function () {
var BASE = { y: 2026, m: 3, d: 14 }; // 계산 겹 상수: 고정 오늘, new Date() 미사용
var CELL_TOTAL = 42;
var SHAKE_DUR = 420; // ms, 계산 겹: 감쇠 진동이 잦아드는 데 걸리는 시간
var SHAKE_FREQ = 2 * Math.PI * 3.4 / SHAKE_DUR; // 계산 겹: 흔들림 각속도
var SHAKE_ZETA = 6.5; // 계산 겹: 감쇠 상수
var SHAKE_AMP = 8; // px, 흔들림 최대 진폭
var BANNER_MS = 900; // ms, 거부 배너가 떠 있는 시간
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;
}
function isWeekend(y, m, d) {
var w = dow(y, m, d);
return w === 0 || w === 6;
}
var kd = document.getElementById('kd');
var grid = document.getElementById('kdGrid');
var banner = document.getElementById('kdBanner');
var n = daysInMonth(BASE.y, BASE.m);
var lead = dow(BASE.y, BASE.m, 1);
var trail = CELL_TOTAL - lead - n;
var cellByDay = {};
var invalidDays = [];
var i, cell;
for (i = 0; i < lead; i++) {
cell = document.createElement('span');
cell.className = 'kd__cell kd__cell--out';
grid.appendChild(cell);
}
for (var d = 1; d <= n; d++) {
var invalid = isWeekend(BASE.y, BASE.m, d);
cell = document.createElement('button');
cell.type = 'button';
cell.className = 'kd__cell' + (invalid ? ' kd__cell--invalid' : '');
cell.textContent = String(d);
cell.dataset.day = String(d);
if (invalid) { cell.dataset.invalid = '1'; invalidDays.push(d); }
grid.appendChild(cell);
cellByDay[d] = cell;
}
for (i = 0; i < trail; i++) {
cell = document.createElement('span');
cell.className = 'kd__cell kd__cell--out';
grid.appendChild(cell);
}
var selected = null;
var shakeTarget = null;
var shakeStart = 0;
var bannerTimer = null;
var rafId = null;
function shakeAt(t) {
// 계산 겹: 지수 감쇠 x 코사인 진동을 픽셀 오프셋으로 바꾼다
if (t < 0 || t > SHAKE_DUR) return 0;
var x = t / SHAKE_DUR;
var amp = Math.exp(-SHAKE_ZETA * x);
return SHAKE_AMP * amp * Math.cos(SHAKE_FREQ * t);
}
function loop(ts) {
if (shakeTarget) {
var elapsed = ts - shakeStart;
if (elapsed > SHAKE_DUR) {
shakeTarget.style.transform = '';
shakeTarget = null;
} else {
shakeTarget.style.transform = 'translateX(' + shakeAt(elapsed).toFixed(2) + 'px)';
}
}
rafId = requestAnimationFrame(loop);
}
function showBanner() {
banner.classList.add('kd__banner--show');
if (bannerTimer) clearTimeout(bannerTimer);
bannerTimer = setTimeout(function () { banner.classList.remove('kd__banner--show'); }, BANNER_MS);
}
function reject(day, ts) {
shakeTarget = cellByDay[day];
shakeStart = ts;
showBanner();
}
function selectDay(day) {
if (selected) cellByDay[selected].classList.remove('kd__cell--selected');
selected = day;
cellByDay[day].classList.add('kd__cell--selected');
}
function human() {
kd.classList.remove('is-demo');
stopAutoplay();
}
grid.addEventListener('click', function (e) {
var cellEl = e.target.closest('.kd__cell');
if (!cellEl || !cellEl.dataset.day) return;
human();
var day = parseInt(cellEl.dataset.day, 10);
if (cellEl.dataset.invalid) {
reject(day, performance.now());
} else {
selectDay(day);
}
});
var seqIdx = -1;
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
function tick(ts) {
seqIdx = (seqIdx + 1) % invalidDays.length;
reject(invalidDays[seqIdx], ts);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
rafId = requestAnimationFrame(loop);
autoTimer = setInterval(function () { tick(performance.now()); }, 480);
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './DatePickerInvalidShake.module.scss';
type Box<T> = { current: T };
const BASE = { y: 2026, m: 3, d: 14 }; // 계산 겹 상수: 고정 오늘, new Date() 미사용
const CELL_TOTAL = 42;
const SHAKE_DUR = 420; // ms, 계산 겹: 감쇠 진동이 잦아드는 데 걸리는 시간
const SHAKE_FREQ = (2 * Math.PI * 3.4) / SHAKE_DUR; // 계산 겹: 흔들림 각속도
const SHAKE_ZETA = 6.5; // 계산 겹: 감쇠 상수
const SHAKE_AMP = 8; // px, 흔들림 최대 진폭
const BANNER_MS = 900; // ms, 거부 배너가 떠 있는 시간
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;
}
function isWeekend(y: number, m: number, d: number) {
const w = dow(y, m, d);
return w === 0 || w === 6;
}
const N_DAYS = daysInMonth(BASE.y, BASE.m);
const LEAD = dow(BASE.y, BASE.m, 1);
const TRAIL = CELL_TOTAL - LEAD - N_DAYS;
const INVALID_DAYS: number[] = [];
for (let d = 1; d <= N_DAYS; d++) if (isWeekend(BASE.y, BASE.m, d)) INVALID_DAYS.push(d);
function shakeAt(t: number) {
// 계산 겹: 지수 감쇠 x 코사인 진동을 픽셀 오프셋으로 바꾼다
if (t < 0 || t > SHAKE_DUR) return 0;
const x = t / SHAKE_DUR;
const amp = Math.exp(-SHAKE_ZETA * x);
return SHAKE_AMP * amp * Math.cos(SHAKE_FREQ * t);
}
export default function DatePickerInvalidShake() {
const cellRefs = useRef({}) as unknown as Box<Record<number, HTMLButtonElement>>;
const shakeTargetRef = useRef(0) as unknown as Box<number>;
const shakeStartRef = useRef(0) as unknown as Box<number>;
const bannerTimerRef = useRef(0) as unknown as Box<number>;
const [isDemo, setIsDemo] = useState(true);
const [selected, setSelected] = useState<number | null>(null);
const [bannerShow, setBannerShow] = useState(false);
function human() {
setIsDemo(false);
}
function showBanner() {
setBannerShow(true);
window.clearTimeout(bannerTimerRef.current);
bannerTimerRef.current = window.setTimeout(() => setBannerShow(false), BANNER_MS);
}
function reject(day: number, ts: number) {
shakeTargetRef.current = day;
shakeStartRef.current = ts;
showBanner();
}
function onCellClick(day: number, invalid: boolean) {
human();
if (invalid) {
reject(day, performance.now());
} else {
setSelected(day);
}
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return;
let rafId = 0;
function loop(ts: number) {
const day = shakeTargetRef.current;
if (day) {
const elapsed = ts - shakeStartRef.current;
const el = cellRefs.current[day];
if (el) {
if (elapsed > SHAKE_DUR) {
el.style.transform = '';
shakeTargetRef.current = 0;
} else {
el.style.transform = 'translateX(' + shakeAt(elapsed).toFixed(2) + 'px)';
}
}
}
rafId = requestAnimationFrame(loop);
}
rafId = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rafId);
}, []);
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return;
let seqIdx = -1;
const timer = window.setInterval(() => {
if (!isDemo) return;
seqIdx = (seqIdx + 1) % INVALID_DAYS.length;
reject(INVALID_DAYS[seqIdx], performance.now());
}, 480);
return () => window.clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDemo]);
const lead = Array.from({ length: LEAD }, (_, i) => <span key={'l' + i} className={`${styles.kdCell} ${styles.kdCellOut}`} />);
const days = Array.from({ length: N_DAYS }, (_, i) => {
const d = i + 1;
const invalid = isWeekend(BASE.y, BASE.m, d);
return (
<button
type="button"
key={'d' + d}
className={`${styles.kdCell} ${invalid ? styles.kdCellInvalid : ''} ${selected === d ? styles.kdCellSelected : ''}`}
ref={(el: HTMLButtonElement | null) => { if (el) cellRefs.current[d] = el; }}
onClick={() => onCellClick(d, invalid)}
>
{d}
</button>
);
});
const trail = Array.from({ length: TRAIL }, (_, i) => <span key={'t' + i} className={`${styles.kdCell} ${styles.kdCellOut}`} />);
return (
<div className={`${styles.kd} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.kdHead}><span className={styles.kdLabel}>2026년 3월</span></div>
<div className={styles.kdWeekday} aria-hidden="true">
{WEEKDAY.map((w) => <span key={w}>{w}</span>)}
</div>
<div className={styles.kdGrid}>{lead}{days}{trail}</div>
<div className={`${styles.kdBanner} ${bannerShow ? styles.kdBannerShow : ''}`}>주말은 선택할 수 없어요</div>
</div>
);
}08 · 拒否振動切替 — ブロックされた値に触れるとそのマスの位置の値が短く左右に揺れる方式 — 計測 累積面積 3.376% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function shakeAt(t) {
// 計算層: 指数減衰 × コサイン振動をピクセルのオフセットに変換する
if (t < 0 || t > SHAKE_DUR) return 0;
var x = t / SHAKE_DUR;
var amp = Math.exp(-SHAKE_ZETA * x);
return SHAKE_AMP * amp * Math.cos(SHAKE_FREQ * t);
}
function loop(ts) {
if (shakeTarget) {
var elapsed = ts - shakeStart;
if (elapsed > SHAKE_DUR) {
shakeTarget.style.transform = '';
shakeTarget = null;
} else {
shakeTarget.style.transform = 'translateX(' + shakeAt(elapsed).toFixed(2) + 'px)';
}
}
rafId = requestAnimationFrame(loop);
}
数値が語ること
08番は累積面積3.38%・平均強度39.3を23サンプル中16フレームで示し、05番と近い数字になるのも同じ理由です。shakeAt() は1つのセルの translateX とバナーの不透明度しか動かさないため、バネのように減衰する物理演算が広い面積を必要とせずにサイクル全体を通して走ります。
09モーション削減代替切替 — モーション削減の値がオンになるとポップオーバー・シート・振動の値が即座に切り替わる方式
モーション削減の値がオンになると、前の8つのパターンにあった動きや揺れの値がすべて外れ、瞬時の表示・非表示の値だけが残ります。モーションを抑える設定を有効にしている利用者や、省電力デバイスの入力画面がまさにこの方式の対象で、他の8つが何に切り替わるかを示すためだけに作られた項目です。

<main class="stage">
<div class="rm is-demo" id="rm">
<div class="rm__col">
<span class="rm__tag">通常</span>
<div class="rm__mini">
<button type="button" class="rm__field" id="rmNormalField">3月14日</button>
<div class="rm__pop rm__pop--normal" id="rmNormalPop">
<div class="rm__popRow">12 13 <b>14</b> 15 16</div>
</div>
</div>
</div>
<div class="rm__col">
<span class="rm__tag">低モーション</span>
<div class="rm__mini">
<button type="button" class="rm__field" id="rmReducedField">3月14日</button>
<div class="rm__pop rm__pop--reduced" id="rmReducedPop">
<div class="rm__popRow">12 13 <b>14</b> 15 16</div>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 같은 열림 흐름을 두 칸에 나란히 재생한다 — 왼쪽(일반)은 스프링 스케일,
// 오른쪽(감소된 모션)은 opacity 만. prefers-reduced-motion 실측정에서는 왼쪽도 opacity 만 쓴다.
@include stage($stage-ink);
.rm { display: flex; gap: $sp-2; width: 100%; max-width: 260px; justify-content: center; }
.rm__col { display: grid; gap: $sp-1; justify-items: center; }
.rm__tag { font-size: 9px; font-weight: 700; color: $subject-blue-light; }
.rm__mini { position: relative; width: 108px; }
.rm__field {
width: 100%; border: none; border-radius: $r-control; padding: $sp-1 $sp-2;
background: $subject-cream; color: $subject-ink; font-size: 10px; font-weight: 700; cursor: pointer;
box-shadow: $shadow-raised;
}
.rm__pop {
position: absolute; top: 100%; left: 0; right: 0; margin-top: $sp-1;
box-sizing: border-box; padding: $sp-1; border-radius: $r-sm;
background: $stage-paper; box-shadow: $shadow-raised;
opacity: 0; pointer-events: none;
}
.rm__pop--normal {
transform: scale(.8);
transform-origin: top center;
transition: transform $dur-enter $ease-elastic, opacity $dur-quick $ease-out;
}
.rm__pop--normal.is-open { transform: scale(1); opacity: 1; pointer-events: auto; }
.rm__pop--reduced {
transition: opacity $dur-quick $ease-out;
}
.rm__pop--reduced.is-open { opacity: 1; pointer-events: auto; }
.rm__popRow { font-size: 9px; color: $subject-ink; text-align: center; letter-spacing: .04em; }
.rm__popRow b { color: $subject-blue-deep; }
@media (max-width: 340px) {
.rm { max-width: 200px; gap: $sp-1; }
.rm__mini { width: 84px; }
.rm__field { font-size: 9px; }
.rm__popRow { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.rm__pop--normal { transform: none; transition: opacity $dur-quick $ease-out; }
}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);
}
.rm {
display: flex;
gap: 8px;
width: 100%;
max-width: 260px;
justify-content: center;
}
.rm__col {
display: grid;
gap: 4px;
justify-items: center;
}
.rm__tag {
font-size: 9px;
font-weight: 700;
color: #5b8cf8;
}
.rm__mini {
position: relative;
width: 108px;
}
.rm__field {
width: 100%;
border: none;
border-radius: 12px;
padding: 4px 8px;
background: #fff7e6;
color: #17141a;
font-size: 10px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.rm__pop {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
box-sizing: border-box;
padding: 4px;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
pointer-events: none;
}
.rm__pop--normal {
transform: scale(0.8);
transform-origin: top center;
transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 220ms ease-out;
}
.rm__pop--normal.is-open {
transform: scale(1);
opacity: 1;
pointer-events: auto;
}
.rm__pop--reduced {
transition: opacity 220ms ease-out;
}
.rm__pop--reduced.is-open {
opacity: 1;
pointer-events: auto;
}
.rm__popRow {
font-size: 9px;
color: #17141a;
text-align: center;
letter-spacing: 0.04em;
}
.rm__popRow b {
color: #0a4fe8;
}
@media (max-width: 340px) {
.rm {
max-width: 200px;
gap: 4px;
}
.rm__mini {
width: 84px;
}
.rm__field {
font-size: 9px;
}
.rm__popRow {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rm__pop--normal {
transform: none;
transition: opacity 220ms ease-out;
}
}(function () {
var rm = document.getElementById('rm');
var normalField = document.getElementById('rmNormalField');
var reducedField = document.getElementById('rmReducedField');
var normalPop = document.getElementById('rmNormalPop');
var reducedPop = document.getElementById('rmReducedPop');
var open = false;
function render() {
normalPop.classList.toggle('is-open', open);
reducedPop.classList.toggle('is-open', open);
}
function human() {
rm.classList.remove('is-demo');
stopAutoplay();
}
function toggle() {
open = !open;
render();
}
normalField.addEventListener('click', function () { human(); toggle(); });
reducedField.addEventListener('click', function () { human(); toggle(); });
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
function tick() { toggle(); }
render();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(tick, 620); }
})();import { useEffect, useState } from 'react';
import styles from './DatePickerReducedMotion.module.scss';
export default function DatePickerReducedMotion() {
const [isDemo, setIsDemo] = useState(true);
const [open, setOpen] = useState(false);
function human() {
setIsDemo(false);
}
function toggle() {
setOpen((v) => !v);
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return;
const timer = window.setInterval(() => {
if (!isDemo) return;
setOpen((v) => !v);
}, 620);
return () => window.clearInterval(timer);
}, [isDemo]);
return (
<div className={`${styles.rm} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.rmCol}>
<span className={styles.rmTag}>일반</span>
<div className={styles.rmMini}>
<button
type="button"
className={styles.rmField}
onClick={() => { human(); toggle(); }}
>
3월 14일
</button>
<div className={`${styles.rmPop} ${styles.rmPopNormal} ${open ? styles.isOpen : ''}`}>
<div className={styles.rmPopRow}>12 13 <b>14</b> 15 16</div>
</div>
</div>
</div>
<div className={styles.rmCol}>
<span className={styles.rmTag}>감소된 모션</span>
<div className={styles.rmMini}>
<button
type="button"
className={styles.rmField}
onClick={() => { human(); toggle(); }}
>
3월 14일
</button>
<div className={`${styles.rmPop} ${styles.rmPopReduced} ${open ? styles.isOpen : ''}`}>
<div className={styles.rmPopRow}>12 13 <b>14</b> 15 16</div>
</div>
</div>
</div>
</div>
);
}09 · モーション削減代替切替 — モーション削減の値がオンになるとポップオーバー・シート・振動の値が即座に切り替わる方式 — 計測 累積面積 3.151% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.rm__pop {
position: absolute; top: 100%; left: 0; right: 0; margin-top: $sp-1;
box-sizing: border-box; padding: $sp-1; border-radius: $r-sm;
background: $stage-paper; box-shadow: $shadow-raised;
opacity: 0; pointer-events: none;
}
.rm__pop--normal {
transform: scale(.8);
transform-origin: top center;
transition: transform $dur-enter $ease-elastic, opacity $dur-quick $ease-out;
}
.rm__pop--normal.is-open { transform: scale(1); opacity: 1; pointer-events: auto; }
.rm__pop--reduced {
transition: opacity $dur-quick $ease-out;
}
.rm__pop--reduced.is-open { opacity: 1; pointer-events: auto; }
数値が語ること
09番は累積面積3.15%・平均強度76.6を23サンプル中15フレームで示し、この横並び比較はどちらもカレンダー全体ではなく2つの小さなポップオーバーだけを動かしています。
値がポップオーバーを開閉する基準 — 焦点値・範囲値・幅の基準
01番の入力欄クリックと ANCHOR_GAP 8px — 入力欄を操作した瞬間にカレンダーがポップオーバーとして開き、measureAnchor() が実測した入力欄の下端からちょうど ANCHOR_GAP ピクセル下に固定します。ポップオーバーの外側をクリックするか Escape を押すと再び閉じます。
04番の FADE_STEP .12 と FADE_FLOOR .15 — min 〜 max の範囲を外れた日付の不透明度がどれだけ急に落ちるかを決めます。recompute() は月内のすべての日を調べ、境界からの距離1日ごとに不透明度を .12 ずつ下げ、.15 の下限で止めます。
07番の THRESHOLD 140px — この方式の中で唯一、細部ではなくピッカー全体の形を変える値です。measureMode() がビューポートの実測幅を読み、140 を下回った瞬間、同じカレンダーが入力欄に固定されたポップオーバーから、別のボタンで上げ下げするシートに切り替わります。この閾値の設計こそ、日付選択 ui スマホ の使い勝手を左右する要になります。
この記事以外にも、カレンダー 選択 UI 9選 — コピペOK は別の軸で動きます。同じような月グリッドの上で、1つの値のために1つの入力欄が1つのポップオーバーを開くのではなく、2回のクリックで開始日と終了日の間を塗りつぶします。カレンダー css デザイン 9選 — 実際に月が動く は入力欄もポップオーバーも取り払い、カレンダーグリッド自体を目的地として扱います。この記事のグリッドは、その同じ月の計算を入力欄に紐づけて使った一形態にすぎません。タイムピッカー ui 9選 — 一点から区間まで はカレンダーを時計の文字盤と期間コントロールに置き換えるため、値は日や週ではなく時間や分の軸で動きます。この3つをこの記事と比べれば、CSSを1行も書く前に、ある 日付選択 ui がどの軸に属するのか最も早く見極められます。
どこで壊れるか — 狭い画面・範囲外入力・モーション削減
9つのうちどれも、入力欄をネイティブな <input type="date"> として作っていません。すべてがポップオーバーやシートを独自に操作するボタンかdivであり、それによってブラウザ標準のピッカーがどんな見た目になるかに任せるのではなく、カレンダーの開き方そのものを自分で決められます。
範囲の境界を日付が越える瞬間
04番の recompute() は min か max が変わったとき——このデモではグリッドのクリックか自動再生の tick() のときだけ——動きます。外部のフォーム欄から min/max を操作する実装につなぐ場合は、変更のたびに recompute() 自身を呼び直す必要があり、そうしないと範囲を新たに外れた日付が、次にたまたま再描画を起こすクリックまで元の不透明な見た目のまま残ります。
狭い画面
9つすべてがSCSSに @media (max-width: 340px) を持っていますが、07番だけはその幅よりずっと手前でレイアウトの形自体を変えられます。measureMode() の THRESHOLD 判定はビューポート幅140pxで popover と sheet のクラスを切り替え、これは他の8つが余白や文字サイズのために使う共通の340pxのルールとは無関係な、独自のブレークポイントです。この9つのソース全部は上のzipに入っていて、2s5u3jbfを入力すれば開きます。
モーション削減
9つすべてがJavaScript側で matchMedia('(prefers-reduced-motion: reduce)') を自動ループを始める前に確認し、すべてがCSS側にも @media (prefers-reduced-motion: reduce) を用意しています。09番はこの違いを並べて示すためだけに作られた項目です。rm__pop--normal は transform と opacity の両方をトランジションさせ、rm__pop--reduced は opacity だけをトランジションさせ、モーション削減用のメディアブロックは通常側もその同じopacityだけのトランジションまで削ります。そのため、モーション削減を有効にした利用者には比較の両側が同じ瞬時のフェードに見えます。
3つのバリエーションとアクセシビリティ
今日リングの減衰比を下げる(ZETA 4.2 から 2.0) — 減衰定数を4.2から2.0に下げると、リングは一度で収まらず二、三回余分に波打つように広がって薄くなります。
拒否振動の減衰比を下げる(SHAKE_ZETA 6.5 から 3.0) — 減衰定数を6.5から3.0に下げると、振動は一度で止まらず左右に数回余分に揺れ、拒否感がより強く感じられます。
チップ塗りつぶしの伝播を遅くする(STAGGER 28 から 70) — 遅延値を28から70に伸ばすと、背景が開始日から終了日までほぼ同時に染まらず、波のように一マスずつ遅れて塗られるのが見えます。
| バリエーション | 変わるもの | 変わらないもの |
|---|---|---|
今日リングの減衰比を下げる(ZETA 4.2→2.0) |
05番のリング拍動の回数だけ | DUR・FREQ・バッジ自身の拡大率 |
拒否振動の減衰比を下げる(SHAKE_ZETA 6.5→3.0) |
08番の振動回数だけ | BANNER_MS・SHAKE_AMP・SHAKE_FREQ |
チップ塗りつぶしの伝播を遅くする(STAGGER 28→70) |
06番のセルごとの塗り遅延だけ | PRESETS・アクティブチップのクラス処理 |
9つの中で非ネイティブのroleを使うのは01番と03番だけです。01番のパネルは role="dialog" に aria-label を持ち、aria-haspopup と aria-expanded を持つ入力欄から開きます。03番は role="grid" のdivの上に、常に1つのセルだけへフォーカスを渡すローミング tabIndex を組んでおり、矢印キーでの移動がネイティブなグリッドウィジェットの読み上げに近い挙動になります。01・02・03・04・05・06・08番は曜日見出し行に aria-hidden を設定しており(07番と09番にはこの見出し行自体がありません)、02番と03番はさらに aria-label でコントロールを説明します。9つのどれも disabled 属性を使いません。04番の範囲外の日付でさえフォーカス可能なまま不透明度だけが下がるため、範囲やモードの値によってキーボードのフォーカス順序が変わることはありません。
FAQ
なぜ日付を直接テキスト欄に入力せず「カレンダー 日付選択」で検索する人が多いのでしょうか
日付をテキストで打つと、地域やブラウザによって書式のずれや誤入力を招きやすく、この9つはどれもネイティブな <input type="date"> を使っていません。どの入力欄も単なるボタンでカレンダーを開くため、利用者がどんな書式に慣れていても選んだ値は常に一意になります。
ブラウザの幅を変えると、この9つのうちどれかは本当に下から上がるシートに変わりますか
変わるのは07番だけです。measureMode() は render() が呼ばれるたびにビューポートの実測幅を読み、THRESHOLD の140pxを下回った瞬間に ms__panel--popover から ms__panel--sheet に切り替わります。残りの8つはビューポートがどれだけ狭くなっても1つの見た目のままです。
05番の今日リングはCSSの @keyframes で動いていますか、それとも計算で作られていますか
計算で作られています。pulseAt() は指数減衰 Math.exp(-ZETA * x) にコサイン振動 Math.cos(FREQ * t) を掛け合わせて毎フレーム、リングの拡大率と不透明度、バッジ自身の小さな拍動を求めており、この動きのどの部分もCSSの @keyframes から来ていません。
Sources: MDN — Using CSS transitions · MDN — Window: requestAnimationFrame() method · MDN — prefers-reduced-motion · W3C ARIA APG — Date Picker Dialog Pattern









