09비활성 파동 차단 — 비활성 값이 켜진 버튼을 누르면 파동 확산 값이 중간에서 멈추고 흔들림 값으로 대신 반응하는 방식
비활성 값이 켜진 버튼을 눌러도 파동 확산 값이 절반쯤에서 멈추고, 버튼 전체의 흔들림 값이 짧게 반응합니다.
쓰임권한 없는 액션 버튼, 제출 완료 뒤 잠긴 버튼
SHAKE_FREQ 26 / DECAY 6.5sin decay oscillatorHALT_RATIO .5 ripple

<main class="stage">
<div class="drb is-demo" id="drb" role="group" aria-label="일괄 작업"></div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹 1: 리플이 버튼 대각선의 절반(HALT_RATIO)까지만 실측 확산되다 멈춘다(막힘의 시각화).
// 계산 겹 2: 흔들림은 캔버스 키프레임이 아니라 rAF 로 sin(진동) × exp(감쇠) 를 매 프레임 적분한다.
@include stage($stage-orange);
.drb {
display: flex;
flex-wrap: wrap;
gap: $sp-2;
max-width: 100%;
}
.drb__btn {
position: relative;
overflow: hidden;
appearance: none;
border: none;
border-radius: $r-sm;
background: $subject-cream;
color: $subject-ink;
font-size: 12px;
font-weight: 700;
padding: $sp-2 $sp-4;
cursor: not-allowed;
white-space: nowrap;
flex: 0 0 auto;
will-change: transform;
&[aria-disabled="true"] { opacity: .5; }
&:not([aria-disabled="true"]) { cursor: pointer; opacity: 1; }
}
.drb__ripple {
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 8px;
border-radius: 50%;
background: $subject-ink;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
@media (max-width: 340px) {
.drb__btn { font-size: 11px; padding: $sp-1 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.drb__btn { 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);
}
.drb {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-width: 100%;
}
.drb__btn {
position: relative;
overflow: hidden;
appearance: none;
border: none;
border-radius: 8px;
background: #fff7e6;
color: #17141a;
font-size: 12px;
font-weight: 700;
padding: 8px 16px;
cursor: not-allowed;
white-space: nowrap;
flex: 0 0 auto;
will-change: transform;
}
.drb__btn[aria-disabled=true] {
opacity: 0.5;
}
.drb__btn:not([aria-disabled=true]) {
cursor: pointer;
opacity: 1;
}
.drb__ripple {
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 8px;
border-radius: 50%;
background: #17141a;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
@media (max-width: 340px) {
.drb__btn {
font-size: 11px;
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.drb__btn {
transition: none;
}
}(function () {
var ITEMS = [
{ label: '삭제', disabled: true },
{ label: '승인', disabled: false },
{ label: '잠금 해제', disabled: true },
{ label: '내보내기', disabled: false }
];
var CYCLE_MS = 650; // 데모 자동 시도 간격
var HALT_RATIO = 0.5; // 계산 겹 상수: 리플이 최대 반경의 이 비율까지만 번지고 멈춘다
var SHAKE_MS = 420;
var SHAKE_FREQ = 26; // rad/s 대역 — 데모용 가정값
var SHAKE_DECAY = 6.5;
var SHAKE_AMPLITUDE = 6; // px, 데모용 가정값
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('drb');
var isDemo = true;
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
var cells = ITEMS.map(function (item) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'drb__btn';
btn.textContent = item.label;
if (item.disabled) btn.setAttribute('aria-disabled', 'true');
var ripple = document.createElement('span');
ripple.className = 'drb__ripple';
btn.appendChild(ripple);
root.appendChild(btn);
return { btn: btn, ripple: ripple, disabled: item.disabled, raf: 0 };
});
// 계산 겹 1: 리플이 버튼 대각선의 절반(HALT_RATIO)까지만 실측 확산되다 멈춘다(막힘의 시각화).
// 계산 겹 2: 흔들림은 캔버스 키프레임이 아니라 rAF 로 sin(진동) × exp(감쇠) 를 매 프레임 적분한다.
function attempt(cell) {
if (!cell.disabled) return;
var r = cell.btn.getBoundingClientRect();
var maxR = Math.hypot(r.width, r.height) / 2;
var haltR = maxR * HALT_RATIO;
if (reduce) {
cell.ripple.style.transform = 'translate(-50%,-50%) scale(' + (haltR / 4) + ')';
cell.ripple.style.opacity = '0.001';
cell.btn.style.transform = 'translate(0,0)';
return;
}
if (cell.raf) cancelAnimationFrame(cell.raf);
var t0 = 0;
var dur = Math.max(260, SHAKE_MS);
function step(ts) {
if (!t0) t0 = ts;
var t = (ts - t0) / 1000;
var tt = Math.min(1, (ts - t0) / dur);
// 리플: 0 → haltR 까지 감속 확산 후 멈춤(그 이상 안 커짐), 페이드는 확산과 별도로 진행
var spread = haltR * (1 - Math.pow(1 - Math.min(1, tt * 1.6), 2));
cell.ripple.style.transform = 'translate(-50%,-50%) scale(' + Math.max(0.001, spread / 4) + ')';
cell.ripple.style.opacity = String(Math.max(0, 1 - tt));
// 흔들림: 감쇠 진동자 — 캔버스 키프레임이 아니라 매 프레임 계산
var shakeT = Math.min(t, SHAKE_MS / 1000);
var dx = SHAKE_AMPLITUDE * Math.sin(SHAKE_FREQ * shakeT) * Math.exp(-SHAKE_DECAY * shakeT);
cell.btn.style.transform = 'translate(' + dx.toFixed(2) + 'px,0)';
if (tt < 1) cell.raf = requestAnimationFrame(step);
else { cell.raf = 0; cell.btn.style.transform = 'translate(0,0)'; }
}
cell.raf = requestAnimationFrame(step);
}
cells.forEach(function (cell) {
cell.btn.addEventListener('pointerdown', function () { human(); attempt(cell); });
});
var idx = 0, start = 0;
var disabledIdx = cells.map(function (c, i) { return c.disabled ? i : -1; }).filter(function (i) { return i >= 0; });
function loop(ts) {
if (!start) start = ts;
if (isDemo && !reduce) {
var slot = Math.floor((ts - start) / CYCLE_MS) % disabledIdx.length;
if (slot !== idx) { idx = slot; attempt(cells[disabledIdx[idx]]); }
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from 'react';
import styles from './DisabledRippleBlock.module.scss';
type Box<T> = { current: T };
type Item = { label: string; disabled: boolean };
const ITEMS: Item[] = [
{ label: '삭제', disabled: true },
{ label: '승인', disabled: false },
{ label: '잠금 해제', disabled: true },
{ label: '내보내기', disabled: false }
];
const CYCLE_MS = 650; // 데모 자동 시도 간격
const HALT_RATIO = 0.5; // 계산 겹 상수: 리플이 최대 반경의 이 비율까지만 번지고 멈춘다
const SHAKE_MS = 420;
const SHAKE_FREQ = 26; // rad/s 대역 — 데모용 가정값
const SHAKE_DECAY = 6.5;
const SHAKE_AMPLITUDE = 6; // px, 데모용 가정값
type Cell = { btn: HTMLButtonElement; ripple: HTMLSpanElement; disabled: boolean; raf: number };
export default function DisabledRippleBlock() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const btnRefs = useRef([]) as unknown as Box<HTMLButtonElement[]>;
const rippleRefs = useRef([]) as unknown as Box<HTMLSpanElement[]>;
useEffect(() => {
const root = rootRef.current;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let isDemo = true;
function human() {
if (isDemo) { root.classList.remove(styles.isDemo); isDemo = false; }
}
const cells: Cell[] = ITEMS.map((item, i) => ({
btn: btnRefs.current[i],
ripple: rippleRefs.current[i],
disabled: item.disabled,
raf: 0
}));
// 계산 겹 1: 리플이 버튼 대각선의 절반(HALT_RATIO)까지만 실측 확산되다 멈춘다(막힘의 시각화).
// 계산 겹 2: 흔들림은 캔버스 키프레임이 아니라 rAF 로 sin(진동) × exp(감쇠) 를 매 프레임 적분한다.
function attempt(cell: Cell) {
if (!cell.disabled) return;
const r = cell.btn.getBoundingClientRect();
const maxR = Math.hypot(r.width, r.height) / 2;
const haltR = maxR * HALT_RATIO;
if (reduce) {
cell.ripple.style.transform = `translate(-50%,-50%) scale(${haltR / 4})`;
cell.ripple.style.opacity = '0.001';
cell.btn.style.transform = 'translate(0,0)';
return;
}
if (cell.raf) cancelAnimationFrame(cell.raf);
let t0 = 0;
const dur = Math.max(260, SHAKE_MS);
function step(ts: number) {
if (!t0) t0 = ts;
const t = (ts - t0) / 1000;
const tt = Math.min(1, (ts - t0) / dur);
const spread = haltR * (1 - Math.pow(1 - Math.min(1, tt * 1.6), 2));
cell.ripple.style.transform = `translate(-50%,-50%) scale(${Math.max(0.001, spread / 4)})`;
cell.ripple.style.opacity = String(Math.max(0, 1 - tt));
const shakeT = Math.min(t, SHAKE_MS / 1000);
const dx = SHAKE_AMPLITUDE * Math.sin(SHAKE_FREQ * shakeT) * Math.exp(-SHAKE_DECAY * shakeT);
cell.btn.style.transform = `translate(${dx.toFixed(2)}px,0)`;
if (tt < 1) cell.raf = requestAnimationFrame(step);
else { cell.raf = 0; cell.btn.style.transform = 'translate(0,0)'; }
}
cell.raf = requestAnimationFrame(step);
}
const cleanups: Array<() => void> = [];
cells.forEach((cell) => {
const onDown = () => { human(); attempt(cell); };
cell.btn.addEventListener('pointerdown', onDown);
cleanups.push(() => cell.btn.removeEventListener('pointerdown', onDown));
});
const disabledIdx = cells.map((c, i) => (c.disabled ? i : -1)).filter((i) => i >= 0);
let idx = 0;
let start = 0;
let raf = 0;
function loop(ts: number) {
if (!start) start = ts;
if (isDemo && !reduce) {
const slot = Math.floor((ts - start) / CYCLE_MS) % disabledIdx.length;
if (slot !== idx) { idx = slot; attempt(cells[disabledIdx[idx]]); }
}
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(raf);
cells.forEach((c) => { if (c.raf) cancelAnimationFrame(c.raf); });
cleanups.forEach((fn) => fn());
};
}, []);
return (
<div className={`${styles.drb} ${styles.isDemo}`} role="group" aria-label="일괄 작업" ref={(el: HTMLDivElement | null) => { if (el) rootRef.current = el; }}>
{ITEMS.map((item, i) => (
<button
key={item.label}
type="button"
className={styles.btn}
aria-disabled={item.disabled || undefined}
ref={(el: HTMLButtonElement | null) => { if (el) btnRefs.current[i] = el; }}
>
{item.label}
<span className={styles.ripple} ref={(el: HTMLSpanElement | null) => { if (el) rippleRefs.current[i] = el; }} />
</button>
))}
</div>
);
}09 비활성 파동 차단 — 비활성 값이 켜진 버튼을 누르면 파동 확산 값이 중간에서 멈추고 흔들림 값으로 대신 반응하는 방식 — 측정 누적면적 2.133% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 토글 버튼 그룹 9종 — 나란히 붙은 버튼이 하나처럼 움직이는 아홉 가지
이 데모가 속한 글
토글 버튼 그룹 9종 — 나란히 붙은 버튼이 하나처럼 움직이는 아홉 가지토글 버튼 그룹 9종을 값 기준으로 나누면, 아홉 가지 중 하나는 활성 버튼 값이 바뀌면 슬라이드 표시자의 위치 값이 실측 좌표를 따라 이동하고, 하나는 그룹 너비 값이 줄어들면 넘치는 버튼이 더보기 값 안으로 접히 이 글의 데모 전부 보기


