토글 버튼 그룹 9종 — 나란히 붙은 버튼이 하나처럼 움직이는 아홉 가지
토글 버튼 그룹 9종을 값 기준으로 나누면, 아홉 가지 중 하나는 활성 버튼 값이 바뀌면 슬라이드 표시자의 위치 값이 실측 좌표를 따라 이동하고, 하나는 그룹 너비 값이 줄어들면 넘치는 버튼이 더보기 값 안으로 접히며, 또 하나는 방향키로 포커스 값이 옮겨질 때 포커스 링의 위치 값이
아홉 데모는 전부 requestAnimationFrame 루프 안에서 각자의 기준 값 하나를 계산해 다음 프레임의 모양을 정합니다. 항목마다 그 값이 무엇으로 시작해 무엇으로 끝나는지 표와 코드로 먼저 짚고, 실제 조작이 값을 어디서 깨뜨리는지는 뒤에서 따로 다룹니다.
01 btn-group-shared-border-merge
02 btn-group-active-slide-indicator
03 btn-group-divider-fade-on-hover
04 btn-group-radio-exclusive-press
05 btn-group-overflow-collapse-more
06 btn-group-vertical-horizontal-orient-morph
08 btn-group-keyboard-arrow-focus-slide
09 btn-group-disabled-ripple-block
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 공유 경계선 병합 — 나란히 붙은 두 버튼의 안쪽 경계선 값이 하나로 겹쳐 두 겹으로 보이지 않게 유지되는 방식
- 02 활성 표시자 슬라이드 — 활성 버튼 값이 바뀌면 강조 배경의 위치 값이 실측 좌표를 따라 옆 버튼으로 미끄러지는 방식
- 03 구분선 페이드 — 버튼 사이 구분선의 투명도 값이 인접 버튼에 마우스가 가까워질수록 옅어지는 방식
- 04 배타적 눌림 상태 — 한 버튼의 눌림 값이 켜지면 나머지 버튼의 눌림 값이 그 즉시 함께 꺼지는 방식
- 05 넘침 접힘 더보기 — 그룹 너비 값이 좁아지면 넘치는 버튼들이 하나의 더보기 값 안으로 접히는 방식
- 06 가로세로 방향 전환 — 그룹의 방향 값이 바뀌면 버튼들의 위치 값이 실측 좌표 사이를 이동해 줄이 다시 짜이는 방식
- 07 아이콘 라벨 조합 노출 — 컨테이너 너비 값이 넓어지면 아이콘 버튼의 글자 라벨 너비 값이 0에서 늘어나며 드러나는 방식
- 08 키보드 방향키 포커스 슬라이드 — 방향키로 포커스 값이 옮겨질 때 포커스 테두리의 위치 값이 버튼 사이 실측 좌표를 따라 이동하는 방식
- 09 비활성 파동 차단 — 비활성 값이 켜진 버튼을 누르면 파동 확산 값이 중간에서 멈추고 흔들림 값으로 대신 반응하는 방식
아홉 개의 해부 — 어떤 값이 언제 바뀌는가
아래 표는 아홉 항목이 화면에서 실제로 얼마나 넓게, 얼마나 세게, 얼마나 자주 움직였는지를 담은 값입니다. 버튼 그룹 중 누적 면적이 가장 넓은 항목은 06 하나(방향이 바뀌며 버튼들이 새 좌표로 다시 줄을 서는 만큼 화면을 넓게 씁니다)이고, 가장 좁은 항목은 03 하나(구분선 하나의 투명도만 바뀌므로 면적 자체가 작습니다)이며, 강도가 가장 센 항목은 08 하나(포커스 링이 좌표를 이징으로 따라가며 순간 변화량이 큽니다)뿐입니다.
| 항목 | 누적면적% | 강도_평균 | 움직인프레임 |
|---|---|---|---|
| 01 | 2.75% | 47.5 | 11/23 |
| 02 | 4.88% | 102.6 | 13/23 |
| 03 | 0.30% | 24.9 | 13/23 |
| 04 | 9.53% | 77.3 | 15/23 |
| 05 | 6.63% | 121.3 | 22/23 |
| 06 | 15.29% | 120.7 | 16/23 |
| 07 | 0.60% | 76.4 | 9/23 |
| 08 | 8.26% | 190.7 | 18/23 |
| 09 | 2.13% | 45.7 | 16/23 |
계산 방식으로 보면 아홉 항목은 네 갈래로 묶입니다.
스프링으로 튕기는 값 — 02·04
02 는 활성 버튼이 바뀔 때 STIFF 210·DAMP 18 스프링으로 표시자의 위치 값을 다음 버튼 좌표까지 감쇠 진동시키고, 04 는 눌림 값이 바뀔 때 STIFF 320·DAMP 14 스프링으로 버튼의 눌림 깊이 값을 오버슛과 함께 되돌립니다. 두 항목 모두 목표 값과 현재 값의 차이를 힘으로 바꿔 매 프레임 속도와 위치를 함께 갱신합니다.
실측 좌표를 이징으로 따라가는 값 — 06·07·08
06 은 방향이 바뀔 때 각 버튼의 이전 좌표와 새 좌표를 getBoundingClientRect 로 재고 FLIP 기법으로 그 사이를 480ms 동안 잇고, 07 은 컨테이너 너비 값이 기준을 넘으면 라벨의 실제 글자 너비 값을 재서 0부터 그 값까지 easeOutCubic 으로 늘리며, 08 은 방향키를 누를 때마다 다음 버튼의 실측 좌표까지 포커스 링의 위치 값을 easeInOut 으로 옮깁니다. 셋 다 스프링이 아니라 시작 값과 끝 값 사이를 정해진 시간 동안 이징 함수 하나로 보간합니다.
매 프레임 직접 계산하는 값 — 01·03·05
01 은 클릭마다 경계선 근처에 번지는 리플 반경 값을 프레임마다 계산하고, 03 은 포인터와 구분선의 거리 값을 매 프레임 다시 재 smoothstep 으로 투명도 값을 구하며, 05 는 버튼 너비의 합과 그룹 너비 값을 매 프레임 비교해 넘치는 버튼 수를 그 자리에서 정합니다. 세 항목은 보간할 시작·끝 값이 따로 없고, 그 프레임의 조건만으로 값이 곧바로 정해집니다.
한 번의 입력이 감쇠하며 끝나는 값 — 09
09 는 비활성 버튼을 누른 순간 파동 확산 값과 흔들림 값을 동시에 시작시켜, 파동은 절반에서 멈추고 흔들림은 sin 감쇠로 잦아들 때까지만 한 번 계산됩니다.
패턴별 코드
01공유 경계선 병합 — 나란히 붙은 두 버튼의 안쪽 경계선 값이 하나로 겹쳐 두 겹으로 보이지 않게 유지되는 방식
버튼과 버튼이 만나는 안쪽 경계선끼리 -1.5px 만큼 겹치게 잡아, 어느 버튼이 활성이어도 경계선이 두 겹으로 보이지 않게 만든 값입니다. 클릭은 그 위에서 번지는 리플 반경 값만 바꾸고, 겹침 값 자체는 버튼 배치가 바뀌지 않는 한 고정됩니다.
.bg__btn {
position: relative;
appearance: none;
border: 1.5px solid rgba($subject-cream, .38);
background: rgba($subject-cream, .06);
color: $subject-cream;
font-size: 13px;
font-weight: 600;
padding: $sp-2 $sp-4;
cursor: pointer;
overflow: hidden;
transition: background-color $dur-quick $ease-out, color $dur-quick $ease-out, border-color $dur-quick $ease-out;
// 공유 경계선 병합 — 이웃 버튼과 1.5px 만큼 겹쳐 안쪽 경계선이 항상 한 겹으로만 보인다
& + & { margin-left: -1.5px; }
&:first-child { border-radius: $r-control 0 0 $r-control; }
&:last-child { border-radius: 0 $r-control $r-control 0; }
&:hover, &:focus-visible { z-index: 1; }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}

<main class="stage">
<div class="bg is-demo" id="bg" role="group" aria-label="텍스트 정렬">
<button class="bg__btn" data-align="left" aria-pressed="true">왼쪽</button>
<button class="bg__btn" data-align="center" aria-pressed="false">중앙</button>
<button class="bg__btn" data-align="right" aria-pressed="false">오른쪽</button>
<button class="bg__btn" data-align="justify" aria-pressed="false">양쪽</button>
<span class="bg__seam" id="seam0" aria-hidden="true"></span>
<span class="bg__seam" id="seam1" aria-hidden="true"></span>
<span class="bg__seam" id="seam2" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 이 getBoundingClientRect 로 버튼 사이 공유 경계선의 실측 좌표를 재고,
// 클릭 파동(ripple)이 그 좌표 + BLEED_PX 만큼만 이웃으로 번지도록 clip-path 를 매 프레임 계산한다.
// 경계선 자체은 이 파일에서 음수 margin 으로 겹쳐 항상 한 겹으로만 보인다.
@include stage($stage-ink);
.bg {
position: relative;
display: flex;
border-radius: $r-control;
overflow: visible;
}
.bg__btn {
position: relative;
appearance: none;
border: 1.5px solid rgba($subject-cream, .38);
background: rgba($subject-cream, .06);
color: $subject-cream;
font-size: 13px;
font-weight: 600;
padding: $sp-2 $sp-4;
cursor: pointer;
overflow: hidden;
transition: background-color $dur-quick $ease-out, color $dur-quick $ease-out, border-color $dur-quick $ease-out;
// 공유 경계선 병합 — 이웃 버튼과 1.5px 만큼 겹쳐 안쪽 경계선이 항상 한 겹으로만 보인다
& + & { margin-left: -1.5px; }
&:first-child { border-radius: $r-control 0 0 $r-control; }
&:last-child { border-radius: 0 $r-control $r-control 0; }
&:hover, &:focus-visible { z-index: 1; }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.bg__btn--on {
background: $subject-blue-deep;
border-color: $subject-blue-deep;
color: $subject-cream;
z-index: 2;
}
.bg__ripple {
position: absolute;
border-radius: 50%;
background: rgba($subject-cream, .5);
pointer-events: none;
will-change: width, height, opacity, clip-path;
}
.bg__seam {
position: absolute;
top: 4px;
bottom: 4px;
width: 1px;
left: var(--seam-x, 0);
transform: translateX(-50%);
background: rgba($subject-cream, .16);
pointer-events: none;
z-index: 3;
}
@media (max-width: 340px) {
.bg__btn { font-size: 12px; padding: $sp-2 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.bg__btn { transition: none; }
.bg__ripple { 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);
}
.bg {
position: relative;
display: flex;
border-radius: 12px;
overflow: visible;
}
.bg__btn {
position: relative;
appearance: none;
border: 1.5px solid rgba(255, 247, 230, 0.38);
background: rgba(255, 247, 230, 0.06);
color: #fff7e6;
font-size: 13px;
font-weight: 600;
padding: 8px 16px;
cursor: pointer;
overflow: hidden;
transition: background-color 220ms ease-out, color 220ms ease-out, border-color 220ms ease-out;
}
.bg__btn + .bg__btn {
margin-left: -1.5px;
}
.bg__btn:first-child {
border-radius: 12px 0 0 12px;
}
.bg__btn:last-child {
border-radius: 0 12px 12px 0;
}
.bg__btn:hover, .bg__btn:focus-visible {
z-index: 1;
}
.bg__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.bg__btn--on {
background: #0a4fe8;
border-color: #0a4fe8;
color: #fff7e6;
z-index: 2;
}
.bg__ripple {
position: absolute;
border-radius: 50%;
background: rgba(255, 247, 230, 0.5);
pointer-events: none;
will-change: width, height, opacity, clip-path;
}
.bg__seam {
position: absolute;
top: 4px;
bottom: 4px;
width: 1px;
left: var(--seam-x, 0);
transform: translateX(-50%);
background: rgba(255, 247, 230, 0.16);
pointer-events: none;
z-index: 3;
}
@media (max-width: 340px) {
.bg__btn {
font-size: 12px;
padding: 8px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.bg__btn {
transition: none;
}
.bg__ripple {
display: none;
}
}(function () {
var CYCLE_MS = 900; // 자동 루프가 다음 버튼으로 넘어가는 간격
var BLEED_PX = 6; // 계산 겹 상수: 파동이 이웃 경계선을 넘어 번질 수 있는 최대 여유(px) — 이 값만큼만 넘고 그 이상은 실측 경계선에서 잘린다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var RIPPLE_MS = reduce ? 1 : 420; // 계산 자체는 유지하되 reduce 에서는 목표 상태로 즉시 놓는다
var root = document.getElementById('bg');
var btns = Array.prototype.slice.call(root.querySelectorAll('.bg__btn'));
var seams = [document.getElementById('seam0'), document.getElementById('seam1'), document.getElementById('seam2')];
var isDemo = true;
// 계산 겹: 버튼 사이 공유 경계선의 실제 화면 좌표를 getBoundingClientRect 로 잰다 — 짐작 px 를 쓰지 않는다.
function measureSeams() {
var rootRect = root.getBoundingClientRect();
for (var i = 0; i < seams.length; i++) {
var a = btns[i].getBoundingClientRect();
var b = btns[i + 1].getBoundingClientRect();
var seamX = (a.right + b.left) / 2 - rootRect.left; // 두 버튼이 겹치는 경계선의 실측 중점
seams[i].style.setProperty('--seam-x', seamX.toFixed(2) + 'px');
}
}
function setActive(btn) {
btns.forEach(function (b) { b.setAttribute('aria-pressed', String(b === btn)); b.classList.toggle('bg__btn--on', b === btn); });
}
var ripples = [];
function spawnRipple(btn, clientX) {
var idx = btns.indexOf(btn);
var rect = btn.getBoundingClientRect();
var rootRect = root.getBoundingClientRect();
var originX = (clientX == null ? rect.left + rect.width / 2 : clientX) - rect.left;
var leftSeam = idx > 0 ? btns[idx - 1].getBoundingClientRect().right - rect.left : null;
var rightSeam = idx < btns.length - 1 ? btns[idx + 1].getBoundingClientRect().left - rect.left : null;
var span = document.createElement('span');
span.className = 'bg__ripple';
span.style.left = originX + 'px';
span.style.top = (rect.height / 2) + 'px';
btn.appendChild(span);
ripples.push({
el: span, start: performance.now(), from: 0, to: Math.max(rect.width, rect.height) * 1.3,
// 계산 겹: 파동이 번질 수 있는 한계를 이웃 경계선 실측값 + BLEED_PX 로 고정한다
minClip: leftSeam == null ? -9999 : leftSeam - BLEED_PX,
maxClip: rightSeam == null ? 9999 : rightSeam + BLEED_PX,
originX: originX
});
}
function easeOutQuad(t) { return 1 - (1 - t) * (1 - t); }
function tickRipples(ts) {
for (var i = ripples.length - 1; i >= 0; i--) {
var r = ripples[i];
var t = Math.min(1, (ts - r.start) / RIPPLE_MS);
var radius = r.from + (r.to - r.from) * easeOutQuad(t);
var left = Math.max(r.minClip, r.originX - radius);
var right = Math.min(r.maxClip, r.originX + radius);
r.el.style.width = radius * 2 + 'px';
r.el.style.height = radius * 2 + 'px';
r.el.style.marginLeft = -radius + 'px';
r.el.style.marginTop = -radius + 'px';
r.el.style.opacity = String(1 - t);
// clip-path 로 파동이 실측 경계선 밖으로 못 나가게 자른다
r.el.style.clipPath = 'inset(-9999px ' + Math.max(0, r.originX + radius - right) + 'px -9999px ' + Math.max(0, left - (r.originX - radius)) + 'px)';
if (t >= 1) { r.el.remove(); ripples.splice(i, 1); }
}
}
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
btns.forEach(function (b) {
b.addEventListener('click', function (e) {
human();
setActive(b);
spawnRipple(b, reduce ? null : e.clientX);
});
});
window.addEventListener('resize', measureSeams);
measureSeams();
var cycleStart = 0, lastIdx = -1;
function loop(ts) {
if (!cycleStart) cycleStart = ts;
if (isDemo && !reduce) {
var idx = Math.floor((ts - cycleStart) / CYCLE_MS) % btns.length;
if (idx !== lastIdx) { lastIdx = idx; setActive(btns[idx]); spawnRipple(btns[idx], null); }
}
tickRipples(ts);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// SharedBorderMerge — vanilla 판(run/369/demo/01-btn-group-shared-border-merge/index.html) 의 React 포트.
// getBoundingClientRect 로 버튼 사이 공유 경계선의 실측 좌표를 재고, 클릭 파동을 그 좌표 + BLEED_PX
// 만큼만 이웃으로 번지도록 clip-path 로 매 프레임 계산해 자른다.
import { useEffect, useRef } from "react";
import styles from "./SharedBorderMerge.module.scss";
type Box<T> = { current: T };
type RippleState = { el: HTMLSpanElement; start: number; to: number; minClip: number; maxClip: number; originX: number };
const ALIGNS = ["left", "center", "right", "justify"] as const;
const LABELS: Record<string, string> = { left: "왼쪽", center: "중앙", right: "오른쪽", justify: "양쪽" };
export default function SharedBorderMerge() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const btnRefs = useRef([]) as unknown as Box<HTMLButtonElement[]>;
const seamRefs = useRef([]) as unknown as Box<HTMLSpanElement[]>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const CYCLE_MS = 900;
const BLEED_PX = 6; // 계산 겹 상수: 파동이 이웃 경계선을 넘어 번질 수 있는 최대 여유(px)
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const RIPPLE_MS = reduce ? 1 : 420;
const root = rootRef.current;
const btns = btnRefs.current;
const seams = seamRefs.current;
if (!root || btns.length === 0) return;
let isDemo = true;
const ripples: RippleState[] = [];
function measureSeams() {
const rootRect = root.getBoundingClientRect();
for (let i = 0; i < seams.length; i++) {
const a = btns[i].getBoundingClientRect();
const b = btns[i + 1].getBoundingClientRect();
const seamX = (a.right + b.left) / 2 - rootRect.left;
seams[i].style.setProperty("--seam-x", seamX.toFixed(2) + "px");
}
}
function setActive(btn: HTMLButtonElement) {
btns.forEach((b) => {
b.setAttribute("aria-pressed", String(b === btn));
b.classList.toggle(styles.btnOn, b === btn);
});
}
function spawnRipple(btn: HTMLButtonElement, clientX: number | null) {
const idx = btns.indexOf(btn);
const rect = btn.getBoundingClientRect();
const originX = (clientX == null ? rect.left + rect.width / 2 : clientX) - rect.left;
const leftSeam = idx > 0 ? btns[idx - 1].getBoundingClientRect().right - rect.left : null;
const rightSeam = idx < btns.length - 1 ? btns[idx + 1].getBoundingClientRect().left - rect.left : null;
const span = document.createElement("span");
span.className = styles.ripple;
span.style.left = originX + "px";
span.style.top = rect.height / 2 + "px";
btn.appendChild(span);
ripples.push({
el: span, start: performance.now(), to: Math.max(rect.width, rect.height) * 1.3,
minClip: leftSeam == null ? -9999 : leftSeam - BLEED_PX,
maxClip: rightSeam == null ? 9999 : rightSeam + BLEED_PX,
originX,
});
}
function easeOutQuad(t: number) { return 1 - (1 - t) * (1 - t); }
function tickRipples(ts: number) {
for (let i = ripples.length - 1; i >= 0; i--) {
const r = ripples[i];
const t = Math.min(1, (ts - r.start) / RIPPLE_MS);
const radius = r.to * easeOutQuad(t);
const left = Math.max(r.minClip, r.originX - radius);
const right = Math.min(r.maxClip, r.originX + radius);
r.el.style.width = radius * 2 + "px";
r.el.style.height = radius * 2 + "px";
r.el.style.marginLeft = -radius + "px";
r.el.style.marginTop = -radius + "px";
r.el.style.opacity = String(1 - t);
r.el.style.clipPath = "inset(-9999px " + Math.max(0, r.originX + radius - right) + "px -9999px " + Math.max(0, left - (r.originX - radius)) + "px)";
if (t >= 1) { r.el.remove(); ripples.splice(i, 1); }
}
}
function human() { if (isDemo) { root.classList.remove(styles.isDemo); isDemo = false; } }
function onClick(this: HTMLButtonElement, e: MouseEvent) {
human();
setActive(this);
spawnRipple(this, reduce ? null : e.clientX);
}
btns.forEach((b) => b.addEventListener("click", onClick as unknown as EventListener));
window.addEventListener("resize", measureSeams);
measureSeams();
let cycleStart = 0, lastIdx = -1;
function loop(ts: number) {
if (!cycleStart) cycleStart = ts;
if (isDemo && !reduce) {
const idx = Math.floor((ts - cycleStart) / CYCLE_MS) % btns.length;
if (idx !== lastIdx) { lastIdx = idx; setActive(btns[idx]); spawnRipple(btns[idx], null); }
}
tickRipples(ts);
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
btns.forEach((b) => b.removeEventListener("click", onClick as unknown as EventListener));
window.removeEventListener("resize", measureSeams);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.bg + " " + styles.isDemo} ref={rootRef} role="group" aria-label="텍스트 정렬">
{ALIGNS.map((align, i) => (
<button
key={align}
className={styles.btn}
data-align={align}
aria-pressed={i === 0}
ref={(el: HTMLButtonElement | null) => { if (el) btnRefs.current[i] = el; }}
>
{LABELS[align]}
</button>
))}
{[0, 1, 2].map((i) => (
<span key={i} className={styles.seam} ref={(el: HTMLSpanElement | null) => { if (el) seamRefs.current[i] = el; }} aria-hidden="true" />
))}
</div>
</main>
);
}01 · 공유 경계선 병합 — 나란히 붙은 두 버튼의 안쪽 경계선 값이 하나로 겹쳐 두 겹으로 보이지 않게 유지되는 방식 — 측정 누적면적 2.754% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02활성 표시자 슬라이드 — 활성 버튼 값이 바뀌면 강조 배경의 위치 값이 실측 좌표를 따라 옆 버튼으로 미끄러지는 방식
활성 버튼이 바뀔 때마다 표시자의 목표 위치를 두 버튼의 실측 좌표 차이로 구하고, STIFF 210·DAMP 18 스프링으로 그 차이를 매 프레임 줄여 나가는 값입니다. 감쇠가 낮아 목표를 살짝 지나쳤다가 되돌아오는 오버슛이 자연스럽게 남습니다.
function render() {
var scale = maxW > 0 ? w / maxW : 1;
indicator.style.transform = 'translateX(' + x.toFixed(2) + 'px) scaleX(' + scale.toFixed(4) + ')';
}
var cycleStart = 0, lastIdx = -1;
function loop(ts) {
if (!last) last = ts;
var dt = Math.min(0.032, (ts - last) / 1000);
last = ts;
if (!cycleStart) cycleStart = ts;
if (isDemo && !reduce) {
var idx = Math.floor((ts - cycleStart) / CYCLE_MS) % btns.length;
if (idx !== lastIdx) { lastIdx = idx; goTo(idx, ts); }
}
var ax = STIFF * (targetX - x) - DAMP * vx;
var aw = STIFF * (targetW - w) - DAMP * vw;
vx += ax * dt; x += vx * dt;
vw += aw * dt; w += vw * dt;
render();
requestAnimationFrame(loop);
}

<main class="stage">
<div class="asi-wrap">
<div class="asi is-demo" id="asi" role="group" aria-label="정렬 기준">
<span class="asi__indicator" id="asiIndicator" aria-hidden="true"></span>
<button class="asi__btn asi__btn--on" data-i="0" aria-pressed="true">최신순</button>
<button class="asi__btn" data-i="1" aria-pressed="false">인기순</button>
<button class="asi__btn" data-i="2" aria-pressed="false">가격순</button>
<button class="asi__btn" data-i="3" aria-pressed="false">평점 높은순</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 이 getBoundingClientRect 로 활성 버튼의 실측 좌표·폭을 재고, 감쇠 스프링
// 방정식(x'' = k*(target-x) - c*x')을 매 프레임 적분해 표시자를 오버슛 후 정착시킨다.
// 폭 변화는 CSS width 가 아니라 transform: scaleX 비율로만 표현해 레이아웃을 다시 계산하지 않는다.
@include stage($stage-ink);
.asi-wrap {
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.asi {
position: relative;
display: inline-flex;
gap: 0;
padding: 4px;
border-radius: $r-pill;
background: rgba($subject-cream, .07);
}
.asi__indicator {
position: absolute;
top: 4px;
left: 4px;
height: calc(100% - #{$sp-2});
border-radius: $r-pill;
background: $subject-blue-deep;
transform-origin: left center;
will-change: transform;
z-index: 0;
}
.asi__btn {
position: relative;
z-index: 1;
appearance: none;
border: none;
background: transparent;
color: rgba($subject-cream, .78);
font-size: 13px;
font-weight: 600;
padding: $sp-2 $sp-4;
white-space: nowrap;
cursor: pointer;
border-radius: $r-pill;
transition: color $dur-quick $ease-out;
}
.asi__btn--on {
color: $subject-cream;
}
.asi__btn:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
@media (max-width: 340px) {
.asi__btn { font-size: 12px; padding: $sp-1 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.asi__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.asi-wrap {
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.asi {
position: relative;
display: inline-flex;
gap: 0;
padding: 4px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.07);
}
.asi__indicator {
position: absolute;
top: 4px;
left: 4px;
height: calc(100% - 8px);
border-radius: 999px;
background: #0a4fe8;
transform-origin: left center;
will-change: transform;
z-index: 0;
}
.asi__btn {
position: relative;
z-index: 1;
appearance: none;
border: none;
background: transparent;
color: rgba(255, 247, 230, 0.78);
font-size: 13px;
font-weight: 600;
padding: 8px 16px;
white-space: nowrap;
cursor: pointer;
border-radius: 999px;
transition: color 220ms ease-out;
}
.asi__btn--on {
color: #fff7e6;
}
.asi__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.asi__btn {
font-size: 12px;
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.asi__btn {
transition: none;
}
}(function () {
var CYCLE_MS = 1100; // 자동 루프가 다음 버튼으로 넘어가는 간격
var STIFF = 210; // 계산 겹 상수: 스프링 강성(k) — 클수록 목표에 빨리 붙는다
var DAMP = 18; // 계산 겹 상수: 감쇠(c) — 작을수록 오버슛이 크게 남는다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('asi');
var indicator = document.getElementById('asiIndicator');
var btns = Array.prototype.slice.call(root.querySelectorAll('.asi__btn'));
var isDemo = true;
// 계산 겹: 목표(버튼 실측 좌표)를 향해 감쇠 스프링 방정식으로 위치·폭 비율을 매 프레임 적분한다.
// x'' = STIFF*(target-x) - DAMP*x' — cubic-bezier 로는 못 만드는 오버슛 후 정착 곡선이다.
// 폭은 CSS width 가 아니라 scaleX(비율) 로만 바꿔 레이아웃을 다시 계산하지 않는다(transform 전용 규칙).
var x = 0, w = 0, vx = 0, vw = 0, targetX = 0, targetW = 0, last = 0, activeIdx = 0;
var maxW = 0;
function measure(idx) {
var rootRect = root.getBoundingClientRect();
var r = btns[idx].getBoundingClientRect();
return { x: r.left - rootRect.left, w: r.width };
}
// 시작 시 모든 버튼 폭을 실측해 인디케이터의 고정 기준 폭(maxW)을 정한다 — scaleX 는 이 기준에 대한 비율이다
btns.forEach(function (b) { maxW = Math.max(maxW, b.getBoundingClientRect().width); });
indicator.style.width = maxW + 'px';
function goTo(idx, ts) {
activeIdx = idx;
btns.forEach(function (b, i) {
b.classList.toggle('asi__btn--on', i === idx);
b.setAttribute('aria-pressed', String(i === idx));
});
var m = measure(idx);
targetX = m.x; targetW = m.w;
if (reduce) { x = targetX; w = targetW; vx = 0; vw = 0; }
}
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
btns.forEach(function (b, i) {
b.addEventListener('click', function () { human(); goTo(i, performance.now()); });
});
window.addEventListener('resize', function () { var m = measure(activeIdx); targetX = m.x; targetW = m.w; });
// 초기 위치를 즉시 실측값으로 놓는다
requestAnimationFrame(function () { var m = measure(0); x = targetX = m.x; w = targetW = m.w; render(); });
function render() {
var scale = maxW > 0 ? w / maxW : 1;
indicator.style.transform = 'translateX(' + x.toFixed(2) + 'px) scaleX(' + scale.toFixed(4) + ')';
}
var cycleStart = 0, lastIdx = -1;
function loop(ts) {
if (!last) last = ts;
var dt = Math.min(0.032, (ts - last) / 1000);
last = ts;
if (!cycleStart) cycleStart = ts;
if (isDemo && !reduce) {
var idx = Math.floor((ts - cycleStart) / CYCLE_MS) % btns.length;
if (idx !== lastIdx) { lastIdx = idx; goTo(idx, ts); }
}
var ax = STIFF * (targetX - x) - DAMP * vx;
var aw = STIFF * (targetW - w) - DAMP * vw;
vx += ax * dt; x += vx * dt;
vw += aw * dt; w += vw * dt;
render();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// ActiveSlideIndicator — vanilla 판(run/369/demo/02-btn-group-active-slide-indicator/index.html) 의 React 포트.
// getBoundingClientRect 로 활성 버튼의 실측 좌표·폭을 재고, 감쇠 스프링 방정식을 매 프레임 적분해
// 표시자를 오버슛 후 정착시킨다. 폭 변화는 transform: scaleX 비율로만 표현한다.
import { useEffect, useRef } from "react";
import styles from "./ActiveSlideIndicator.module.scss";
type Box<T> = { current: T };
const LABELS = ["최신순", "인기순", "가격순", "평점 높은순"];
export default function ActiveSlideIndicator() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const indicatorRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const btnRefs = useRef([]) as unknown as Box<HTMLButtonElement[]>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const CYCLE_MS = 1100;
const STIFF = 210; // 계산 겹 상수: 스프링 강성(k)
const DAMP = 18; // 계산 겹 상수: 감쇠(c)
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const indicator = indicatorRef.current;
const btns = btnRefs.current;
if (!root || !indicator || btns.length === 0) return;
let isDemo = true;
let x = 0, w = 0, vx = 0, vw = 0, targetX = 0, targetW = 0, last = 0, activeIdx = 0;
let maxW = 0;
btns.forEach((b) => { maxW = Math.max(maxW, b.getBoundingClientRect().width); });
indicator.style.width = maxW + "px";
function measure(idx: number) {
const rootRect = root!.getBoundingClientRect();
const r = btns[idx].getBoundingClientRect();
return { x: r.left - rootRect.left, w: r.width };
}
function goTo(idx: number) {
activeIdx = idx;
btns.forEach((b, i) => {
b.classList.toggle(styles.btnOn, i === idx);
b.setAttribute("aria-pressed", String(i === idx));
});
const m = measure(idx);
targetX = m.x; targetW = m.w;
if (reduce) { x = targetX; w = targetW; vx = 0; vw = 0; }
}
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function onResize() { const m = measure(activeIdx); targetX = m.x; targetW = m.w; }
window.addEventListener("resize", onResize);
const clicks: (() => void)[] = [];
btns.forEach((b, i) => {
const fn = () => { human(); goTo(i); };
b.addEventListener("click", fn);
clicks.push(fn);
});
function render() {
const scale = maxW > 0 ? w / maxW : 1;
indicator!.style.transform = "translateX(" + x.toFixed(2) + "px) scaleX(" + scale.toFixed(4) + ")";
}
let cycleStart = 0, lastIdx = -1;
function loop(ts: number) {
if (!last) last = ts;
const dt = Math.min(0.032, (ts - last) / 1000);
last = ts;
if (!cycleStart) cycleStart = ts;
if (isDemo && !reduce) {
const idx = Math.floor((ts - cycleStart) / CYCLE_MS) % btns.length;
if (idx !== lastIdx) { lastIdx = idx; goTo(idx); }
}
const ax = STIFF * (targetX - x) - DAMP * vx;
const aw = STIFF * (targetW - w) - DAMP * vw;
vx += ax * dt; x += vx * dt;
vw += aw * dt; w += vw * dt;
render();
rafRef.current = requestAnimationFrame(loop);
}
requestAnimationFrame(() => { const m = measure(0); x = targetX = m.x; w = targetW = m.w; render(); });
rafRef.current = requestAnimationFrame(loop);
return () => {
window.removeEventListener("resize", onResize);
btns.forEach((b, i) => b.removeEventListener("click", clicks[i]));
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.asiWrap}>
<div className={styles.asi + " " + styles.isDemo} ref={rootRef} role="group" aria-label="정렬 기준">
<span className={styles.indicator} ref={indicatorRef} aria-hidden="true" />
{LABELS.map((label, i) => (
<button
key={label}
className={styles.btn + (i === 0 ? " " + styles.btnOn : "")}
aria-pressed={i === 0}
ref={(el: HTMLButtonElement | null) => { if (el) btnRefs.current[i] = el; }}
>
{label}
</button>
))}
</div>
</div>
</main>
);
}02 · 활성 표시자 슬라이드 — 활성 버튼 값이 바뀌면 강조 배경의 위치 값이 실측 좌표를 따라 옆 버튼으로 미끄러지는 방식 — 측정 누적면적 4.878% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03구분선 페이드 — 버튼 사이 구분선의 투명도 값이 인접 버튼에 마우스가 가까워질수록 옅어지는 방식
포인터가 구분선에 가까워질수록 거리 값이 FALLOFF_PX 46 안으로 들어오고, smoothstep 으로 구한 그 값을 lerp 계수 .22 로 매 프레임 투명도에 가깝게 붙이는 항목입니다. 포인터가 멀어지면 같은 계산이 반대 방향으로 진행돼 구분선이 다시 또렷해집니다.
function loop(ts) {
if (!demoStart) demoStart = ts;
var rect = root.getBoundingClientRect();
var px = isDemo && !reduce ? demoPointerX(ts, rect.width) : pointerX;
var centers = seamCenters();
for (var i = 0; i < seams.length; i++) {
var target = px == null ? 1 : targetOpacity(Math.abs(px - centers[i]));
opac[i] += (target - opac[i]) * (reduce ? 1 : SMOOTH);
seams[i].style.opacity = opac[i].toFixed(3);
}
requestAnimationFrame(loop);
}

<main class="stage">
<div class="dfh is-demo" id="dfh" role="group" aria-label="보기 전환">
<button class="dfh__btn">그리드</button>
<span class="dfh__seam" id="seam0" aria-hidden="true"></span>
<button class="dfh__btn">리스트</button>
<span class="dfh__seam" id="seam1" aria-hidden="true"></span>
<button class="dfh__btn">카드</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 이 포인터(x) 와 각 구분선의 실측 중점 사이 거리 값을 매 프레임 재고,
// smoothstep falloff 로 목표 투명도를 구한 뒤 지수 완화(lerp)로 순간이동 없이 따라가게 한다.
@include stage($stage-yellow);
.dfh {
position: relative;
display: flex;
align-items: center;
border-radius: $r-control;
background: rgba($subject-ink, .06);
padding: $sp-1;
}
.dfh__btn {
appearance: none;
border: none;
background: transparent;
color: $subject-ink;
font-size: 13px;
font-weight: 700;
padding: $sp-2 $sp-4;
cursor: pointer;
border-radius: $r-sm;
transition: background-color $dur-quick $ease-out;
&:hover, &:focus-visible { background: rgba($subject-ink, .08); }
&:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
}
.dfh__seam {
position: relative;
width: 4px;
height: 28px;
align-self: center;
opacity: 1;
will-change: opacity;
&::before {
content: "";
position: absolute;
inset: 0;
border-radius: $r-xs;
background: rgba($subject-ink, .42);
}
&::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 26px;
height: 26px;
transform: translate(-50%, -50%);
border-radius: 50%;
background: rgba($subject-ink, .14);
filter: blur(5px);
}
}
@media (max-width: 340px) {
.dfh__btn { font-size: 12px; padding: $sp-2 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.dfh__btn { transition: none; }
.dfh__seam { opacity: 1 !important; }
}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);
}
.dfh {
position: relative;
display: flex;
align-items: center;
border-radius: 12px;
background: rgba(23, 20, 26, 0.06);
padding: 4px;
}
.dfh__btn {
appearance: none;
border: none;
background: transparent;
color: #17141a;
font-size: 13px;
font-weight: 700;
padding: 8px 16px;
cursor: pointer;
border-radius: 8px;
transition: background-color 220ms ease-out;
}
.dfh__btn:hover, .dfh__btn:focus-visible {
background: rgba(23, 20, 26, 0.08);
}
.dfh__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.dfh__seam {
position: relative;
width: 4px;
height: 28px;
align-self: center;
opacity: 1;
will-change: opacity;
}
.dfh__seam::before {
content: "";
position: absolute;
inset: 0;
border-radius: 4px;
background: rgba(23, 20, 26, 0.42);
}
.dfh__seam::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 26px;
height: 26px;
transform: translate(-50%, -50%);
border-radius: 50%;
background: rgba(23, 20, 26, 0.14);
filter: blur(5px);
}
@media (max-width: 340px) {
.dfh__btn {
font-size: 12px;
padding: 8px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.dfh__btn {
transition: none;
}
.dfh__seam {
opacity: 1 !important;
}
}(function () {
var FALLOFF_PX = 46; // 계산 겹 상수: 포인터 거리 값이 이 px 안이면 구분선 투명도가 낮아지기 시작한다
var SMOOTH = 0.22; // 계산 겹 상수: 지수 완화(lerp) 비율 — 값이 순간이동하지 않고 따라간다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('dfh');
var seams = [document.getElementById('seam0'), document.getElementById('seam1')];
var isDemo = true;
var pointerX = null;
var opac = [1, 1]; // 계산 겹: 각 구분선의 현재 렌더 투명도(완화된 값)
function seamCenters() {
var rootRect = root.getBoundingClientRect();
return seams.map(function (s) {
var r = s.getBoundingClientRect();
return r.left + r.width / 2 - rootRect.left;
});
}
// 계산 겹: 포인터 거리 값 → 투명도 목표값(0~1)을 매끄러운 falloff 곡선으로 계산
function targetOpacity(dist) {
var t = Math.min(1, Math.max(0, dist / FALLOFF_PX));
return t * t * (3 - 2 * t); // smoothstep — 선형보다 자연스러운 감쇠
}
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
root.addEventListener('pointermove', function (e) {
human();
pointerX = e.clientX - root.getBoundingClientRect().left;
});
root.addEventListener('pointerleave', function () { pointerX = null; });
var demoStart = 0;
function demoPointerX(ts, width) {
var t = (ts - demoStart) / 1000;
return width / 2 + Math.sin(t * 1.1) * (width / 2 - 10); // 좌우로 오가는 시뮬레이션 좌표
}
function loop(ts) {
if (!demoStart) demoStart = ts;
var rect = root.getBoundingClientRect();
var px = isDemo && !reduce ? demoPointerX(ts, rect.width) : pointerX;
var centers = seamCenters();
for (var i = 0; i < seams.length; i++) {
var target = px == null ? 1 : targetOpacity(Math.abs(px - centers[i]));
opac[i] += (target - opac[i]) * (reduce ? 1 : SMOOTH);
seams[i].style.opacity = opac[i].toFixed(3);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// DividerFadeOnHover — vanilla 판(run/369/demo/03-btn-group-divider-fade-on-hover/index.html) 의 React 포트.
// 포인터(x) 와 각 구분선의 실측 중점 사이 거리 값을 매 프레임 재고, smoothstep falloff 로 목표
// 투명도를 구한 뒤 지수 완화(lerp)로 순간이동 없이 따라가게 한다.
import { useEffect, useRef } from "react";
import styles from "./DividerFadeOnHover.module.scss";
type Box<T> = { current: T };
export default function DividerFadeOnHover() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const seamRefs = useRef([]) as unknown as Box<HTMLSpanElement[]>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const FALLOFF_PX = 46; // 계산 겹 상수: 포인터 거리 값이 이 px 안이면 구분선 투명도가 낮아지기 시작한다
const SMOOTH = 0.22; // 계산 겹 상수: 지수 완화(lerp) 비율
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const seams = seamRefs.current;
if (!root || seams.length === 0) return;
let isDemo = true;
let pointerX: number | null = null;
const opac = [1, 1];
function seamCenters() {
const rootRect = root!.getBoundingClientRect();
return seams.map((s) => {
const r = s.getBoundingClientRect();
return r.left + r.width / 2 - rootRect.left;
});
}
function targetOpacity(dist: number) {
const t = Math.min(1, Math.max(0, dist / FALLOFF_PX));
return t * t * (3 - 2 * t);
}
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function onMove(e: PointerEvent) { human(); pointerX = e.clientX - root!.getBoundingClientRect().left; }
function onLeave() { pointerX = null; }
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", onLeave);
let demoStart = 0;
function demoPointerX(ts: number, width: number) {
const t = (ts - demoStart) / 1000;
return width / 2 + Math.sin(t * 1.1) * (width / 2 - 10);
}
function loop(ts: number) {
if (!demoStart) demoStart = ts;
const rect = root!.getBoundingClientRect();
const px = isDemo && !reduce ? demoPointerX(ts, rect.width) : pointerX;
const centers = seamCenters();
for (let i = 0; i < seams.length; i++) {
const target = px == null ? 1 : targetOpacity(Math.abs(px - centers[i]));
opac[i] += (target - opac[i]) * (reduce ? 1 : SMOOTH);
seams[i].style.opacity = opac[i].toFixed(3);
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", onLeave);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.dfh + " " + styles.isDemo} ref={rootRef} role="group" aria-label="보기 전환">
<button className={styles.btn}>그리드</button>
<span className={styles.seam} ref={(el: HTMLSpanElement | null) => { if (el) seamRefs.current[0] = el; }} aria-hidden="true" />
<button className={styles.btn}>리스트</button>
<span className={styles.seam} ref={(el: HTMLSpanElement | null) => { if (el) seamRefs.current[1] = el; }} aria-hidden="true" />
<button className={styles.btn}>카드</button>
</div>
</main>
);
}03 · 구분선 페이드 — 버튼 사이 구분선의 투명도 값이 인접 버튼에 마우스가 가까워질수록 옅어지는 방식 — 측정 누적면적 0.297% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
04배타적 눌림 상태 — 한 버튼의 눌림 값이 켜지면 나머지 버튼의 눌림 값이 그 즉시 함께 꺼지는 방식
한 버튼을 클릭하거나 방향키로 옆 버튼에 초점을 옮기면 그 버튼의 눌림 값만 켜고 나머지는 그 즉시 함께 꺼서, 항상 하나의 값만 켜져 있게 만드는 항목입니다. 켜지는 쪽은 STIFF 320·DAMP 14 스프링으로 눌림 깊이를 오버슛과 함께 되돌립니다.
function setChecked(idx) {
checkedIdx = idx;
btns.forEach(function (b, i) {
var on = i === idx;
b.setAttribute('aria-checked', String(on));
b.setAttribute('tabindex', on ? '0' : '-1');
target[i] = on ? 1 : 0;
if (reduce) { depth[i] = target[i]; vel[i] = 0; }
});
}
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
btns.forEach(function (b, i) {
b.addEventListener('click', function () { human(); setChecked(i); b.focus({ preventScroll: true }); });
});

<main class="stage">
<div class="rep is-demo" id="rep" role="radiogroup" aria-label="보기 방식">
<button class="rep__btn" role="radio" aria-checked="true" tabindex="0" data-i="0">목록형</button>
<button class="rep__btn" role="radio" aria-checked="false" tabindex="-1" data-i="1">격자형</button>
<button class="rep__btn" role="radio" aria-checked="false" tabindex="-1" data-i="2">보드형</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 이 버튼마다 눌림 깊이(depth)를 감쇠 스프링으로 적분한다. 한 버튼이 켜지는
// 순간 나머지가 동시에 0으로 되돌아가고, 켜지는 버튼은 depth 1을 살짝 넘겼다가 정착한다(오버슛).
// translateY·box-shadow 는 그 깊이값의 함수다 — 레이아웃에 영향 없는 transform·paint 만 바뀐다.
@include stage($stage-orange);
.rep {
display: flex;
gap: $sp-3;
}
.rep__btn {
appearance: none;
border: none;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
font-size: 13px;
font-weight: 700;
padding: $sp-3 $sp-5;
cursor: pointer;
box-shadow: 0 8px 14px rgba(23, 20, 26, .34);
will-change: transform, box-shadow;
&:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
}
.rep__btn--on {
background: $subject-blue-deep;
color: $subject-cream;
}
@media (max-width: 340px) {
.rep { gap: $sp-2; }
.rep__btn { font-size: 12px; padding: $sp-2 $sp-4; }
}
@media (prefers-reduced-motion: reduce) {
.rep__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);
}
.rep {
display: flex;
gap: 12px;
}
.rep__btn {
appearance: none;
border: none;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-size: 13px;
font-weight: 700;
padding: 12px 20px;
cursor: pointer;
box-shadow: 0 8px 14px rgba(23, 20, 26, 0.34);
will-change: transform, box-shadow;
}
.rep__btn:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.rep__btn--on {
background: #0a4fe8;
color: #fff7e6;
}
@media (max-width: 340px) {
.rep {
gap: 8px;
}
.rep__btn {
font-size: 12px;
padding: 8px 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.rep__btn {
transition: none;
}
}(function () {
var CYCLE_MS = 950; // 자동 루프가 다음 버튼을 누르는 간격
var STIFF = 320; // 계산 겹 상수: 눌림 깊이 스프링 강성
var DAMP = 14; // 계산 겹 상수: 감쇠 — 작을수록 눌린 뒤 살짝 되튀는 오버슛이 남는다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('rep');
var btns = Array.prototype.slice.call(root.querySelectorAll('.rep__btn'));
var isDemo = true;
var checkedIdx = 0;
// 계산 겹: 버튼마다 눌림 깊이(depth 0~1)를 감쇠 스프링으로 적분한다 — 켜지는 버튼은
// depth 1을 살짝 넘겼다가(오버슛) 정착하고, 나머지는 동시에 0으로 되돌아간다.
var depth = btns.map(function () { return 0; });
var vel = btns.map(function () { return 0; });
var target = btns.map(function (b, i) { return i === 0 ? 1 : 0; });
function setChecked(idx) {
checkedIdx = idx;
btns.forEach(function (b, i) {
var on = i === idx;
b.setAttribute('aria-checked', String(on));
b.setAttribute('tabindex', on ? '0' : '-1');
target[i] = on ? 1 : 0;
if (reduce) { depth[i] = target[i]; vel[i] = 0; }
});
}
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
btns.forEach(function (b, i) {
b.addEventListener('click', function () { human(); setChecked(i); b.focus({ preventScroll: true }); });
});
root.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
human();
var dir = e.key === 'ArrowRight' ? 1 : -1;
var next = (checkedIdx + dir + btns.length) % btns.length;
setChecked(next);
btns[next].focus({ preventScroll: true });
e.preventDefault();
});
function render(i) {
var d = depth[i];
var overshootLift = Math.max(0, -(d - 1)) * -2; // 오버슛 구간(d>1)에서 살짝 더 눌리는 표현
var y = d * 3 + (d > 1 ? (d - 1) * 4 : 0);
var blur = 14 - d * 8;
var offY = 8 - d * 6;
btns[i].style.transform = 'translateY(' + y.toFixed(2) + 'px)';
btns[i].style.boxShadow = '0 ' + Math.max(1, offY).toFixed(1) + 'px ' + Math.max(4, blur).toFixed(1) + 'px rgba(23,20,26,.34)';
btns[i].classList.toggle('rep__btn--on', d > 0.5);
}
var cycleStart = 0, lastIdx = -1;
function loop(ts) {
if (!cycleStart) cycleStart = ts;
if (isDemo && !reduce) {
var idx = Math.floor((ts - cycleStart) / CYCLE_MS) % btns.length;
if (idx !== lastIdx) { lastIdx = idx; setChecked(idx); }
}
for (var i = 0; i < btns.length; i++) {
var acc = STIFF * (target[i] - depth[i]) - DAMP * vel[i];
vel[i] += acc * (1 / 60);
depth[i] += vel[i] * (1 / 60);
render(i);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// RadioExclusivePress — vanilla 판(run/369/demo/04-btn-group-radio-exclusive-press/index.html) 의 React 포트.
// 버튼마다 눌림 깊이(depth)를 감쇠 스프링으로 적분한다. 한 버튼이 켜지는 순간 나머지가 동시에
// 0으로 되돌아가고, 켜지는 버튼은 depth 1을 살짝 넘겼다가 정착한다(오버슛).
import { useEffect, useRef } from "react";
import styles from "./RadioExclusivePress.module.scss";
type Box<T> = { current: T };
const LABELS = ["목록형", "격자형", "보드형"];
export default function RadioExclusivePress() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const btnRefs = useRef([]) as unknown as Box<HTMLButtonElement[]>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const CYCLE_MS = 950;
const STIFF = 320; // 계산 겹 상수: 눌림 깊이 스프링 강성
const DAMP = 14; // 계산 겹 상수: 감쇠
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const btns = btnRefs.current;
if (!root || btns.length === 0) return;
let isDemo = true;
let checkedIdx = 0;
const depth = btns.map(() => 0);
const vel = btns.map(() => 0);
const target = btns.map((_, i) => (i === 0 ? 1 : 0));
function setChecked(idx: number) {
checkedIdx = idx;
btns.forEach((b, i) => {
const on = i === idx;
b.setAttribute("aria-checked", String(on));
b.setAttribute("tabindex", on ? "0" : "-1");
target[i] = on ? 1 : 0;
if (reduce) { depth[i] = target[i]; vel[i] = 0; }
});
}
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
const clicks: (() => void)[] = [];
btns.forEach((b, i) => {
const fn = () => { human(); setChecked(i); b.focus({ preventScroll: true }); };
b.addEventListener("click", fn);
clicks.push(fn);
});
function onKeydown(e: KeyboardEvent) {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
human();
const dir = e.key === "ArrowRight" ? 1 : -1;
const next = (checkedIdx + dir + btns.length) % btns.length;
setChecked(next);
btns[next].focus({ preventScroll: true });
e.preventDefault();
}
root.addEventListener("keydown", onKeydown);
function render(i: number) {
const d = depth[i];
const y = d * 3 + (d > 1 ? (d - 1) * 4 : 0);
const blur = 14 - d * 8;
const offY = 8 - d * 6;
btns[i].style.transform = "translateY(" + y.toFixed(2) + "px)";
btns[i].style.boxShadow = "0 " + Math.max(1, offY).toFixed(1) + "px " + Math.max(4, blur).toFixed(1) + "px rgba(23,20,26,.34)";
btns[i].classList.toggle(styles.btnOn, d > 0.5);
}
let cycleStart = 0, lastIdx = -1;
function loop(ts: number) {
if (!cycleStart) cycleStart = ts;
if (isDemo && !reduce) {
const idx = Math.floor((ts - cycleStart) / CYCLE_MS) % btns.length;
if (idx !== lastIdx) { lastIdx = idx; setChecked(idx); }
}
for (let i = 0; i < btns.length; i++) {
const acc = STIFF * (target[i] - depth[i]) - DAMP * vel[i];
vel[i] += acc * (1 / 60);
depth[i] += vel[i] * (1 / 60);
render(i);
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
btns.forEach((b, i) => b.removeEventListener("click", clicks[i]));
root.removeEventListener("keydown", onKeydown);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.rep + " " + styles.isDemo} ref={rootRef} role="radiogroup" aria-label="보기 방식">
{LABELS.map((label, i) => (
<button
key={label}
className={styles.btn}
role="radio"
aria-checked={i === 0}
tabIndex={i === 0 ? 0 : -1}
ref={(el: HTMLButtonElement | null) => { if (el) btnRefs.current[i] = el; }}
>
{label}
</button>
))}
</div>
</main>
);
}04 · 배타적 눌림 상태 — 한 버튼의 눌림 값이 켜지면 나머지 버튼의 눌림 값이 그 즉시 함께 꺼지는 방식 — 측정 누적면적 9.53% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05넘침 접힘 더보기 — 그룹 너비 값이 좁아지면 넘치는 버튼들이 하나의 더보기 값 안으로 접히는 방식
그룹 너비 값과 버튼 너비의 합을 매 프레임 비교해, 합이 그룹보다 OVERFLOW_BUFFER 24px 이상 넘치면 뒤쪽 버튼부터 감춰 더보기 값 하나로 접는 항목입니다. 화면이 아무리 좁아져도 최소 한 개의 그룹 버튼은 항상 남습니다.
if (sumAll > avail) {
var sum = 0;
cutoff = 0;
for (var i = 0; i < widths.length; i++) {
var add = widths[i] + (i > 0 ? gap : 0);
if (sum + add + moreWidth > avail) break;
sum += add;
cutoff = i + 1;
}
cutoff = Math.max(1, cutoff);
}
btns.forEach(function (b, i) { b.hidden = i >= cutoff; });
var overflowCount = widths.length - cutoff;
more.hidden = overflowCount === 0;
more.textContent = '더보기 ▾ (' + overflowCount + ')';

<main class="stage">
<div class="ovm is-demo" id="ovm">
<div class="ovm__resizer" id="ovmBox" style="width: 260px;">
<div class="ovm__track" id="ovmTrack" role="toolbar" aria-label="서식 도구"></div>
<button class="ovm__more" id="ovmMore" aria-haspopup="true" aria-expanded="false" hidden>더보기 ▾</button>
<ul class="ovm__menu" id="ovmMenu" hidden></ul>
</div>
<span class="ovm__handle" id="ovmHandle" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 이 버튼마다 실측 폭을 한 번 캐시하고, 컨테이너 폭(실측)에서 OVERFLOW_BUFFER 를
// 뺀 여유값과 비교해 몇 개가 들어가는지 부분합으로 계산한다 — 추측 px 가 아니라 실측 합산이다.
// 컨테이너 폭 자체가 이 항목의 "값" 이라 여기서만 예외로 width 를 직접 바꾼다(다른 항목은 transform 전용).
@include stage($stage-paper);
.ovm {
display: flex;
align-items: center;
gap: $sp-2;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.ovm__resizer {
position: relative;
box-sizing: border-box;
overflow: hidden;
flex: 0 0 auto;
border-radius: $r-control;
background: rgba($subject-ink, .04);
padding: $sp-2;
display: flex;
align-items: center;
gap: $sp-2;
}
.ovm__track {
display: flex;
gap: $sp-2;
overflow: hidden;
flex: 0 0 auto;
}
.ovm__btn,
.ovm__more,
.ovm__menuBtn {
appearance: none;
border: 1px solid rgba($subject-ink, .18);
border-radius: $r-sm;
background: $subject-cream;
color: $subject-ink;
font-size: 12px;
font-weight: 600;
padding: $sp-1 $sp-3;
white-space: nowrap;
cursor: pointer;
flex: 0 0 auto;
}
.ovm__more {
background: $subject-blue-deep;
border-color: $subject-blue-deep;
color: $subject-cream;
}
.ovm__menu {
position: absolute;
top: 100%;
right: $sp-2;
margin: 4px 0 0;
padding: $sp-1;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
background: $subject-cream;
border-radius: $r-sm;
box-shadow: $shadow-raised;
z-index: 2;
}
.ovm__handle {
flex: 0 0 auto;
width: 6px;
height: 40px;
border-radius: $r-xs;
background: rgba($subject-ink, .22);
cursor: ew-resize;
touch-action: none;
}
@media (max-width: 340px) {
.ovm__btn, .ovm__more, .ovm__menuBtn { font-size: 11px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.ovm__resizer { 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);
}
.ovm {
display: flex;
align-items: center;
gap: 8px;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.ovm__resizer {
position: relative;
box-sizing: border-box;
overflow: hidden;
flex: 0 0 auto;
border-radius: 12px;
background: rgba(23, 20, 26, 0.04);
padding: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.ovm__track {
display: flex;
gap: 8px;
overflow: hidden;
flex: 0 0 auto;
}
.ovm__btn,
.ovm__more,
.ovm__menuBtn {
appearance: none;
border: 1px solid rgba(23, 20, 26, 0.18);
border-radius: 8px;
background: #fff7e6;
color: #17141a;
font-size: 12px;
font-weight: 600;
padding: 4px 12px;
white-space: nowrap;
cursor: pointer;
flex: 0 0 auto;
}
.ovm__more {
background: #0a4fe8;
border-color: #0a4fe8;
color: #fff7e6;
}
.ovm__menu {
position: absolute;
top: 100%;
right: 8px;
margin: 4px 0 0;
padding: 4px;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
background: #fff7e6;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
z-index: 2;
}
.ovm__handle {
flex: 0 0 auto;
width: 6px;
height: 40px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.22);
cursor: ew-resize;
touch-action: none;
}
@media (max-width: 340px) {
.ovm__btn, .ovm__more, .ovm__menuBtn {
font-size: 11px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ovm__resizer {
transition: none;
}
}(function () {
var LABELS = ['굵게', '기울임', '밑줄', '취소선', '정렬', '색상', '링크'];
var OVERFLOW_BUFFER = 24; // 계산 겹 상수: 더보기 버튼 폭까지 계산한 뒤 남기는 안전 여유(px)
var MIN_W = 150, MAX_W = 320;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('ovm');
var box = document.getElementById('ovmBox');
var track = document.getElementById('ovmTrack');
var more = document.getElementById('ovmMore');
var menu = document.getElementById('ovmMenu');
var handle = document.getElementById('ovmHandle');
var isDemo = true;
var btns = LABELS.map(function (label, i) {
var b = document.createElement('button');
b.className = 'ovm__btn';
b.type = 'button';
b.textContent = label;
b.dataset.i = String(i);
return b;
});
// 계산 겹: 각 버튼의 실제 폭을 한 번 실측해 캐시한다(추측 px 를 쓰지 않는다) — track 에 전부 넣고 잰 뒤
// 넘치는 만큼만 더보기로 옮긴다.
btns.forEach(function (b) { track.appendChild(b); });
var widths = btns.map(function (b) { return b.getBoundingClientRect().width; });
var gap = 8;
var moreWidth = 0;
function recompute() {
var avail = box.getBoundingClientRect().width - OVERFLOW_BUFFER;
moreWidth = moreWidth || (function () { more.hidden = false; var w = more.getBoundingClientRect().width; more.hidden = true; return w; })();
var sumAll = widths.reduce(function (s, w) { return s + w; }, 0) + gap * (widths.length - 1);
var cutoff = widths.length;
if (sumAll > avail) {
var sum = 0;
cutoff = 0;
for (var i = 0; i < widths.length; i++) {
var add = widths[i] + (i > 0 ? gap : 0);
if (sum + add + moreWidth > avail) break;
sum += add;
cutoff = i + 1;
}
cutoff = Math.max(1, cutoff);
}
btns.forEach(function (b, i) { b.hidden = i >= cutoff; });
var overflowCount = widths.length - cutoff;
more.hidden = overflowCount === 0;
more.textContent = '더보기 ▾ (' + overflowCount + ')';
menu.innerHTML = '';
for (var j = cutoff; j < widths.length; j++) {
var li = document.createElement('li');
var mb = document.createElement('button');
mb.type = 'button';
mb.className = 'ovm__menuBtn';
mb.textContent = LABELS[j];
li.appendChild(mb);
menu.appendChild(li);
}
}
more.addEventListener('click', function () {
human();
var open = menu.hidden;
menu.hidden = !open;
more.setAttribute('aria-expanded', String(open));
});
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
var dragging = false, dragStartX = 0, dragStartW = 0;
handle.addEventListener('pointerdown', function (e) {
human();
dragging = true;
dragStartX = e.clientX;
dragStartW = box.getBoundingClientRect().width;
handle.setPointerCapture(e.pointerId);
});
handle.addEventListener('pointermove', function (e) {
if (!dragging) return;
var w = Math.min(MAX_W, Math.max(MIN_W, dragStartW + (e.clientX - dragStartX)));
box.style.width = w + 'px';
recompute();
});
handle.addEventListener('pointerup', function () { dragging = false; });
var ro = new ResizeObserver(function () { recompute(); });
ro.observe(box);
var start = 0;
function demoWidth(ts) {
var t = (ts - start) / 1900;
var wave = (Math.sin(t * Math.PI * 2) + 1) / 2; // 0~1
return MIN_W + wave * (MAX_W - MIN_W);
}
function loop(ts) {
if (!start) start = ts;
if (isDemo && !reduce) {
box.style.width = demoWidth(ts).toFixed(1) + 'px';
recompute();
}
requestAnimationFrame(loop);
}
recompute();
if (reduce) { box.style.width = MAX_W + 'px'; recompute(); }
requestAnimationFrame(loop);
})();import { useEffect, useRef } from 'react';
import styles from './OverflowCollapseMore.module.scss';
type Box<T> = { current: T };
const LABELS = ['굵게', '기울임', '밑줄', '취소선', '정렬', '색상', '링크'];
const OVERFLOW_BUFFER = 24; // 계산 겹 상수: 더보기 버튼 폭까지 계산한 뒤 남기는 안전 여유(px)
const MIN_W = 150;
const MAX_W = 320;
const GAP = 8;
export default function OverflowCollapseMore() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const boxRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackRef = useRef(null) as unknown as Box<HTMLDivElement>;
const moreRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const menuRef = useRef(null) as unknown as Box<HTMLUListElement>;
const handleRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const btnRefs = useRef([]) as unknown as Box<HTMLButtonElement[]>;
useEffect(() => {
const root = rootRef.current;
const box = boxRef.current;
const track = trackRef.current;
const more = moreRef.current;
const menu = menuRef.current;
const handle = handleRef.current;
const btns = btnRefs.current;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let isDemo = true;
// 계산 겹: 각 버튼의 실제 폭을 한 번 실측해 캐시한다(추측 px 를 쓰지 않는다) — 넘치는 만큼만 더보기로 옮긴다.
const widths = btns.map((b) => b.getBoundingClientRect().width);
let moreWidth = 0;
function recompute() {
const avail = box.getBoundingClientRect().width - OVERFLOW_BUFFER;
if (!moreWidth) {
more.hidden = false;
moreWidth = more.getBoundingClientRect().width;
more.hidden = true;
}
const sumAll = widths.reduce((s, w) => s + w, 0) + GAP * (widths.length - 1);
let cutoff = widths.length;
if (sumAll > avail) {
let sum = 0;
cutoff = 0;
for (let i = 0; i < widths.length; i++) {
const add = widths[i] + (i > 0 ? GAP : 0);
if (sum + add + moreWidth > avail) break;
sum += add;
cutoff = i + 1;
}
cutoff = Math.max(1, cutoff);
}
btns.forEach((b, i) => { b.hidden = i >= cutoff; });
const overflowCount = widths.length - cutoff;
more.hidden = overflowCount === 0;
more.textContent = `더보기 ▾ (${overflowCount})`;
menu.innerHTML = '';
for (let j = cutoff; j < widths.length; j++) {
const li = document.createElement('li');
const mb = document.createElement('button');
mb.type = 'button';
mb.className = styles.menuBtn;
mb.textContent = LABELS[j];
li.appendChild(mb);
menu.appendChild(li);
}
}
function human() {
if (isDemo) { root.classList.remove(styles.isDemo); isDemo = false; }
}
function onMoreClick() {
human();
const open = menu.hidden;
menu.hidden = !open;
more.setAttribute('aria-expanded', String(open));
}
more.addEventListener('click', onMoreClick);
let dragging = false;
let dragStartX = 0;
let dragStartW = 0;
function onPointerDown(e: PointerEvent) {
human();
dragging = true;
dragStartX = e.clientX;
dragStartW = box.getBoundingClientRect().width;
handle.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const w = Math.min(MAX_W, Math.max(MIN_W, dragStartW + (e.clientX - dragStartX)));
box.style.width = w + 'px';
recompute();
}
function onPointerUp() { dragging = false; }
handle.addEventListener('pointerdown', onPointerDown);
handle.addEventListener('pointermove', onPointerMove);
handle.addEventListener('pointerup', onPointerUp);
const ro = new ResizeObserver(() => recompute());
ro.observe(box);
let start = 0;
function demoWidth(ts: number) {
const t = (ts - start) / 1900;
const wave = (Math.sin(t * Math.PI * 2) + 1) / 2; // 0~1
return MIN_W + wave * (MAX_W - MIN_W);
}
let raf = 0;
function loop(ts: number) {
if (!start) start = ts;
if (isDemo && !reduce) {
box.style.width = demoWidth(ts).toFixed(1) + 'px';
recompute();
}
raf = requestAnimationFrame(loop);
}
recompute();
if (reduce) { box.style.width = MAX_W + 'px'; recompute(); }
raf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
more.removeEventListener('click', onMoreClick);
handle.removeEventListener('pointerdown', onPointerDown);
handle.removeEventListener('pointermove', onPointerMove);
handle.removeEventListener('pointerup', onPointerUp);
};
}, []);
return (
<div className={`${styles.ovm} ${styles.isDemo}`} ref={(el: HTMLDivElement | null) => { if (el) rootRef.current = el; }}>
<div
className={styles.resizer}
style={{ width: '260px' }}
ref={(el: HTMLDivElement | null) => { if (el) boxRef.current = el; }}
>
<div
className={styles.track}
role="toolbar"
aria-label="서식 도구"
ref={(el: HTMLDivElement | null) => { if (el) trackRef.current = el; }}
>
{LABELS.map((label, i) => (
<button
key={label}
type="button"
className={styles.btn}
ref={(el: HTMLButtonElement | null) => { if (el) btnRefs.current[i] = el; }}
>
{label}
</button>
))}
</div>
<button
type="button"
className={styles.more}
aria-haspopup="true"
aria-expanded="false"
hidden
ref={(el: HTMLButtonElement | null) => { if (el) moreRef.current = el; }}
>
더보기 ▾
</button>
<ul
className={styles.menu}
hidden
ref={(el: HTMLUListElement | null) => { if (el) menuRef.current = el; }}
/>
</div>
<span
className={styles.handle}
aria-hidden="true"
ref={(el: HTMLSpanElement | null) => { if (el) handleRef.current = el; }}
/>
</div>
);
}05 · 넘침 접힘 더보기 — 그룹 너비 값이 좁아지면 넘치는 버튼들이 하나의 더보기 값 안으로 접히는 방식 — 측정 누적면적 6.63% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06가로세로 방향 전환 — 그룹의 방향 값이 바뀌면 버튼들의 위치 값이 실측 좌표 사이를 이동해 줄이 다시 짜이는 방식
그룹의 방향 값이 가로에서 세로로 바뀌는 순간 각 버튼의 이전 좌표를 getBoundingClientRect 로 미리 재 두고, 강제 리플로우 뒤 새 좌표까지 FLIP 기법으로 480ms 동안 되돌리는 항목입니다. 실제 레이아웃은 바뀐 직후 이미 새 자리에 있고, 눈에 보이는 이동은 그 차이를 거꾸로 재생한 결과입니다.
btns.forEach(function (b, i) {
var dx = first[i].left - last[i].left;
var dy = first[i].top - last[i].top;
if (!dx && !dy) return;
b.style.transition = 'none';
b.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
// 강제 리플로우로 초기 transform 을 화면에 반영시킨 뒤에만 되돌리기 transition 이 재생된다.
b.getBoundingClientRect();
requestAnimationFrame(function () {
b.style.transition = 'transform ' + FLIP_MS + 'ms cubic-bezier(.22,.9,.32,1)';
b.style.transform = 'translate(0,0)';
});
});

<main class="stage">
<div class="vhm" id="vhm">
<div class="vhm__group" id="vhmGroup" role="group" aria-label="편집 도구"></div>
<button class="vhm__toggle" id="vhmToggle" type="button" aria-pressed="false">방향 전환 ⤢</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 의 flip() 이 방향 전환 전(First)·후(Last) 각 버튼의 실측 좌표 차를 구해
// transform 으로 상쇄한 뒤 되돌리는 FLIP 기법 — 레이아웃 속성 자체는 전환에만 한 번 바뀌고,
// 눈에 보이는 이동은 항상 transform 으로만 그린다.
@include stage($stage-yellow);
.vhm {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
max-width: 100%;
}
.vhm__group {
display: flex;
flex-direction: row;
gap: $sp-2;
max-width: 100%;
overflow-x: auto;
}
.vhm--vertical .vhm__group {
flex-direction: column;
overflow-x: visible;
}
.vhm__btn {
appearance: none;
border: none;
border-radius: $r-sm;
background: $subject-cream;
color: $subject-ink;
font-size: 13px;
font-weight: 700;
padding: $sp-2 $sp-4;
cursor: pointer;
white-space: nowrap;
flex: 0 0 auto;
}
.vhm__toggle {
appearance: none;
border: 1px solid rgba($subject-ink, .24);
border-radius: $r-pill;
background: transparent;
color: $subject-ink;
font-size: 12px;
font-weight: 600;
padding: $sp-1 $sp-3;
cursor: pointer;
}
@media (max-width: 340px) {
.vhm__btn { font-size: 12px; padding: $sp-2 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.vhm__btn { transition: none !important; }
}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);
}
.vhm {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
max-width: 100%;
}
.vhm__group {
display: flex;
flex-direction: row;
gap: 8px;
max-width: 100%;
overflow-x: auto;
}
.vhm--vertical .vhm__group {
flex-direction: column;
overflow-x: visible;
}
.vhm__btn {
appearance: none;
border: none;
border-radius: 8px;
background: #fff7e6;
color: #17141a;
font-size: 13px;
font-weight: 700;
padding: 8px 16px;
cursor: pointer;
white-space: nowrap;
flex: 0 0 auto;
}
.vhm__toggle {
appearance: none;
border: 1px solid rgba(23, 20, 26, 0.24);
border-radius: 999px;
background: transparent;
color: #17141a;
font-size: 12px;
font-weight: 600;
padding: 4px 12px;
cursor: pointer;
}
@media (max-width: 340px) {
.vhm__btn {
font-size: 12px;
padding: 8px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.vhm__btn {
transition: none !important;
}
}(function () {
var LABELS = ['굵게', '기울임', '밑줄', '정렬'];
var CYCLE_MS = 700; // 데모 자동 전환 주기 — 측정 창(2s) 안에 전환이 여러 번 들어오게 짧게 잡음
var FLIP_MS = 480;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('vhm');
var group = document.getElementById('vhmGroup');
var toggle = document.getElementById('vhmToggle');
var isDemo = true;
var vertical = false;
var btns = LABELS.map(function (label) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'vhm__btn';
b.textContent = label;
group.appendChild(b);
return b;
});
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
// 계산 겹: FLIP — 전환 전(First) 각 버튼의 실측 좌표를 재고, 전환 후(Last) 좌표와의 차이(Invert)를
// transform 으로 순간 상쇄한 뒤, 다음 프레임에 transform 을 0 으로 되돌려(Play) 자연스럽게 이어붙인다.
function flip(apply) {
var first = btns.map(function (b) { return b.getBoundingClientRect(); });
apply();
var last = btns.map(function (b) { return b.getBoundingClientRect(); });
if (reduce) return; // 계산은 유지하되 즉시 반영, 애니메이션 생략
btns.forEach(function (b, i) {
var dx = first[i].left - last[i].left;
var dy = first[i].top - last[i].top;
if (!dx && !dy) return;
b.style.transition = 'none';
b.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
// 강제 리플로우로 초기 transform 을 화면에 반영시킨 뒤에만 되돌리기 transition 이 재생된다.
b.getBoundingClientRect();
requestAnimationFrame(function () {
b.style.transition = 'transform ' + FLIP_MS + 'ms cubic-bezier(.22,.9,.32,1)';
b.style.transform = 'translate(0,0)';
});
});
}
function setOrientation(v) {
flip(function () {
vertical = v;
root.classList.toggle('vhm--vertical', vertical);
toggle.setAttribute('aria-pressed', String(vertical));
});
}
toggle.addEventListener('click', function () {
human();
setOrientation(!vertical);
});
var start = 0;
function loop(ts) {
if (!start) start = ts;
if (isDemo && !reduce) {
var phaseVertical = (ts - start) % (CYCLE_MS * 2) >= CYCLE_MS;
if (phaseVertical !== vertical) setOrientation(phaseVertical);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from 'react';
import styles from './OrientMorph.module.scss';
type Box<T> = { current: T };
const LABELS = ['굵게', '기울임', '밑줄', '정렬'];
const CYCLE_MS = 700; // 데모 자동 전환 주기 — 측정 창(2s) 안에 전환이 여러 번 들어오게 짧게 잡음
const FLIP_MS = 480;
export default function OrientMorph() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const groupRef = useRef(null) as unknown as Box<HTMLDivElement>;
const toggleRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const btnRefs = useRef([]) as unknown as Box<HTMLButtonElement[]>;
useEffect(() => {
const root = rootRef.current;
const toggle = toggleRef.current;
const btns = btnRefs.current;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let isDemo = true;
let vertical = false;
function human() {
if (isDemo) { root.classList.remove(styles.isDemo); isDemo = false; }
}
// 계산 겹: FLIP — 전환 전(First) 각 버튼의 실측 좌표를 재고, 전환 후(Last) 좌표와의 차이(Invert)를
// transform 으로 순간 상쇄한 뒤, 다음 프레임에 transform 을 0 으로 되돌려(Play) 자연스럽게 이어붙인다.
function flip(apply: () => void) {
const first = btns.map((b) => b.getBoundingClientRect());
apply();
const last = btns.map((b) => b.getBoundingClientRect());
if (reduce) return; // 계산은 유지하되 즉시 반영, 애니메이션 생략
btns.forEach((b, i) => {
const dx = first[i].left - last[i].left;
const dy = first[i].top - last[i].top;
if (!dx && !dy) return;
b.style.transition = 'none';
b.style.transform = `translate(${dx}px,${dy}px)`;
b.getBoundingClientRect();
requestAnimationFrame(() => {
b.style.transition = `transform ${FLIP_MS}ms cubic-bezier(.22,.9,.32,1)`;
b.style.transform = 'translate(0,0)';
});
});
}
function setOrientation(v: boolean) {
flip(() => {
vertical = v;
root.classList.toggle(styles.vertical, vertical);
toggle.setAttribute('aria-pressed', String(vertical));
});
}
function onToggleClick() {
human();
setOrientation(!vertical);
}
toggle.addEventListener('click', onToggleClick);
let start = 0;
let raf = 0;
function loop(ts: number) {
if (!start) start = ts;
if (isDemo && !reduce) {
const phaseVertical = (ts - start) % (CYCLE_MS * 2) >= CYCLE_MS;
if (phaseVertical !== vertical) setOrientation(phaseVertical);
}
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(raf);
toggle.removeEventListener('click', onToggleClick);
};
}, []);
return (
<div className={`${styles.vhm} ${styles.isDemo}`} ref={(el: HTMLDivElement | null) => { if (el) rootRef.current = el; }}>
<div
className={styles.group}
role="group"
aria-label="편집 도구"
ref={(el: HTMLDivElement | null) => { if (el) groupRef.current = el; }}
>
{LABELS.map((label, i) => (
<button
key={label}
type="button"
className={styles.btn}
ref={(el: HTMLButtonElement | null) => { if (el) btnRefs.current[i] = el; }}
>
{label}
</button>
))}
</div>
<button
type="button"
className={styles.toggle}
aria-pressed="false"
ref={(el: HTMLButtonElement | null) => { if (el) toggleRef.current = el; }}
>
방향 전환 ⤢
</button>
</div>
);
}06 · 가로세로 방향 전환 — 그룹의 방향 값이 바뀌면 버튼들의 위치 값이 실측 좌표 사이를 이동해 줄이 다시 짜이는 방식 — 측정 누적면적 15.29% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07아이콘 라벨 조합 노출 — 컨테이너 너비 값이 넓어지면 아이콘 버튼의 글자 라벨 너비 값이 0에서 늘어나며 드러나는 방식
컨테이너 너비 값이 기준을 넘으면 아이콘 옆 라벨의 실제 글자 너비 값을 재서, 0에서 그 값까지 easeOutCubic 으로 늘리며 clip-path 로 드러내는 항목입니다. 포인터가 버튼 위에 머물거나 키보드 초점이 오면 같은 계산이 시작되고, 벗어나면 반대로 줄어듭니다.
function render(cell) {
var pct = (1 - cell.progress) * 100;
cell.reveal.style.clipPath = 'inset(0 ' + pct.toFixed(2) + '% 0 0)';
cell.label.style.opacity = String(cell.progress);
}
function animate(cell, to) {
cell.target = to;
var from = cell.progress;
if (reduce) { cell.progress = to; render(cell); return; }
if (cell.raf) cancelAnimationFrame(cell.raf);
var dur = 400, t0 = 0;
function step(ts) {
if (!t0) t0 = ts;
var t = Math.min(1, (ts - t0) / dur);
cell.progress = from + (to - from) * easeOutCubic(t);
render(cell);
if (t < 1) cell.raf = requestAnimationFrame(step);
else cell.raf = 0;
}
cell.raf = requestAnimationFrame(step);
}
<main class="stage">
<div class="clr is-demo" id="clr" role="group" aria-label="빠른 작업"></div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 이 라벨의 실측 텍스트 폭(fullW)을 한 번 재 reveal 트랙 폭에 고정하고,
// easeOutCubic 로 보간한 진행률을 clip-path 로 매 프레임 그린다 — width 는 다시 건드리지 않는다.
@include stage($stage-orange);
.clr {
display: flex;
flex-wrap: wrap;
gap: $sp-2;
max-width: 100%;
}
.clr__btn {
display: flex;
align-items: center;
gap: $sp-1;
appearance: none;
border: none;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
padding: $sp-2 $sp-3;
cursor: pointer;
flex: 0 0 auto;
&:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
}
.clr__icon {
font-size: 15px;
line-height: 1;
width: 16px;
text-align: center;
}
.clr__reveal {
display: inline-block;
overflow: hidden;
width: 0;
will-change: clip-path;
}
.clr__label {
display: inline-block;
white-space: nowrap;
font-size: 12px;
font-weight: 600;
opacity: 0;
will-change: opacity;
}
@media (max-width: 340px) {
.clr__btn { padding: $sp-1 $sp-2; }
.clr__label { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.clr__reveal, .clr__label { 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);
}
.clr {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-width: 100%;
}
.clr__btn {
display: flex;
align-items: center;
gap: 4px;
appearance: none;
border: none;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
padding: 8px 12px;
cursor: pointer;
flex: 0 0 auto;
}
.clr__btn:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.clr__icon {
font-size: 15px;
line-height: 1;
width: 16px;
text-align: center;
}
.clr__reveal {
display: inline-block;
overflow: hidden;
width: 0;
will-change: clip-path;
}
.clr__label {
display: inline-block;
white-space: nowrap;
font-size: 12px;
font-weight: 600;
opacity: 0;
will-change: opacity;
}
@media (max-width: 340px) {
.clr__btn {
padding: 4px 8px;
}
.clr__label {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.clr__reveal, .clr__label {
transition: none;
}
}(function () {
var ITEMS = [
{ icon: '+', label: '새로 만들기' },
{ icon: '⎘', label: '복제' },
{ icon: '↧', label: '내보내기' },
{ icon: '⌘', label: '단축키' }
];
var CYCLE_MS = 900; // 데모 자동 순회 간격 — 측정 창(2s) 안에 전환이 여러 번 들어오게 짧게 잡음
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('clr');
var isDemo = true;
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
var cells = ITEMS.map(function (item) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'clr__btn';
var icon = document.createElement('span');
icon.className = 'clr__icon';
icon.textContent = item.icon;
icon.setAttribute('aria-hidden', 'true');
var reveal = document.createElement('span');
reveal.className = 'clr__reveal';
var label = document.createElement('span');
label.className = 'clr__label';
label.textContent = item.label;
reveal.appendChild(label);
btn.appendChild(icon);
btn.appendChild(reveal);
root.appendChild(btn);
return { btn: btn, reveal: reveal, label: label, progress: 0, target: 0, raf: 0, fullW: 0 };
});
// 계산 겹: 각 라벨의 실측 텍스트 폭(fullW)을 한 번 재고, reveal 트랙 폭을 그 값에 고정한 뒤
// clip-path 를 0→fullW 사이에서 이징으로 보간해 "덮개를 걷어내는" 진행률로 그린다 — width 자체는
// 다시 애니메이션하지 않는다(레이아웃 트리거는 최초 1회 뿐).
cells.forEach(function (cell) {
cell.fullW = cell.label.getBoundingClientRect().width;
cell.reveal.style.width = cell.fullW + 'px';
});
function render(cell) {
var pct = (1 - cell.progress) * 100;
cell.reveal.style.clipPath = 'inset(0 ' + pct.toFixed(2) + '% 0 0)';
cell.label.style.opacity = String(cell.progress);
}
function animate(cell, to) {
cell.target = to;
var from = cell.progress;
if (reduce) { cell.progress = to; render(cell); return; }
if (cell.raf) cancelAnimationFrame(cell.raf);
var dur = 400, t0 = 0;
function step(ts) {
if (!t0) t0 = ts;
var t = Math.min(1, (ts - t0) / dur);
cell.progress = from + (to - from) * easeOutCubic(t);
render(cell);
if (t < 1) cell.raf = requestAnimationFrame(step);
else cell.raf = 0;
}
cell.raf = requestAnimationFrame(step);
}
cells.forEach(function (cell) {
render(cell);
cell.btn.addEventListener('pointerenter', function () { human(); animate(cell, 1); });
cell.btn.addEventListener('pointerleave', function () { if (!isDemo) animate(cell, 0); });
cell.btn.addEventListener('focus', function () { human(); animate(cell, 1); });
cell.btn.addEventListener('blur', function () { if (!isDemo) animate(cell, 0); });
});
var active = -1;
var start = 0;
function loop(ts) {
if (!start) start = ts;
if (isDemo && !reduce) {
var idx = Math.floor((ts - start) / CYCLE_MS) % cells.length;
if (idx !== active) {
if (active >= 0) animate(cells[active], 0);
active = idx;
animate(cells[active], 1);
}
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from 'react';
import styles from './IconLabelComboReveal.module.scss';
type Box<T> = { current: T };
type Item = { icon: string; label: string };
const ITEMS: Item[] = [
{ icon: '+', label: '새로 만들기' },
{ icon: '⎘', label: '복제' },
{ icon: '↧', label: '내보내기' },
{ icon: '⌘', label: '단축키' }
];
const CYCLE_MS = 900; // 데모 자동 순회 간격 — 측정 창(2s) 안에 전환이 여러 번 들어오게 짧게 잡음
type Cell = {
reveal: HTMLSpanElement;
label: HTMLSpanElement;
progress: number;
raf: number;
fullW: number;
};
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
export default function IconLabelComboReveal() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const revealRefs = useRef([]) as unknown as Box<HTMLSpanElement[]>;
const labelRefs = useRef([]) as unknown as Box<HTMLSpanElement[]>;
const btnRefs = useRef([]) as unknown as Box<HTMLButtonElement[]>;
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; }
}
// 계산 겹: 각 라벨의 실측 텍스트 폭(fullW)을 한 번 재고, reveal 트랙 폭을 그 값에 고정한 뒤
// clip-path 를 0→fullW 사이에서 이징으로 보간해 진행률을 그린다 — width 는 다시 애니메이션하지 않는다.
const cells: Cell[] = ITEMS.map((_, i) => {
const reveal = revealRefs.current[i];
const label = labelRefs.current[i];
const fullW = label.getBoundingClientRect().width;
reveal.style.width = fullW + 'px';
return { reveal, label, progress: 0, raf: 0, fullW };
});
function render(cell: Cell) {
const pct = (1 - cell.progress) * 100;
cell.reveal.style.clipPath = `inset(0 ${pct.toFixed(2)}% 0 0)`;
cell.label.style.opacity = String(cell.progress);
}
function animate(cell: Cell, to: number) {
const from = cell.progress;
if (reduce) { cell.progress = to; render(cell); return; }
if (cell.raf) cancelAnimationFrame(cell.raf);
const dur = 400;
let t0 = 0;
function step(ts: number) {
if (!t0) t0 = ts;
const t = Math.min(1, (ts - t0) / dur);
cell.progress = from + (to - from) * easeOutCubic(t);
render(cell);
if (t < 1) cell.raf = requestAnimationFrame(step);
else cell.raf = 0;
}
cell.raf = requestAnimationFrame(step);
}
const cleanups: Array<() => void> = [];
cells.forEach((cell, i) => {
render(cell);
const btn = btnRefs.current[i];
const onEnter = () => { human(); animate(cell, 1); };
const onLeave = () => { if (!isDemo) animate(cell, 0); };
const onFocus = () => { human(); animate(cell, 1); };
const onBlur = () => { if (!isDemo) animate(cell, 0); };
btn.addEventListener('pointerenter', onEnter);
btn.addEventListener('pointerleave', onLeave);
btn.addEventListener('focus', onFocus);
btn.addEventListener('blur', onBlur);
cleanups.push(() => {
btn.removeEventListener('pointerenter', onEnter);
btn.removeEventListener('pointerleave', onLeave);
btn.removeEventListener('focus', onFocus);
btn.removeEventListener('blur', onBlur);
});
});
let active = -1;
let start = 0;
let raf = 0;
function loop(ts: number) {
if (!start) start = ts;
if (isDemo && !reduce) {
const idx = Math.floor((ts - start) / CYCLE_MS) % cells.length;
if (idx !== active) {
if (active >= 0) animate(cells[active], 0);
active = idx;
animate(cells[active], 1);
}
}
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(raf);
cells.forEach((cell) => { if (cell.raf) cancelAnimationFrame(cell.raf); });
cleanups.forEach((fn) => fn());
};
}, []);
return (
<div
className={`${styles.clr} ${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}
ref={(el: HTMLButtonElement | null) => { if (el) btnRefs.current[i] = el; }}
>
<span className={styles.icon} aria-hidden="true">{item.icon}</span>
<span
className={styles.reveal}
ref={(el: HTMLSpanElement | null) => { if (el) revealRefs.current[i] = el; }}
>
<span
className={styles.label}
ref={(el: HTMLSpanElement | null) => { if (el) labelRefs.current[i] = el; }}
>
{item.label}
</span>
</span>
</button>
))}
</div>
);
}07 · 아이콘 라벨 조합 노출 — 컨테이너 너비 값이 넓어지면 아이콘 버튼의 글자 라벨 너비 값이 0에서 늘어나며 드러나는 방식 — 측정 누적면적 0.602% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08키보드 방향키 포커스 슬라이드 — 방향키로 포커스 값이 옮겨질 때 포커스 테두리의 위치 값이 버튼 사이 실측 좌표를 따라 이동하는 방식
방향키를 누르면 다음 버튼의 실측 좌표를 구해 포커스 테두리의 위치·크기 값을 FOCUS_EASE easeInOut 으로 그 좌표까지 옮기는 항목입니다. role=radiogroup·role=radio 로 각 버튼의 aria-checked 값을 함께 세우고, 이동이 끝나면 focus({preventScroll:true}) 로 스크롤 없이 포커스를 맞춥니다.
function moveRing(toIdx) {
var to = measure(toIdx);
if (reduce || !from) { render(to); from = to; return; }
var start = from, dur = 260, t0 = 0;
var ease = EASES[FOCUS_EASE] || EASES.linear;
if (raf) cancelAnimationFrame(raf);
function step(ts) {
if (!t0) t0 = ts;
var t = Math.min(1, (ts - t0) / dur);
var e = ease(t);
render({
x: start.x + (to.x - start.x) * e,
y: start.y + (to.y - start.y) * e,
w: start.w + (to.w - start.w) * e,
h: start.h + (to.h - start.h) * e
});
if (t < 1) raf = requestAnimationFrame(step);
else { raf = 0; from = to; }
}
raf = requestAnimationFrame(step);
}

<main class="stage">
<div class="kfs is-demo" id="kfs">
<div class="kfs__group" id="kfsGroup" role="radiogroup" aria-label="보기 모드"></div>
<span class="kfs__ring" id="kfsRing" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 이 선택된 버튼의 실측 좌표(getBoundingClientRect, 그룹 기준 상대값)를 매번 재고
// FOCUS_EASE 로 진행률을 보간해 포커스 링을 translate·리사이즈한다 — % 나 고정폭이 아니라 실측이다.
@include stage($stage-ink);
.kfs {
position: relative;
display: inline-flex;
max-width: 100%;
}
.kfs__group {
position: relative;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
max-width: 100%;
}
.kfs__btn {
position: relative;
z-index: 1;
appearance: none;
border: none;
border-radius: $r-sm;
background: transparent;
color: $subject-cream;
font-size: 12px;
font-weight: 600;
padding: $sp-2 $sp-3;
cursor: pointer;
white-space: nowrap;
flex: 0 0 auto;
&:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
&[aria-checked="true"] { color: $stage-ink; }
}
.kfs__ring {
position: absolute;
top: 0;
left: 0;
z-index: 0;
border-radius: $r-sm;
background: $subject-blue-light;
will-change: transform, width, height;
}
@media (max-width: 340px) {
.kfs__btn { font-size: 11px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.kfs__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.kfs {
position: relative;
display: inline-flex;
max-width: 100%;
}
.kfs__group {
position: relative;
display: flex;
flex-wrap: wrap;
gap: 8px;
max-width: 100%;
}
.kfs__btn {
position: relative;
z-index: 1;
appearance: none;
border: none;
border-radius: 8px;
background: transparent;
color: #fff7e6;
font-size: 12px;
font-weight: 600;
padding: 8px 12px;
cursor: pointer;
white-space: nowrap;
flex: 0 0 auto;
}
.kfs__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.kfs__btn[aria-checked=true] {
color: #17141a;
}
.kfs__ring {
position: absolute;
top: 0;
left: 0;
z-index: 0;
border-radius: 8px;
background: #5b8cf8;
will-change: transform, width, height;
}
@media (max-width: 340px) {
.kfs__btn {
font-size: 11px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.kfs__ring {
transition: none;
}
}(function () {
var LABELS = ['그리드 보기', '리스트 보기', '타임라인 보기', '보드 보기'];
var CYCLE_MS = 500; // 데모 자동 순회 간격 — 측정 창(2s) 안에 전환이 여러 번 들어오게 짧게 잡음
var FOCUS_EASE = 'easeInOut'; // 변형: easeInOut → linear (등속)
var EASES = {
linear: function (t) { return t; },
easeInOut: function (t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
};
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('kfs');
var group = document.getElementById('kfsGroup');
var ring = document.getElementById('kfsRing');
var isDemo = true;
var current = 0;
var btns = LABELS.map(function (label, i) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'kfs__btn';
b.textContent = label;
b.setAttribute('role', 'radio');
b.setAttribute('aria-checked', i === 0 ? 'true' : 'false');
b.tabIndex = i === 0 ? 0 : -1;
group.appendChild(b);
return b;
});
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
// 계산 겹: 포커스 링을 목표 버튼의 실측 좌표(getBoundingClientRect, 그룹 기준 상대값)로 진행률 t 를
// FOCUS_EASE 로 보간해 translate·scale 한다 — % 나 고정폭이 아니라 매번 실측한다.
var raf = 0, from = null;
function measure(i) {
var r = btns[i].getBoundingClientRect();
var gr = group.getBoundingClientRect();
return { x: r.left - gr.left, y: r.top - gr.top, w: r.width, h: r.height };
}
function render(box) {
ring.style.transform = 'translate(' + box.x + 'px,' + box.y + 'px)';
ring.style.width = box.w + 'px';
ring.style.height = box.h + 'px';
}
function moveRing(toIdx) {
var to = measure(toIdx);
if (reduce || !from) { render(to); from = to; return; }
var start = from, dur = 260, t0 = 0;
var ease = EASES[FOCUS_EASE] || EASES.linear;
if (raf) cancelAnimationFrame(raf);
function step(ts) {
if (!t0) t0 = ts;
var t = Math.min(1, (ts - t0) / dur);
var e = ease(t);
render({
x: start.x + (to.x - start.x) * e,
y: start.y + (to.y - start.y) * e,
w: start.w + (to.w - start.w) * e,
h: start.h + (to.h - start.h) * e
});
if (t < 1) raf = requestAnimationFrame(step);
else { raf = 0; from = to; }
}
raf = requestAnimationFrame(step);
}
function select(i, focus) {
btns.forEach(function (b, j) {
b.setAttribute('aria-checked', String(j === i));
b.tabIndex = j === i ? 0 : -1;
});
current = i;
moveRing(i);
if (focus) btns[i].focus({ preventScroll: true });
}
btns.forEach(function (b, i) {
b.addEventListener('click', function () { human(); select(i, false); });
b.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
human();
e.preventDefault();
var dir = e.key === 'ArrowRight' ? 1 : -1;
select((current + dir + btns.length) % btns.length, true);
});
});
from = measure(0);
render(from);
var start0 = 0;
function loop(ts) {
if (!start0) start0 = ts;
if (isDemo && !reduce) {
var idx = Math.floor((ts - start0) / CYCLE_MS) % btns.length;
if (idx !== current) select(idx, false);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from 'react';
import styles from './KeyboardArrowFocusSlide.module.scss';
type Box<T> = { current: T };
type Rect = { x: number; y: number; w: number; h: number };
const LABELS = ['그리드 보기', '리스트 보기', '타임라인 보기', '보드 보기'];
const CYCLE_MS = 500; // 데모 자동 순회 간격 — 측정 창(2s) 안에 전환이 여러 번 들어오게 짧게 잡음
const FOCUS_EASE = 'easeInOut'; // 변형: easeInOut → linear (등속)
const EASES: Record<string, (t: number) => number> = {
linear: (t) => t,
easeInOut: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2)
};
export default function KeyboardArrowFocusSlide() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const groupRef = useRef(null) as unknown as Box<HTMLDivElement>;
const ringRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const btnRefs = useRef([]) as unknown as Box<HTMLButtonElement[]>;
useEffect(() => {
const root = rootRef.current;
const group = groupRef.current;
const ring = ringRef.current;
const btns = btnRefs.current;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let isDemo = true;
let current = 0;
let raf = 0;
let from: Rect | null = null;
function human() {
if (isDemo) { root.classList.remove(styles.isDemo); isDemo = false; }
}
// 계산 겹: 포커스 링을 목표 버튼의 실측 좌표(getBoundingClientRect, 그룹 기준 상대값)로 진행률 t 를
// FOCUS_EASE 로 보간해 translate·리사이즈한다 — % 나 고정폭이 아니라 매번 실측한다.
function measure(i: number): Rect {
const r = btns[i].getBoundingClientRect();
const gr = group.getBoundingClientRect();
return { x: r.left - gr.left, y: r.top - gr.top, w: r.width, h: r.height };
}
function render(box: Rect) {
ring.style.transform = `translate(${box.x}px,${box.y}px)`;
ring.style.width = box.w + 'px';
ring.style.height = box.h + 'px';
}
function moveRing(toIdx: number) {
const to = measure(toIdx);
if (reduce || !from) { render(to); from = to; return; }
const start = from;
const dur = 260;
let t0 = 0;
const ease = EASES[FOCUS_EASE] || EASES.linear;
if (raf) cancelAnimationFrame(raf);
function step(ts: number) {
if (!t0) t0 = ts;
const t = Math.min(1, (ts - t0) / dur);
const e = ease(t);
render({
x: start.x + (to.x - start.x) * e,
y: start.y + (to.y - start.y) * e,
w: start.w + (to.w - start.w) * e,
h: start.h + (to.h - start.h) * e
});
if (t < 1) raf = requestAnimationFrame(step);
else { raf = 0; from = to; }
}
raf = requestAnimationFrame(step);
}
function select(i: number, focus: boolean) {
btns.forEach((b, j) => {
b.setAttribute('aria-checked', String(j === i));
b.tabIndex = j === i ? 0 : -1;
});
current = i;
moveRing(i);
if (focus) btns[i].focus({ preventScroll: true });
}
const cleanups: Array<() => void> = [];
btns.forEach((b, i) => {
const onClick = () => { human(); select(i, false); };
const onKeydown = (e: KeyboardEvent) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
human();
e.preventDefault();
const dir = e.key === 'ArrowRight' ? 1 : -1;
select((current + dir + btns.length) % btns.length, true);
};
b.addEventListener('click', onClick);
b.addEventListener('keydown', onKeydown);
cleanups.push(() => {
b.removeEventListener('click', onClick);
b.removeEventListener('keydown', onKeydown);
});
});
from = measure(0);
render(from);
let start0 = 0;
let loopRaf = 0;
function loop(ts: number) {
if (!start0) start0 = ts;
if (isDemo && !reduce) {
const idx = Math.floor((ts - start0) / CYCLE_MS) % btns.length;
if (idx !== current) select(idx, false);
}
loopRaf = requestAnimationFrame(loop);
}
loopRaf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(loopRaf);
if (raf) cancelAnimationFrame(raf);
cleanups.forEach((fn) => fn());
};
}, []);
return (
<div className={`${styles.kfs} ${styles.isDemo}`} ref={(el: HTMLDivElement | null) => { if (el) rootRef.current = el; }}>
<div
className={styles.group}
role="radiogroup"
aria-label="보기 모드"
ref={(el: HTMLDivElement | null) => { if (el) groupRef.current = el; }}
>
{LABELS.map((label, i) => (
<button
key={label}
type="button"
className={styles.btn}
role="radio"
aria-checked={i === 0}
tabIndex={i === 0 ? 0 : -1}
ref={(el: HTMLButtonElement | null) => { if (el) btnRefs.current[i] = el; }}
>
{label}
</button>
))}
</div>
<span className={styles.ring} aria-hidden="true" ref={(el: HTMLSpanElement | null) => { if (el) ringRef.current = el; }} />
</div>
);
}08 · 키보드 방향키 포커스 슬라이드 — 방향키로 포커스 값이 옮겨질 때 포커스 테두리의 위치 값이 버튼 사이 실측 좌표를 따라 이동하는 방식 — 측정 누적면적 8.261% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09비활성 파동 차단 — 비활성 값이 켜진 버튼을 누르면 파동 확산 값이 중간에서 멈추고 흔들림 값으로 대신 반응하는 방식
비활성 값이 켜진 버튼을 눌러도 파동 확산 값은 HALT_RATIO .5 지점에서 멈추고, 대신 버튼 전체가 SHAKE_FREQ 26·DECAY 6.5 로 감쇠하는 sin 진동만큼 짧게 흔들리는 항목입니다. 두 계산은 클릭 시점에 함께 시작해 각자의 시간에 따로 멈춥니다.
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);

<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 포함 · 비밀번호 보기
값이 위치와 크기를 바꾸는 곳 — 활성 값·너비 값·포커스 값 기준
아홉 항목을 다시 세 축으로 겹쳐 보면 각자 다른 값이 위치와 크기를 정합니다. 활성 값이 기준인 쪽은 02(표시자 위치)·04(눌림 깊이)·09(비활성 상태에서도 반응은 남는 값)처럼, 어떤 버튼이 선택·눌림·비활성인지가 바뀔 때마다 위치나 크기 값이 다시 계산됩니다. 너비 값이 기준인 쪽은 05(그룹 전체 너비)·07(컨테이너와 라벨 너비)처럼 그릇의 크기가 버튼 수나 라벨 노출 여부를 정하고, 포커스 값이 기준인 쪽은 08 하나로 키보드 초점의 위치가 곧 테두리의 좌표가 됩니다.
나머지 01·03·06은 이 세 기준과 무관하게 자기만의 값을 씁니다. 01은 경계선의 겹침 좌표, 03은 포인터와 구분선의 거리, 06은 그룹의 방향 상태가 각자 위치·크기를 정하는 기준 값입니다.
어디서 깨지는가 — 빠른 연속 클릭, 좁은 화면, 키보드 포커스
09는 비활성 버튼을 빠르게 여러 번 누르면 매번 흔들림의 감쇠 계산이 처음부터 다시 시작돼, 앞선 흔들림이 채 잦아들기도 전에 새 흔들림이 덮어씁니다. 파동과 흔들림이 겹쳐 보이는 건 버그가 아니라 클릭마다 두 계산을 새로 시작하도록 짜인 그대로의 동작입니다.
05는 화면이 아무리 좁아져도 Math.max(1, cutoff) 때문에 최소 한 개의 버튼은 항상 보이도록 잡혀 있어서, 좁은 화면에서도 그룹 자체가 통째로 사라지지는 않습니다. 대신 남은 버튼 하나가 그룹 너비보다 실제로 더 넓게 삐져나올 수 있는 지점이기도 합니다.
08은 방향키를 아주 빠르게 눌러 이전 이동이 끝나기 전에 다음 이동이 시작되면, 포커스 테두리가 중간 지점이 아니라 이전 이동의 시작 좌표로 되돌아갔다가 다시 새 목표로 출발하는 것처럼 보일 수 있습니다. moveRing 이 진행 중인 애니메이션을 취소할 때 시작 좌표 값을 그 순간의 위치로 갱신하지 않고 이전 시작 값을 그대로 재사용하기 때문입니다.
같은 값 계산을 CSS 버튼 애니메이션 9종 — 복붙하면 되는 SCSS + React의 리플·눌림 계산과 겹쳐 보면, 그 편은 버튼 하나의 클릭 반응만 계산하고 이 편은 그 반응이 그룹 안 옆 버튼에 어떤 값을 전달하는지까지 계산한다는 차이가 있습니다. css 버튼 디자인 9종 — 캔디 그라데이션·눌림·로딩 버튼 코드와 비교하면 그 편은 그레이디언트·그림자 같은 정적인 색·깊이 값을 다루고, 이 편은 시간에 따라 바뀌는 위치·너비·포커스 값을 다룬다는 점에서 계산 축이 다릅니다. 플로팅 버튼 아이콘 버튼 패턴 9종 — 누르고 끌고 펼쳐지는 역시 버튼 하나가 스스로 펼쳐지는 값을 계산하는 쪽이라, 옆 버튼과의 관계 값을 계산하는 이 편의 09개 항목과는 기준이 갈립니다.
파일을 열어 값을 직접 바꿔 보고 싶다면, 압축 비번 he9ea98k 으로 아홉 항목의 html·scss·js 전체를 내려받을 수 있습니다.
변형 세 가지와 접근성
| 변형 | 값 | 느낌 |
|---|---|---|
| 슬라이드 표시자 이동 시간 늘리기 | 02 DAMP 18 → 9 | 감쇠를 18→9 로 낮추면 표시자가 목표 버튼에 바로 멈추지 않고 살짝 지나쳤다가 되돌아오는 오버슛이 남아, 이동이 더 튕기듯 보인다. |
| 더보기 접힘 여유폭 줄이기 | 05 OVERFLOW_BUFFER 24 → 8 | 여유폭을 24→8px 로 줄이면 버튼이 더 늦게까지 펼쳐진 채 남아 있어 더보기로 접히는 시점이 화면 끝에 훨씬 가까워진다. |
| 포커스 링 이동을 등속으로 바꾸기 | 08 FOCUS_EASE easeInOut → linear | 이징을 easeInOut 에서 linear 로 바꾸면 포커스 링이 출발과 도착에서 늦춰지지 않고 같은 속도로 곧장 옆 버튼에 닿아, 방향키 이동이 기계적이고 또박또박한 느낌으로 바뀐다. |
모션 감소 설정에 대한 대응은 항목마다 계산이 다릅니다. 01은 리플이 사라지는 시간을 420ms에서 1ms로 줄여 같은 계산 경로를 유지한 채 사실상 즉시 사라지게 하고, 02·04·07·08은 스프링·이징 보간 자체를 건너뛰어 목표 값을 즉시 대입해 순간 이동시킵니다. 03은 lerp 계수를 SMOOTH 대신 1로 바꿔 같은 프레임 안에서 목표 투명도로 바로 붙고, 05는 자동으로 좁아지던 너비를 최대값에 고정해 넘침 계산 자체가 일어나지 않게 하며, 06은 FLIP 의 되돌리기 transform 단계만 건너뛰어 레이아웃은 이미 바뀐 자리에 그대로 있으며, 09는 흔들림 계산 자체를 생략하고 파동이 멈춘 최종 위치로 바로 그립니다.
접근성 값은 04와 08 두 항목에서만 실제 role="radiogroup" · role="radio" 로 구현됩니다. ARIA group 역할 문서가 설명하듯 이 두 항목은 버튼마다 aria-checked 값을 클릭·방향키 시점에 다시 세우고, ArrowLeft·ArrowRight 로 포커스 값을 옆 버튼에 옮긴 뒤 focus({preventScroll:true}) 로 스크롤 위치를 흔들지 않고 포커스만 맞춥니다. 나머지 항목은 같은 role·aria 조합을 쓰지 않으며, 04·08의 실측 좌표 계산은 :focus-visible 로 마우스 클릭과 키보드 포커스의 테두리 표시를 구분하는 값과도 맞물립니다.
FAQ
방향키만으로 버튼 그룹을 전부 조작할 수 있나요?
04와 08 두 항목은 ArrowLeft·ArrowRight 로 눌림 값이나 포커스 값을 옆 버튼으로 옮기도록 짜여 있어, 마우스 클릭 없이 방향키만으로 값을 바꿀 수 있습니다. 나머지 항목은 클릭이나 포인터 이동으로 값이 바뀝니다.
버튼에 실제 input[type=radio] 가 있어야 라디오처럼 동작하나요?
아닙니다. 04와 08은 <button> 에 role="radio"·role="radiogroup" 과 aria-checked 값만 얹어 실제 라디오 입력 없이도 같은 접근성을 냅니다. 값의 상태만 aria 속성으로 알려 주는 방식입니다.
zip 안 코드를 그대로 붙이면 css 토글 버튼 그룹이 바로 동작하나요?
네, 아홉 항목 모두 외부 라이브러리 없이 순수 html·scss·js 로만 값을 계산하도록 만들어졌습니다. zip 안 코드를 그대로 붙이면 각 항목의 계산 함수까지 포함해 그대로 동작합니다.







