08拒否振動切替 — ブロックされた値に触れるとそのマスの位置の値が短く左右に揺れる方式
ブロックされた値に触れるとそのマスの位置の値が左右に短く揺れて止まり、案内バナーの不透明度の値が一瞬上がってから再び下がります。
使いどころ既に予約済みの日付の再選択防止、締め切られた受付日の案内
shakeAt()SHAKE_ZETABANNER_MS

<main class="stage">
<div class="kd is-demo" id="kd">
<div class="kd__head"><span class="kd__label">2026年3月</span></div>
<div class="kd__weekday" aria-hidden="true">
<span>日</span><span>月</span><span>火</span><span>水</span><span>木</span><span>金</span><span>土</span>
</div>
<div class="kd__grid" id="kdGrid"></div>
<div class="kd__banner" id="kdBanner">週末は選択できません</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 잘못된 날짜를 고르면 loop() 이 지수 감쇠 x 코사인 진동으로
// 그 셀의 translateX 를 흔들고(damped shake), 동시에 거부 배너를 띄운다.
@include stage($stage-paper);
.kd { width: 100%; max-width: 230px; background: $subject-cream; border-radius: $r-card; padding: $sp-2; box-shadow: $shadow-raised; box-sizing: border-box; }
.kd__head { text-align: center; margin-bottom: $sp-1; }
.kd__label { font-size: 11px; font-weight: 700; color: $subject-ink; }
.kd__weekday, .kd__grid { display: grid; grid-template-columns: repeat(7, 1fr); }
.kd__weekday span { font-size: 9px; text-align: center; color: rgba($subject-ink, .72); padding-bottom: $sp-1; }
.kd__cell {
display: grid; place-items: center; height: 19px; font-size: 10px; color: $subject-ink;
border-radius: $r-xs; border: none; background: transparent; cursor: pointer; will-change: transform;
}
.kd__cell--out { visibility: hidden; }
.kd__cell--invalid { color: rgba($subject-ink, .38); cursor: not-allowed; }
.kd__cell--selected { background: $subject-blue-deep; color: $subject-cream; font-weight: 700; }
.kd__banner {
margin-top: $sp-1;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: $error-soft;
color: $error;
font-size: 9px;
font-weight: 700;
text-align: center;
opacity: 0;
transition: opacity $dur-quick $ease-out;
}
.kd__banner--show { opacity: 1; }
@media (max-width: 340px) {
.kd { max-width: 190px; }
.kd__cell { height: 16px; font-size: 9px; }
.kd__weekday span { font-size: 8px; padding-bottom: 0; }
.kd__banner { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.kd__cell { transform: none !important; }
.kd__banner { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.kd {
width: 100%;
max-width: 230px;
background: #fff7e6;
border-radius: 16px;
padding: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.kd__head {
text-align: center;
margin-bottom: 4px;
}
.kd__label {
font-size: 11px;
font-weight: 700;
color: #17141a;
}
.kd__weekday, .kd__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.kd__weekday span {
font-size: 9px;
text-align: center;
color: rgba(23, 20, 26, 0.72);
padding-bottom: 4px;
}
.kd__cell {
display: grid;
place-items: center;
height: 19px;
font-size: 10px;
color: #17141a;
border-radius: 4px;
border: none;
background: transparent;
cursor: pointer;
will-change: transform;
}
.kd__cell--out {
visibility: hidden;
}
.kd__cell--invalid {
color: rgba(23, 20, 26, 0.38);
cursor: not-allowed;
}
.kd__cell--selected {
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
}
.kd__banner {
margin-top: 4px;
padding: 4px 8px;
border-radius: 4px;
background: #ffe0d8;
color: #c2361a;
font-size: 9px;
font-weight: 700;
text-align: center;
opacity: 0;
transition: opacity 220ms ease-out;
}
.kd__banner--show {
opacity: 1;
}
@media (max-width: 340px) {
.kd {
max-width: 190px;
}
.kd__cell {
height: 16px;
font-size: 9px;
}
.kd__weekday span {
font-size: 8px;
padding-bottom: 0;
}
.kd__banner {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.kd__cell {
transform: none !important;
}
.kd__banner {
transition: none;
}
}(function () {
var BASE = { y: 2026, m: 3, d: 14 }; // 계산 겹 상수: 고정 오늘, new Date() 미사용
var CELL_TOTAL = 42;
var SHAKE_DUR = 420; // ms, 계산 겹: 감쇠 진동이 잦아드는 데 걸리는 시간
var SHAKE_FREQ = 2 * Math.PI * 3.4 / SHAKE_DUR; // 계산 겹: 흔들림 각속도
var SHAKE_ZETA = 6.5; // 계산 겹: 감쇠 상수
var SHAKE_AMP = 8; // px, 흔들림 최대 진폭
var BANNER_MS = 900; // ms, 거부 배너가 떠 있는 시간
function isLeap(y) { return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; }
function daysInMonth(y, m) {
var t = [31, isLeap(y) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return t[m - 1];
}
function dow(y, m, d) { // Zeller 합동식 — 0=日 ~ 6=土
var yy = y, mm = m;
if (mm < 3) { yy -= 1; mm += 12; }
var K = yy % 100, J = Math.floor(yy / 100);
var h = (d + Math.floor(13 * (mm + 1) / 5) + K + Math.floor(K / 4) + Math.floor(J / 4) - 2 * J) % 7;
h = ((h % 7) + 7) % 7;
return (h + 6) % 7;
}
function isWeekend(y, m, d) {
var w = dow(y, m, d);
return w === 0 || w === 6;
}
var kd = document.getElementById('kd');
var grid = document.getElementById('kdGrid');
var banner = document.getElementById('kdBanner');
var n = daysInMonth(BASE.y, BASE.m);
var lead = dow(BASE.y, BASE.m, 1);
var trail = CELL_TOTAL - lead - n;
var cellByDay = {};
var invalidDays = [];
var i, cell;
for (i = 0; i < lead; i++) {
cell = document.createElement('span');
cell.className = 'kd__cell kd__cell--out';
grid.appendChild(cell);
}
for (var d = 1; d <= n; d++) {
var invalid = isWeekend(BASE.y, BASE.m, d);
cell = document.createElement('button');
cell.type = 'button';
cell.className = 'kd__cell' + (invalid ? ' kd__cell--invalid' : '');
cell.textContent = String(d);
cell.dataset.day = String(d);
if (invalid) { cell.dataset.invalid = '1'; invalidDays.push(d); }
grid.appendChild(cell);
cellByDay[d] = cell;
}
for (i = 0; i < trail; i++) {
cell = document.createElement('span');
cell.className = 'kd__cell kd__cell--out';
grid.appendChild(cell);
}
var selected = null;
var shakeTarget = null;
var shakeStart = 0;
var bannerTimer = null;
var rafId = null;
function shakeAt(t) {
// 계산 겹: 지수 감쇠 x 코사인 진동을 픽셀 오프셋으로 바꾼다
if (t < 0 || t > SHAKE_DUR) return 0;
var x = t / SHAKE_DUR;
var amp = Math.exp(-SHAKE_ZETA * x);
return SHAKE_AMP * amp * Math.cos(SHAKE_FREQ * t);
}
function loop(ts) {
if (shakeTarget) {
var elapsed = ts - shakeStart;
if (elapsed > SHAKE_DUR) {
shakeTarget.style.transform = '';
shakeTarget = null;
} else {
shakeTarget.style.transform = 'translateX(' + shakeAt(elapsed).toFixed(2) + 'px)';
}
}
rafId = requestAnimationFrame(loop);
}
function showBanner() {
banner.classList.add('kd__banner--show');
if (bannerTimer) clearTimeout(bannerTimer);
bannerTimer = setTimeout(function () { banner.classList.remove('kd__banner--show'); }, BANNER_MS);
}
function reject(day, ts) {
shakeTarget = cellByDay[day];
shakeStart = ts;
showBanner();
}
function selectDay(day) {
if (selected) cellByDay[selected].classList.remove('kd__cell--selected');
selected = day;
cellByDay[day].classList.add('kd__cell--selected');
}
function human() {
kd.classList.remove('is-demo');
stopAutoplay();
}
grid.addEventListener('click', function (e) {
var cellEl = e.target.closest('.kd__cell');
if (!cellEl || !cellEl.dataset.day) return;
human();
var day = parseInt(cellEl.dataset.day, 10);
if (cellEl.dataset.invalid) {
reject(day, performance.now());
} else {
selectDay(day);
}
});
var seqIdx = -1;
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
function tick(ts) {
seqIdx = (seqIdx + 1) % invalidDays.length;
reject(invalidDays[seqIdx], ts);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
rafId = requestAnimationFrame(loop);
autoTimer = setInterval(function () { tick(performance.now()); }, 480);
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './DatePickerInvalidShake.module.scss';
type Box<T> = { current: T };
const BASE = { y: 2026, m: 3, d: 14 }; // 계산 겹 상수: 고정 오늘, new Date() 미사용
const CELL_TOTAL = 42;
const SHAKE_DUR = 420; // ms, 계산 겹: 감쇠 진동이 잦아드는 데 걸리는 시간
const SHAKE_FREQ = (2 * Math.PI * 3.4) / SHAKE_DUR; // 계산 겹: 흔들림 각속도
const SHAKE_ZETA = 6.5; // 계산 겹: 감쇠 상수
const SHAKE_AMP = 8; // px, 흔들림 최대 진폭
const BANNER_MS = 900; // ms, 거부 배너가 떠 있는 시간
const WEEKDAY = ['일', '월', '화', '수', '목', '금', '토'];
function isLeap(y: number) {
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
}
function daysInMonth(y: number, m: number) {
const t = [31, isLeap(y) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return t[m - 1];
}
function dow(y: number, m: number, d: number) { // Zeller 합동식 — 0=일 ~ 6=토
let yy = y;
let mm = m;
if (mm < 3) { yy -= 1; mm += 12; }
const K = yy % 100;
const J = Math.floor(yy / 100);
let h = (d + Math.floor((13 * (mm + 1)) / 5) + K + Math.floor(K / 4) + Math.floor(J / 4) - 2 * J) % 7;
h = ((h % 7) + 7) % 7;
return (h + 6) % 7;
}
function isWeekend(y: number, m: number, d: number) {
const w = dow(y, m, d);
return w === 0 || w === 6;
}
const N_DAYS = daysInMonth(BASE.y, BASE.m);
const LEAD = dow(BASE.y, BASE.m, 1);
const TRAIL = CELL_TOTAL - LEAD - N_DAYS;
const INVALID_DAYS: number[] = [];
for (let d = 1; d <= N_DAYS; d++) if (isWeekend(BASE.y, BASE.m, d)) INVALID_DAYS.push(d);
function shakeAt(t: number) {
// 계산 겹: 지수 감쇠 x 코사인 진동을 픽셀 오프셋으로 바꾼다
if (t < 0 || t > SHAKE_DUR) return 0;
const x = t / SHAKE_DUR;
const amp = Math.exp(-SHAKE_ZETA * x);
return SHAKE_AMP * amp * Math.cos(SHAKE_FREQ * t);
}
export default function DatePickerInvalidShake() {
const cellRefs = useRef({}) as unknown as Box<Record<number, HTMLButtonElement>>;
const shakeTargetRef = useRef(0) as unknown as Box<number>;
const shakeStartRef = useRef(0) as unknown as Box<number>;
const bannerTimerRef = useRef(0) as unknown as Box<number>;
const [isDemo, setIsDemo] = useState(true);
const [selected, setSelected] = useState<number | null>(null);
const [bannerShow, setBannerShow] = useState(false);
function human() {
setIsDemo(false);
}
function showBanner() {
setBannerShow(true);
window.clearTimeout(bannerTimerRef.current);
bannerTimerRef.current = window.setTimeout(() => setBannerShow(false), BANNER_MS);
}
function reject(day: number, ts: number) {
shakeTargetRef.current = day;
shakeStartRef.current = ts;
showBanner();
}
function onCellClick(day: number, invalid: boolean) {
human();
if (invalid) {
reject(day, performance.now());
} else {
setSelected(day);
}
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return;
let rafId = 0;
function loop(ts: number) {
const day = shakeTargetRef.current;
if (day) {
const elapsed = ts - shakeStartRef.current;
const el = cellRefs.current[day];
if (el) {
if (elapsed > SHAKE_DUR) {
el.style.transform = '';
shakeTargetRef.current = 0;
} else {
el.style.transform = 'translateX(' + shakeAt(elapsed).toFixed(2) + 'px)';
}
}
}
rafId = requestAnimationFrame(loop);
}
rafId = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rafId);
}, []);
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return;
let seqIdx = -1;
const timer = window.setInterval(() => {
if (!isDemo) return;
seqIdx = (seqIdx + 1) % INVALID_DAYS.length;
reject(INVALID_DAYS[seqIdx], performance.now());
}, 480);
return () => window.clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDemo]);
const lead = Array.from({ length: LEAD }, (_, i) => <span key={'l' + i} className={`${styles.kdCell} ${styles.kdCellOut}`} />);
const days = Array.from({ length: N_DAYS }, (_, i) => {
const d = i + 1;
const invalid = isWeekend(BASE.y, BASE.m, d);
return (
<button
type="button"
key={'d' + d}
className={`${styles.kdCell} ${invalid ? styles.kdCellInvalid : ''} ${selected === d ? styles.kdCellSelected : ''}`}
ref={(el: HTMLButtonElement | null) => { if (el) cellRefs.current[d] = el; }}
onClick={() => onCellClick(d, invalid)}
>
{d}
</button>
);
});
const trail = Array.from({ length: TRAIL }, (_, i) => <span key={'t' + i} className={`${styles.kdCell} ${styles.kdCellOut}`} />);
return (
<div className={`${styles.kd} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.kdHead}><span className={styles.kdLabel}>2026년 3월</span></div>
<div className={styles.kdWeekday} aria-hidden="true">
{WEEKDAY.map((w) => <span key={w}>{w}</span>)}
</div>
<div className={styles.kdGrid}>{lead}{days}{trail}</div>
<div className={`${styles.kdBanner} ${bannerShow ? styles.kdBannerShow : ''}`}>주말은 선택할 수 없어요</div>
</div>
);
}08 拒否振動切替 — ブロックされた値に触れるとそのマスの位置の値が短く左右に揺れる方式 — 計測 累積面積 3.376% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · css 日付選択 9選 — ポップオーバーで開閉する9つの方式
このデモが属する記事
css 日付選択 9選 — ポップオーバーで開閉する9つの方式css 日付選択:9つのうち1つは入力欄の焦点値がオンになるとカレンダーがポップオーバーとして開き、1つは許可範囲外の日付の霞み値が大きくなって選べない状態が現れ、もう1つは画面幅の値が狭くなるとポップオーバーが下から上がるシートに変わりま この記事のデモをすべて見る


