08Denial-shake value toggle — engaging a blocked value makes that cell's position value shake briefly side to side
Engaging a blocked value makes that cell's position value shake side to side briefly, while a notice banner's opacity value rises for a moment before fading back down.
Best forPreventing reselection of an already-booked date, flagging a closed submission day
shakeAt()SHAKE_ZETABANNER_MS

<main class="stage">
<div class="kd is-demo" id="kd">
<div class="kd__head"><span class="kd__label">March 2026</span></div>
<div class="kd__weekday" aria-hidden="true">
<span>Sun</span><span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span><span>Sat</span>
</div>
<div class="kd__grid" id="kdGrid"></div>
<div class="kd__banner" id="kdBanner">Weekends can't be selected</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=Sun ~ 6=Sat
var yy = y, mm = m;
if (mm < 3) { yy -= 1; mm += 12; }
var K = yy % 100, J = Math.floor(yy / 100);
var h = (d + Math.floor(13 * (mm + 1) / 5) + K + Math.floor(K / 4) + Math.floor(J / 4) - 2 * J) % 7;
h = ((h % 7) + 7) % 7;
return (h + 6) % 7;
}
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 Denial-shake value toggle — engaging a blocked value makes that cell's position value shake briefly side to side — Measured area 3.376% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · Date Picker UI: 9 Popover & Calendar Patterns
The post this demo lives in
Date Picker UI: 9 Popover & Calendar PatternsDate picker UI: One of nine opens a calendar popover when a focus value turns on, one fades out-of-range dates to unsele See all demos in this post
If you like this effect - similar demos
Pickers06 Picker That Fills Waveform Bars Up to the Scrubbed Position9 Graph Animation CSS Pickers With Live Recompute
Pickers02 Hue wheel that reads the angle you pressCustom Color Picker: 9 JS Parts, No Library
Pickers01 Value bubble — converting track position to a percentage the label followsCSS Range Slider: 9 Physics-Grade Custom Sliders