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 オリジナル · SCSS と React は zip に同梱 · ボタン グループ ui 9選 — 隣り合うボタンが一つに動く仕組み
このデモが属する記事
ボタン グループ ui 9選 — 隣り合うボタンが一つに動く仕組みボタン グループ ui: 9つのうち1つはアクティブボタンの値が変わるとスライド表示子の位置の値が実測座標に沿って移動し、1つはグループの幅の値が縮むと溢れたボタンがもっと見る値の中に折り畳まれ、もう1つは方向キーでフォーカスの値が移る際に この記事のデモをすべて見る


