날짜 선택 UI 9종 — 팝오버로 열고 접는 아홉 가지 방식
날짜 선택 UI는 입력 하나로 달력이 열리고 접히는 인터랙션인데, 아홉 개 중 하나는 입력칸의 초점 값이 켜지면 달력이 팝오버로 펼쳐지고, 하나는 허용 범위 밖 날짜의 흐림 값이 커져 선택할 수 없는 칸이 드러나며, 또 하나는 화면 너비 값이 좁아지면 팝오버가 아래에서 올라오는 시트로
아홉 항목 모두 로드 시점에 자동으로 도는 시연 루프는 값을 미리 보여주는 보조 장치일 뿐이고, 진짜 계산은 클릭·키보드·초점 같은 실사용자 입력이 일어나는 순간 다시 돌아갑니다. 달력을 그리는 여섯 항목(01·03·04·05·06·08)은 오늘 날짜를 상수 BASE(2026년 3월 14일)로 고정해 두고(02는 연도 상수만, 07·09는 정적 텍스트) 아홉 개 모두 Math.random 호출은 0번이라, 같은 순서로 입력하면 매번 같은 값이 나오는 계산입니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 입력 팝오버 전환 — 입력칸의 초점 값이 켜지면 달력이 팝오버로 펼쳐지고 꺼지면 접히는 방식
- 02 월년 단위 전환 — 표시 단위 값이 월에서 년으로 바뀌면 여러 해를 한 번에 건너뛸 수 있는 방식
- 03 키보드 이동 전환 — 방향 값이 바뀔 때마다 강조 칸 값이 격자 위를 이동하는 방식
- 04 범위 밖 흐림 전환 — 허용 범위 값을 벗어난 날짜의 흐림 값이 커져 고를 수 없는 상태로 바뀌는 방식
- 05 오늘 표시 전환 — 오늘 값을 가진 칸 둘레에서 링이 주기적으로 커지며 옅어지는 방식
- 06 빠른 선택 전환 — 칩 값을 짚으면 격자를 거치지 않고 날짜 값이 바로 정해지는 방식
- 07 시트 전환 — 화면 너비 값이 좁아지면 팝오버 값이 아래에서 올라오는 시트 값으로 바뀌는 방식
- 08 거부 흔들림 전환 — 막힌 값을 짚으면 그 칸의 위치 값이 짧게 좌우로 흔들리는 방식
- 09 모션 감소 대체 전환 — 모션 감소 값이 켜지면 팝오버·시트·흔들림 값이 즉시 값만 바뀌는 방식
아홉 개의 해부 — 어떤 값이 언제 바뀌는가
01은 필드 클릭과 문서 전체의 바깥 클릭·Escape 키로, 02는 헤더·이전·다음·연도칸 클릭과 좌우 화살표 키로, 03은 칸 초점 이동과 방향키·클릭으로, 04·06·08은 격자 클릭 하나로, 05는 배지 클릭과 Enter·Space 키로, 07은 필드·전환 버튼 클릭 두 개로, 09는 두 필드의 클릭으로 값을 다시 계산합니다. 날짜 선택 팝업 하나를 열고 닫는 계산이라도 아홉 항목이 붙잡는 값은 저마다 다릅니다. 다음 표에 항목별로 실제로 화면이 얼마나 넓게·세게·몇 프레임 동안 바뀌었는지를 실측해 정리했습니다(분모는 캡처 24프레임 중 실제로 값이 바뀐 23프레임입니다).
| 번호 | 이름 | 리스너 | 누적면적% | 강도 | 움직인프레임/23 |
|---|---|---|---|---|---|
| 01 | 입력 팝오버 전환 | click(필드)+바깥 click+keydown(Esc) | 25.68 | 84.3 | 8 |
| 02 | 월년 단위 전환 | click(헤더·이전·다음·연도칸)+keydown(←→) | 5.12 | 109.6 | 19 |
| 03 | 키보드 이동 전환 | focusin+keydown(방향키)+click | 2.13 | 111.3 | 8 |
| 04 | 범위 밖 흐림 전환 | click(격자) | 4.21 | 83.8 | 14 |
| 05 | 오늘 표시 전환 | click(배지)+keydown(Enter/Space) | 3.24 | 38.3 | 16 |
| 06 | 빠른 선택 전환 | click(칩) | 11.49 | 63.8 | 18 |
| 07 | 시트 전환 | click(필드)+click(전환 버튼) | 17.93 | 106.2 | 15 |
| 08 | 거부 흔들림 전환 | click(격자) | 3.38 | 39.3 | 16 |
| 09 | 모션 감소 대체 전환 | click(필드×2) | 3.15 | 76.6 | 15 |
09는 컴포넌트 한 칸짜리 비교 데모라 누적면적(3.15%)이 크지 않지만, 왼쪽·오른쪽 두 판을 같은 클릭 값 하나로 동시에 열고 닫으면서 서로 다른 CSS 값(transform 유무)만 갈아 끼운다는 점에서 09는 화면을 채우는 면적이 아니라 같은 입력에 두 계산 결과를 나란히 세우는 쪽으로 값을 보여 줍니다.
웹 날짜 선택 ui 화면에서 실측 좌표로 목표 값을 정하는 값 — 01·03·07
01의 measureAnchor()는 필드의 실제 렌더 좌표·폭을 getBoundingClientRect()로 재 ANCHOR_GAP(8px)만큼 아래에 팝오버를 앵커하고, 03의 moveRingTo()는 강조 칸의 좌표를 실측해 링을 그 자리로 옮기며, 07의 measureMode()는 뷰포트 요소의 실측 폭을 THRESHOLD(140px)와 비교해 팝오버·시트 형태를 고릅니다. 셋 다 짐작한 고정 픽셀이 아니라 지금 화면에 실제로 놓인 좌표·크기를 기준으로 목표 값을 정합니다.
매 프레임 지수 감쇠를 계산해 값을 그리는 값 — 05·08
05의 pulseAt()과 08의 shakeAt()은 둘 다 requestAnimationFrame 루프 안에서 지수 감쇠(Math.exp(-ZETA*x)) x 코사인 진동을 매 프레임 계산해 인라인 스타일로 그립니다. 05는 ZETA(4.2)로 링 스케일·투명도와 배지 스케일을, 08은 SHAKE_ZETA(6.5)로 칸의 가로 위치 값을 흔듭니다. 나머지 일곱 개(01·02·03·04·06·07·09)는 클래스를 토글한 뒤 SCSS transition 값이 움직임을 대신 그립니다.
고정 배열의 인덱스를 옮겨 다음 값을 정하는 값 — 02·06
02는 AUTO_SEQ([3, 7, 11, 3])라는 자동재생용 목표 월 배열의 인덱스를 순환시키고, 06은 PRESETS(이번 주·다음 7일·이번 달) 배열의 인덱스를 옮겨 다음 값을 고릅니다. 둘 다 매 프레임 계산 대신 배열에서 다음 값을 꺼내는 방식이라, 화면에 보이는 전환 자체는 CSS transition이 담당합니다.
경계로부터 거리만큼 값을 깎는 값 — 04
04는 min·max 범위 밖 날짜마다 경계로부터 며칠 떨어져 있는지(distOut)를 세어 FADE_STEP(.12)씩 불투명도를 깎고, 아무리 멀어도 FADE_FLOOR(.15) 아래로는 내려가지 않게 막습니다. 05·08의 시간 기반 감쇠와 달리, 04는 시간이 아니라 날짜 칸 사이의 거리를 감쇠 변수로 씁니다.
패턴별 코드
01입력 팝오버 전환 — 입력칸의 초점 값이 켜지면 달력이 팝오버로 펼쳐지고 꺼지면 접히는 방식
measureAnchor()가 필드의 실제 렌더 좌표를 실측해 ANCHOR_GAP(8px)만큼 아래에 팝오버를 앵커하고, setOpen()이 열림 여부에 따라 그 실측을 다시 돌립니다. 체크인 날짜 입력칸, 필터 패널의 기간 입력에 쓰는 계산입니다.
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();
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02월년 단위 전환 — 표시 단위 값이 월에서 년으로 바뀌면 여러 해를 한 번에 건너뛸 수 있는 방식
가운데 헤더 버튼을 짚을 때마다 mode 값이 month·year 사이를 오가고, slideTo()는 뷰포트 실측 폭을 슬라이드 거리로 써서 목표-현재 오프셋 부호만큼 방향을 정합니다. 10년 단위 묶음 없이 월과 년 두 단계만 오가는 계산입니다.
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)';
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03키보드 이동 전환 — 방향 값이 바뀔 때마다 강조 칸 값이 격자 위를 이동하는 방식
방향키 하나마다 delta 값을 ±1(좌우)·±7(상하)로 정해 focusDay()에 넘기고, 격자 끝에 닿으면 1일·말일에서 값이 멈춥니다. 키보드 위주 입력 폼, 접근성 우선 대시보드에 쓰는 계산입니다.
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);
});

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
04범위 밖 흐림 전환 — 허용 범위 값을 벗어난 날짜의 흐림 값이 커져 고를 수 없는 상태로 바뀌는 방식
recompute()가 클릭이 일어날 때마다 42칸 전부를 다시 훑어, 경계로부터의 거리(distOut)만큼 FADE_STEP(.12)씩 불투명도를 깎습니다. 예약 가능 기간 제한, 유효기간이 있는 쿠폰 사용일 선택에 쓰는 계산입니다.
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);
}
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05오늘 표시 전환 — 오늘 값을 가진 칸 둘레에서 링이 주기적으로 커지며 옅어지는 방식
pulseAt()이 지수 감쇠(ZETA 4.2) x 코사인 진동을 계산해 링 스케일·투명도와 배지 스케일 값을 함께 만들어, 밝기가 아니라 크기 값으로 오늘 칸을 드러냅니다. 출석 체크 달력, 마감일이 가까운 일정 확인에 쓰는 계산입니다.
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 };
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06빠른 선택 전환 — 칩 값을 짚으면 격자를 거치지 않고 날짜 값이 바로 정해지는 방식
applyPreset()이 선택 범위 시작으로부터의 위치(pos)만큼 STAGGER(28ms)를 곱해 전환 지연을 매기고, 그 칩의 배경 값만 채워 선택 상태를 나타냅니다. 리포트 기간 빠른 선택, 검색 필터의 최근 기간에 쓰는 계산입니다.
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); });
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07시트 전환 — 화면 너비 값이 좁아지면 팝오버 값이 아래에서 올라오는 시트 값으로 바뀌는 방식
measureMode()가 뷰포트 요소의 실측 폭을 THRESHOLD(140px)와 비교해 팝오버·시트 중 어느 값을 그릴지 정하고, render()가 그 값으로 클래스를 다시 매깁니다. 모바일 예약 앱의 날짜 선택, 좁은 화면의 필터 패널에 쓰는 계산입니다.
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');
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08거부 흔들림 전환 — 막힌 값을 짚으면 그 칸의 위치 값이 짧게 좌우로 흔들리는 방식
shakeAt()이 SHAKE_ZETA(6.5) 지수 감쇠를 코사인 진동에 곱해 픽셀 오프셋 값을 만들고, loop()가 매 프레임 그 값을 칸의 transform에 그대로 얹습니다. 이미 예약된 날짜 재선택 방지, 마감된 접수일 안내에 쓰는 계산입니다.
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);
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09모션 감소 대체 전환 — 모션 감소 값이 켜지면 팝오버·시트·흔들림 값이 즉시 값만 바뀌는 방식
같은 열림 값 하나로 왼쪽(일반)은 transform(scale)+opacity 값을, 오른쪽(감소된 모션)은 opacity 값만 바꿔 나란히 비교합니다. 모션 최소화 설정을 켠 사용자, 저사양 기기의 폼 화면에 쓰는 계산입니다.
.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; }

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
값이 팝오버를 여닫는 곳 — 초점 값·범위 값·너비 기준
여닫힘 기준은 01과 07이 서로 다르게 잡습니다. 01은 필드 버튼을 짚을 때마다 dp--open 클래스 값이 뒤집히며 팝오버가 열리고, 문서 전체에 건 리스너가 dp 바깥을 짚은 순간과 Escape 키 입력을 같은 human() 함수로 몰아 닫습니다. 07은 열림 자체는 01과 같은 클릭 값이지만, 그 위에 THRESHOLD(140px)라는 너비 기준 값 하나를 더 얹어 같은 열림 상태에서도 팝오버 모양이 될지 시트 모양이 될지를 매번 다시 잽니다. 07 같은 너비 기준 전환은 모바일 날짜 선택 ui 화면에서 같은 달력 마크업을 재사용하면서 좁은 화면에만 시트 형태를 얹고 싶을 때 유용합니다.
범위 값을 여닫는 기준은 04입니다. min·max 두 값을 클릭 두 번으로 정하고 나면 recompute()가 매번 42칸 전부를 다시 훑어 경계로부터 거리(distOut)만큼 FADE_STEP(.12)씩 불투명도를 깎고 FADE_FLOOR(.15) 아래로는 내려가지 않게 막습니다. 01의 여닫힘 값이 켜짐·꺼짐 두 상태만 오가는 것과 달리, 04의 범위 값은 min·max 두 숫자가 같이 움직이며 칸마다 다른 불투명도 값을 만들어냅니다.
어디서 깨지는가 — 좁은 화면·범위 밖 입력·모션 감소
좁은 화면(340px 이하)에서는 09의 두 칸 비교 레이아웃이 .rm__mini 폭을 108px에서 84px로 줄이고 글자 크기도 함께 낮춥니다. 07은 반대로 화면 전체 폭이 아니라 msViewport 자체의 실측 폭이 THRESHOLD(140px) 밑으로 내려가는 순간에만 반응해, 부모 레이아웃이 아무리 좁아져도 msViewport 자체 폭이 140px 이상이면 여전히 팝오버 형태로 남습니다.
범위 밖 입력은 04가 담당합니다. min·max를 벗어난 날짜를 클릭해도 recompute()가 매 클릭마다 42칸을 다시 훑어 값이 어긋나는 순간이 없습니다. 08은 범위가 아니라 주말이라는 고정 조건(isWeekend)으로 막힌 값을 가려내고, 막힌 칸을 짚으면 shakeAt()이 SHAKE_ZETA(6.5) 감쇠 계산으로 흔들림 값을 만들며 BANNER_MS(900ms) 동안 배너를 띄웁니다.
모션을 줄이는 처리는 아홉 항목 모두 자바스크립트에서 window.matchMedia('(prefers-reduced-motion: reduce)')로 판정하고, prefers-reduced-motion 미디어 쿼리로 SCSS 쪽에서도 같은 판정을 한 번 더 겁니다. 끊어내는 지점은 항목마다 갈립니다: 01·02·03·04·06·07은 reduce 값이 켜지면 해당 요소의 transition만 none으로 끊어 클래스가 바뀌는 순간 즉시 최종 모습으로 넘어가고, 05는 tb__ring 자체를 display: none으로 아예 지우며, 08은 kd__cell의 transform을 !important로 강제 해제하고 자동재생 자체도 시작하지 않아 흔들림 루프가 돌 일이 없습니다. 09는 반대로 미디어 쿼리가 왼쪽 칸(.rm__pop--normal)의 transform만 지워 오른쪽(.rm__pop--reduced)과 같은 opacity 전용 모양으로 맞춥니다.
아홉 개 패턴의 html·scss·js 소스 전부가 비번 2s5u3jbf 으로 열리는 zip 안에 들어 있어, 위에서 짚은 04의 매번 재계산·08의 감쇠 공식을 직접 코드에서 확인하고 바꿔볼 수 있습니다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 오늘 링 감쇠비 낮추기 | 05 ZETA 4.2 → 2.0 | 감쇠 상수를 4.2 에서 2.0 으로 낮추면 링이 한 번에 잦아들지 않고 두세 번 더 파동치듯 커졌다 옅어지길 반복한다 |
| 거부 흔들림 감쇠비 낮추기 | 08 SHAKE_ZETA 6.5 → 3.0 | 감쇠 상수를 6.5 에서 3.0 으로 낮추면 흔들림이 한 번에 멈추지 않고 좌우로 몇 차례 더 오가며 거부감이 더 세게 느껴진다 |
| 칩 채움 전파 늦추기 | 06 STAGGER 28 → 70 | 지연값을 28 에서 70 로 늘리면 배경이 시작일부터 끝일까지 거의 동시에 물들지 않고 파도처럼 한 칸씩 뒤따라 채워지는 게 눈에 보인다 |
접근성은 01과 03이 가장 두텁습니다. 01은 필드 버튼에 aria-haspopup="dialog"·aria-expanded를, 팝오버에 role="dialog"·aria-label="달력"·aria-hidden을 걸어 열림 여부를 스크린리더에도 알립니다. 03은 격자에 role="grid"·aria-label="달력"을 걸고, 초점이 있는 칸 하나만 tabIndex 0을 갖고 나머지는 -1로 감춰 두는 로빙 tabindex로 Tab 키 한 번에 격자 전체가 아니라 지금 칸 하나만 걸리게 합니다. 02는 반대로 월·년 전환 버튼에 aria-expanded나 aria-pressed가 없어, 지금이 월 목록인지 년 목록인지 텍스트가 바뀌는 것 말고는 스크린리더가 알 방법이 없습니다. 04·05·06·08은 요일 머리글에 aria-hidden을 걸어 장식용 텍스트를 읽지 않게 뺀 것 외에 별도 상태 속성이 없고, 07·09는 요일 머리글 자체가 없어 아리아 속성이 하나도 없이 버튼 요소 자체의 기본 포커스·클릭 동작에만 기댑니다.
FAQ
01 같은 팝오버 달력을 실제 예약 폼에 옮겨 붙이면 서버의 예약 가능일과 자동으로 맞물리나요?
아니요. 01의 measureAnchor()·setOpen()은 화면에서 팝오버를 열고 닫는 위치 계산일 뿐이고, 04의 범위 흐림이나 08의 주말 차단처럼 어떤 날짜를 고를 수 없게 할지는 서버가 내려주는 값을 따로 붙여야 합니다.
03의 방향키 이동과 08의 흔들림 거부는 왜 계산 방식이 완전히 다른가요?
03은 focusDay()가 delta 값(±1·±7)만큼 초점을 옮기고 로빙 tabindex로 DOM 초점 자체를 관리하는 이산적인 계산이고, 08은 shakeAt()이 매 프레임 지수 감쇠 x 코사인 값을 연속적으로 계산해 픽셀 오프셋을 만듭니다. 하나는 어느 칸이 초점인지 상태를 옮기는 계산이고, 다른 하나는 시간에 따라 변하는 물리량을 그리는 계산이라 방식 자체가 다릅니다.
웹 날짜 선택 ui 를 만들 때 09처럼 두 모션 버전을 한 화면에 같이 두어야 하나요?
아니요. 09는 일반·감소된 모션 두 값을 나란히 비교해 보여 주는 학습용 데모라 둘을 동시에 그립니다. 실제 화면에서는 window.matchMedia('(prefers-reduced-motion: reduce)') 판정 하나로 갈라, 사용자 설정에 맞는 한쪽 값만 그리면 됩니다.
더 읽을거리
달력 선택 UI 9종 — 복붙하면 되는은 값 선택 UI를 폭넓게 모은 피커 모음을 다루는 데 비해, 이 편은 날짜 선택 값 하나의 열림 위치·이동·범위·유효성 계산만 깊게 판다는 점에서 계산 축이 다릅니다.
css 달력 만들기 9종 — 진짜 넘어가는 월간 캘린더는 월 전체를 보여 주는 달력 격자 레이아웃 자체를 다루는 반면, 이 편의 02·03은 그 격자 위에서 초점·표시 단위 값이 어떻게 움직이는지를 판다는 점에서 레이아웃 축이 아니라 값 이동 축입니다.
시간 선택 ui 9종 — 시각 하나가 아니라 구간까지는 시각·구간이라는 다른 축의 값을 다루는데, 이 편은 같은 팝오버·범위 계산이라도 날짜 값을 기준으로 열림·이동·유효성을 판단한다는 점에서 값의 종류 자체가 다릅니다.









