펼쳐지는 버튼 UI 9종 — 모양이 달라지는 아홉 가지 방식
펼쳐지는 버튼은 클릭 값 하나로 버튼 자체의 모양 값이 다른 형태로 바뀌는 인터랙션인데, 아홉 개 중 하나는 열림 값이 바뀌면 버튼의 모서리 둥근 값이 함께 커지고, 하나는 닫힘 값이 켜지면 더하기 표시가 회전하여 닫기 표시로 바뀌며, 또 하나는 펼침 값이 늘어나는 동안 너비 값이
아홉 항목 모두 로드 즉시 도는 자동 시연 루프는 값을 미리 보여주는 보조 장치일 뿐이고, 진짜 계산은 클릭·키보드·포인터 같은 실사용자 입력이 일어나는 순간 다시 돌아갑니다. 아홉 개 중 자동으로만 값이 바뀌는 리스너는 하나도 없고 Math.random 호출도 0번이라, 버튼 하나를 같은 순서로 눌러 보면 매번 같은 모양 값이 나오는 계산입니다.
01 btn-shape-accordion-reveal
02 btn-shape-plus-to-close
03 btn-shape-corner-round-grow
04 btn-shape-width-expand-inline
05 btn-shape-split-dual-reveal
06 btn-shape-pill-to-bar-search
07 btn-shape-stack-card-lift
09 btn-shape-elastic-snap-back
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 아코디언 드러냄 전환 — 버튼의 열림 값이 바뀌면 둥근 값과 그림자 값이 함께 바뀌며 아래 내용을 내미는 방식
- 02 더하기 회전 전환 — 닫힘 값이 켜지면 더하기 표시가 회전하여 닫기 표시로 바뀌는 방식
- 03 모서리 둥근 값 성장 — 열림 값이 커질수록 버튼의 모서리 둥근 값이 함께 커지는 방식
- 04 너비 관성 전환 — 펼침 값이 늘어나는 동안 너비 값이 목표를 살짝 넘었다가 되돌아오는 방식
- 05 둘로 갈라짐 전환 — 확정 값이 켜지면 버튼이 두 조각으로 갈라지며 각각 다른 값을 드러내는 방식
- 06 알약에서 막대로 전환 — 찾기 값이 켜지면 알약 모양 값이 막대 모양 값으로 바뀌며 입력 자리가 생기는 방식
- 07 겹짐 들어올림 전환 — 자세히 값이 켜지면 뒤에 겹쳐 있던 같은 모양 값들이 위로 차례로 들리는 방식
- 08 표시와 이름 나타남 전환 — 초점 값이 켜지면 표시 크기 값이 커지고 이름 너비 값이 늘어나는 방식
- 09 되튐 복귀 전환 — 접힘 값이 켜지면 형태 값이 줄어들다가 반대로 살짝 늘었다가 제 형태로 돌아가는 방식
아홉 개의 해부 — 어떤 모양 값이 언제 바뀌는가
01·02·04·07은 클릭 하나로, 03은 클릭과 키보드(Enter·Space)로, 05는 셸 클릭과 선택지 버튼 두 개의 클릭으로, 06은 입력과 닫기 버튼 클릭과 초점으로, 08은 포인터 진입·이탈과 클릭과 초점으로, 09는 포인터 눌림·뗌과 클릭으로 값을 다시 계산합니다. 아래 표는 아홉 항목의 누적면적·강도·움직인 프레임 실측 값입니다(분모는 캡처 24프레임 중 실제로 값이 바뀐 23프레임입니다).
| 번호 | 이름 | 리스너 | 누적면적% | 강도 | 움직인프레임/23 |
|---|---|---|---|---|---|
| 01 | 아코디언 드러냄 전환 | click(버튼) | 18.30 | 154.3 | 8 |
| 02 | 더하기 회전 전환 | click(버튼) | 0.64 | 56.7 | 8 |
| 03 | 모서리 둥근 값 성장 | click+keydown(버튼) | 1.67 | 68.6 | 15 |
| 04 | 너비 관성 전환 | click(버튼) | 4.33 | 164.3 | 14 |
| 05 | 둘로 갈라짐 전환 | click(셸)+click(선택지 2) | 7.00 | 100.0 | 7 |
| 06 | 알약에서 막대로 전환 | click(닫기)+input+focus | 8.39 | 141.9 | 7 |
| 07 | 겹짐 들어올림 전환 | click(버튼) | 12.56 | 59.5 | 8 |
| 08 | 표시와 이름 나타남 전환 | pointerenter/leave+click+focus | 1.01 | 68.6 | 8 |
| 09 | 되튐 복귀 전환 | pointerdown+pointerup+click | 11.24 | 79.3 | 12 |
09는 누적면적(11.24%)이 07(12.56%)보다 오히려 낮지만, 매 프레임 K(190)·D(14) 스프링 값을 적분해 눌림·되돌림을 계산하는 항목은 04와 09뿐입니다. 그래서 09는 화면을 채우는 면적이 아니라 컴포넌트 한 개 안의 계산 정교함으로 값을 보여 주는 쪽에 가깝습니다.
실측 치수로 목표 값을 정하는 값 — 01·05·08
01 같은 펼침 버튼은 measure()로 답변 목록의 실제 노출 높이를 재 --panel-h로 넘기고, 05는 셸 폭을 실측해 --shell-w로 넘기며, 08은 토글이 일어날 때마다 이름 값의 폭을 다시 재 --label-w로 넘깁니다. 셋 다 짐작한 고정 픽셀이 아니라 지금 화면에 실제로 놓인 크기를 기준으로 목표 값을 정합니다.
여러 단계를 밟으며 값이 쌓이는 값 — 03·07
03은 RADII 배열 [12px, 16px, 24px, 999px]를 클릭·키보드 입력마다 한 칸씩 넘기며 모서리 둥근 값을 단계로 키우고, 07은 마크업에 미리 박힌 --n(뒤에서부터의 차례) 값에 CSS 쪽에서 STEP_PX(34px)를 곱해 카드마다 다른 높이로 들어 올리며 JS는 STAGGER_MS(80ms)로 지연 순서만 심습니다. 둘 다 값 하나가 아니라 여러 단계·여러 요소에 걸쳐 누적되는 계산입니다.
찾기 값 하나가 폭과 목록까지 함께 바꾸는 값 — 06
06은 찾기 값 하나가 켜지고 꺼지는 것만으로 막대 폭·아이콘 자리·닫기 버튼·힌트 목록까지 한 번에 다시 그립니다. 힌트는 SEEDS라는 시드 고정 로컬 배열에서만 골라 채우고, 실제 검색 서버에 값을 보내지 않습니다.
매 프레임 스프링 적분으로 목표를 넘쳤다 되돌아오는 값 — 04·09
04와 09만 CSS transition이 아니라 자바스크립트 tick() 루프가 매 프레임 -K*(cur-target)-D*vel 값을 적분해 너비·형태 값을 그립니다. 01·02·03·05·06·07·08은 자바스크립트가 클래스나 상태 값만 바꾸고, 화면에 보이는 움직임 자체는 SCSS의 transition·animation 선언이 도맡습니다.
CSS 이징 곡선 자체가 오버슈트를 만드는 값 — 02
02는 별도의 자바스크립트 계산 없이 $ease-elastic 큐빅베지어 곡선 하나가 SPIN_DEG(135도)를 지나쳤다가 되돌아오는 값을 만듭니다. 04·09의 자바스크립트 적분과는 다른 방식으로 같은 '지나쳤다 돌아오는' 느낌을 냅니다.
패턴별 코드
01아코디언 드러냄 전환 — 버튼의 열림 값이 바뀌면 둥근 값과 그림자 값이 함께 바뀌며 아래 내용을 내미는 방식
measure()가 하위 항목의 실제 높이를 getBoundingClientRect()로 실측해 --panel-h로 넘기고, 로드 시점에 STAGGER_MS(70ms)만큼 항목 순서대로 늦게 나타나도록 transitionDelay 값을 미리 심어 둡니다. FAQ 항목 여닫기, 설정 묶음 펼침에 쓰는 계산입니다.
var STAGGER_MS = 70; // 계산 겹: 항목 순서 * 이 값만큼 늦게 나타난다
// 계산 겹: 하위 항목이 실제로 차지하는 노출 높이를 getBoundingClientRect 로 실측해
// panel 의 --panel-h 로 넘긴다 — 짐작한 고정값이 아니라 지금 폰트·내용 기준 값이다.
function measure() {
var h = 0;
items.forEach(function (li) { h += li.getBoundingClientRect().height; });
panel.style.setProperty('--panel-h', h + 'px');
}
items.forEach(function (li, i) {
li.style.transitionDelay = (i * STAGGER_MS) + 'ms';
});

<main class="stage">
<div class="ba is-demo" id="ba">
<button type="button" class="ba__btn" id="baBtn" aria-expanded="false" aria-controls="baPanel">
<span class="ba__label">열림 값 펼치기</span>
<span class="ba__mark" aria-hidden="true">
<svg viewBox="0 0 16 16" width="14" height="14"><path d="M3 6l5 5 5-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</button>
<div class="ba__panel" id="baPanel" role="region">
<ul class="ba__list" id="baList">
<li class="ba__item">답변 값 1</li>
<li class="ba__item">답변 값 2</li>
<li class="ba__item">답변 값 3</li>
</ul>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 하위 항목 노출 높이를 getBoundingClientRect 로 실측해 --panel-h 로 넘기고(index.html),
// 항목마다 순서 * STAGGER_MS 산술로 transition-delay 를 준다.
@include stage($stage-ink);
.ba {
width: 100%;
max-width: 300px;
}
.ba__btn {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-3;
width: 100%;
box-sizing: border-box;
padding: $sp-3 $sp-4;
border: 0;
border-radius: $r-control $r-control 0 0;
background: $subject-cream;
color: $subject-ink;
font-weight: 600;
font-size: 15px;
box-shadow: $shadow-press;
transition: border-radius $dur-base $ease-out, box-shadow $dur-base $ease-out;
cursor: pointer;
}
.ba__mark {
display: inline-flex;
transition: transform $dur-base $ease-out;
}
.ba__panel {
overflow: hidden;
max-height: 0;
border-radius: 0 0 $r-control $r-control;
background: rgba($subject-cream, .92);
transition: max-height $dur-enter $ease-spring;
}
.ba__list {
list-style: none;
margin: 0;
padding: $sp-3 $sp-4;
display: grid;
gap: $sp-2;
}
.ba__item {
color: $subject-ink;
font-size: 13px;
opacity: 0;
transform: translateY(-6px);
transition: opacity $dur-quick $ease-out, transform $dur-quick $ease-out;
}
.ba--open {
.ba__btn { border-radius: $r-control $r-control 0 0; box-shadow: $shadow-raised; }
.ba__mark { transform: rotate(180deg); }
.ba__panel { max-height: var(--panel-h, 160px); }
.ba__item { opacity: 1; transform: translateY(0); }
}
@media (max-width: 340px) {
.ba__btn { padding: $sp-2 $sp-3; font-size: 14px; }
.ba__list { padding: $sp-2 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.ba__btn, .ba__mark, .ba__panel, .ba__item { 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);
}
.ba {
width: 100%;
max-width: 300px;
}
.ba__btn {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
box-sizing: border-box;
padding: 12px 16px;
border: 0;
border-radius: 12px 12px 0 0;
background: #fff7e6;
color: #17141a;
font-weight: 600;
font-size: 15px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: border-radius 300ms ease-out, box-shadow 300ms ease-out;
cursor: pointer;
}
.ba__mark {
display: inline-flex;
transition: transform 300ms ease-out;
}
.ba__panel {
overflow: hidden;
max-height: 0;
border-radius: 0 0 12px 12px;
background: rgba(255, 247, 230, 0.92);
transition: max-height 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ba__list {
list-style: none;
margin: 0;
padding: 12px 16px;
display: grid;
gap: 8px;
}
.ba__item {
color: #17141a;
font-size: 13px;
opacity: 0;
transform: translateY(-6px);
transition: opacity 220ms ease-out, transform 220ms ease-out;
}
.ba--open .ba__btn {
border-radius: 12px 12px 0 0;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ba--open .ba__mark {
transform: rotate(180deg);
}
.ba--open .ba__panel {
max-height: var(--panel-h, 160px);
}
.ba--open .ba__item {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 340px) {
.ba__btn {
padding: 8px 12px;
font-size: 14px;
}
.ba__list {
padding: 8px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.ba__btn, .ba__mark, .ba__panel, .ba__item {
transition: none;
}
}(function () {
var root = document.getElementById('ba');
var btn = document.getElementById('baBtn');
var panel = document.getElementById('baPanel');
var items = panel.querySelectorAll('.ba__item');
var STAGGER_MS = 70; // 계산 겹: 항목 순서 * 이 값만큼 늦게 나타난다
// 계산 겹: 하위 항목이 실제로 차지하는 노출 높이를 getBoundingClientRect 로 실측해
// panel 의 --panel-h 로 넘긴다 — 짐작한 고정값이 아니라 지금 폰트·내용 기준 값이다.
function measure() {
var h = 0;
items.forEach(function (li) { h += li.getBoundingClientRect().height; });
panel.style.setProperty('--panel-h', h + 'px');
}
items.forEach(function (li, i) {
li.style.transitionDelay = (i * STAGGER_MS) + 'ms';
});
var open = false;
function paint() {
measure();
root.classList.toggle('ba--open', open);
btn.setAttribute('aria-expanded', String(open));
}
function toggle() {
open = !open;
paint();
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
toggle();
});
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
measure();
if (!reduce) { toggle(); autoTimer = setInterval(toggle, 800); }
else { open = true; paint(); }
})();import { useEffect, useRef } from "react";
import styles from "./AccordionReveal.module.scss";
type Box<T> = { current: T };
const STAGGER_MS = 70; // 계산 겹: 항목 순서 * 이 값만큼 늦게 나타난다
export default function AccordionReveal() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector(`.${styles.btn}`) as HTMLButtonElement | null;
const panel = root.querySelector(`.${styles.panel}`) as HTMLElement | null;
const items = root.querySelectorAll(`.${styles.item}`);
if (!btn || !panel) return;
items.forEach((li, i) => {
(li as HTMLElement).style.transitionDelay = (i * STAGGER_MS) + "ms";
});
function measure() {
let h = 0;
items.forEach((li) => { h += li.getBoundingClientRect().height; });
panel!.style.setProperty("--panel-h", h + "px");
}
let open = false;
function paint() {
measure();
root!.classList.toggle(styles["root--open"], open);
btn!.setAttribute("aria-expanded", String(open));
}
function toggle() {
open = !open;
paint();
}
function onClick() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
toggle();
}
btn.addEventListener("click", onClick);
let autoTimer: number | null = null;
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
measure();
if (!reduce) { toggle(); autoTimer = window.setInterval(toggle, 800); }
else { open = true; paint(); }
return () => {
stopAutoplay();
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.ba} ${styles["is-demo"]}`}>
<button type="button" className={styles.btn} aria-expanded="false">
<span className={styles.label}>열림 값 펼치기</span>
<span className={styles.mark} aria-hidden="true">
<svg viewBox="0 0 16 16" width="14" height="14"><path d="M3 6l5 5 5-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
</span>
</button>
<div className={styles.panel} role="region">
<ul className={styles.list}>
<li className={styles.item}>답변 값 1</li>
<li className={styles.item}>답변 값 2</li>
<li className={styles.item}>답변 값 3</li>
</ul>
</div>
</div>
);
}01 · 아코디언 드러냄 전환 — 버튼의 열림 값이 바뀌면 둥근 값과 그림자 값이 함께 바뀌며 아래 내용을 내미는 방식 — 측정 누적면적 18.301% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02더하기 회전 전환 — 닫힘 값이 켜지면 더하기 표시가 회전하여 닫기 표시로 바뀌는 방식
SPIN_DEG(135)가 90도가 아니라 지나쳤다가 되돌아오는 목표 각도 값을 정하고, 실제 오버슈트는 $ease-elastic 곡선이 .pc__icon의 transform에 걸려 만들어 냅니다. 항목 추가 버튼, 필터 열기 단추에 쓰는 계산입니다.
var SPIN_DEG = 135; // 계산 겹: 회전 목표 각도 값 — 90도가 아니라 지나쳤다가 되돌아오는 각도
var OVER_MS = 90; // 계산 겹: 목표 각도를 넘겼다 되돌리는 여분 시간
var closed = false;
function paint() {
root.classList.toggle('pc--closed', closed);
btn.setAttribute('aria-pressed', String(closed));
}
function toggle() {
closed = !closed;
paint();
}

<main class="stage">
<div class="pc is-demo" id="pc">
<button type="button" class="pc__btn" id="pcBtn" aria-pressed="false">
<span class="pc__label">항목 값 추가</span>
<span class="pc__disc" aria-hidden="true">
<svg class="pc__icon" viewBox="0 0 24 24" width="22" height="22">
<!-- 세로 막대 — 닫힘 값이 켜지면 stroke-dashoffset 으로 선분이 그려진 채 사라진다 -->
<line class="pc__v" x1="12" y1="5" x2="12" y2="19" />
<!-- 가로 막대 — 원래 있던 선. 두 막대가 같은 중심(12,12)에서 만난다 -->
<line class="pc__h" x1="5" y1="12" x2="19" y2="12" />
</svg>
</span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 아이콘이 SPIN_DEG(135도) 만큼 회전하며 동시에 세로 막대의 stroke-dashoffset 이
// 선분 길이만큼 물러나 path morph 로 닫기 표시가 된다(index.html 주석 참조).
@include stage($stage-yellow);
.pc {
display: grid;
gap: $sp-4;
justify-items: center;
}
.pc__btn {
display: inline-flex;
align-items: center;
gap: $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
padding: $sp-3 $sp-5;
font-weight: 600;
font-size: 16px;
cursor: pointer;
transition: background-color $dur-quick $ease-out;
}
.pc__disc {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: rgba($stage-yellow, .18);
}
.pc__icon {
display: block;
transition: transform 360ms $ease-elastic;
}
.pc__v, .pc__h {
stroke: $stage-yellow;
stroke-width: 2.4;
stroke-linecap: round;
fill: none;
}
// 선분 그리기: 세로 막대는 pathLength 1 기준으로 dashoffset 0 → 1 사라진다
.pc__v {
stroke-dasharray: 1;
stroke-dashoffset: 0;
transition: stroke-dashoffset 270ms $ease-out;
}
.pc--closed {
.pc__icon { transform: rotate(135deg); }
.pc__v { stroke-dashoffset: 1; }
.pc__disc { background: rgba($stage-yellow, .32); }
}
@media (max-width: 340px) {
.pc__btn { padding: $sp-2 $sp-4; font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.pc__icon, .pc__v { transition: none; }
}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);
}
.pc {
display: grid;
gap: 16px;
justify-items: center;
}
.pc__btn {
display: inline-flex;
align-items: center;
gap: 12px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
padding: 12px 20px;
font-weight: 600;
font-size: 16px;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__disc {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: rgba(255, 210, 63, 0.18);
}
.pc__icon {
display: block;
transition: transform 360ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.pc__v, .pc__h {
stroke: #ffd23f;
stroke-width: 2.4;
stroke-linecap: round;
fill: none;
}
.pc__v {
stroke-dasharray: 1;
stroke-dashoffset: 0;
transition: stroke-dashoffset 270ms ease-out;
}
.pc--closed .pc__icon {
transform: rotate(135deg);
}
.pc--closed .pc__v {
stroke-dashoffset: 1;
}
.pc--closed .pc__disc {
background: rgba(255, 210, 63, 0.32);
}
@media (max-width: 340px) {
.pc__btn {
padding: 8px 16px;
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__icon, .pc__v {
transition: none;
}
}(function () {
var root = document.getElementById('pc');
var btn = document.getElementById('pcBtn');
var SPIN_DEG = 135; // 계산 겹: 회전 목표 각도 값 — 90도가 아니라 지나쳤다가 되돌아오는 각도
var OVER_MS = 90; // 계산 겹: 목표 각도를 넘겼다 되돌리는 여분 시간
var closed = false;
function paint() {
root.classList.toggle('pc--closed', closed);
btn.setAttribute('aria-pressed', String(closed));
}
function toggle() {
closed = !closed;
paint();
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
toggle();
});
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(toggle, 1100); }
else { closed = true; paint(); }
})();import { useEffect, useRef } from "react";
import styles from "./PlusToClose.module.scss";
type Box<T> = { current: T };
const SPIN_DEG = 135; // 계산 겹: 회전 목표 각도 값 — 90도가 아니라 지나쳤다가 되돌아오는 각도
const OVER_MS = 90; // 계산 겹: 목표 각도를 넘겼다 되돌리는 여분 시간
export default function PlusToClose() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector(`.${styles.btn}`) as HTMLButtonElement | null;
if (!btn) return;
let closed = false;
function paint() {
root!.classList.toggle(styles["root--closed"], closed);
btn!.setAttribute("aria-pressed", String(closed));
}
function toggle() {
closed = !closed;
paint();
}
function onClick() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
toggle();
}
btn.addEventListener("click", onClick);
let autoTimer: number | null = null;
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(toggle, 1100); }
else { closed = true; paint(); }
return () => {
stopAutoplay();
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.pc} ${styles["is-demo"]}`}>
<button type="button" className={styles.btn} aria-pressed="false">
<span className={styles.label}>항목 값 추가</span>
<span className={styles.disc} aria-hidden="true">
<svg className={styles.icon} viewBox="0 0 24 24" width="22" height="22">
<line className={styles.v} x1="12" y1="5" x2="12" y2="19" />
<line className={styles.h} x1="5" y1="12" x2="19" y2="12" />
</svg>
</span>
</button>
</div>
);
}02 · 더하기 회전 전환 — 닫힘 값이 켜지면 더하기 표시가 회전하여 닫기 표시로 바뀌는 방식 — 측정 누적면적 0.642% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03모서리 둥근 값 성장 — 열림 값이 커질수록 버튼의 모서리 둥근 값이 함께 커지는 방식
RADII([12, 16, 24, 999]) 배열 값을 클릭·키보드(Enter·Space) 입력마다 한 단계씩 넘기며 advance()가 모서리 둥근 값을 다시 그리고, human()이 자동 재생을 멈추고 실사용자 조작으로 넘어갑니다. 다단계 폼의 다음 단추, 단계 선택 카드에 쓰는 계산입니다.
function paint() {
var r = RADII[step];
// 둥근 값은 % 가 아니라 px 로 보간해야 성장이 선형으로 보인다 — 999px 는 알약 표기
btn.style.borderRadius = (r === 999 ? '999px' : r + 'px');
read.textContent = RADII_TEXT[step];
fill.style.transform = 'scaleX(' + ((step + 1) / RADII.length) + ')';
dots.forEach(function (d, i) {
d.classList.toggle('cr__dot--on', i <= step);
});
}
function advance() {
step = (step + 1) % RADII.length;
paint();
}
function human() {
root.classList.remove('is-demo');
stopAutoplay();
advance();
}
btn.addEventListener('click', human);
btn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); }
});

<main class="stage">
<div class="cr is-demo" id="cr">
<div class="cr__meter" aria-hidden="true">
<div class="cr__meterFill" id="crFill"></div>
</div>
<p class="cr__read" id="crRead">둥근 값 12px</p>
<div class="cr__btn" id="crBtn" role="button" tabindex="0" aria-label="단계 값 올리기">
<span class="cr__label">다음 단계</span>
</div>
<div class="cr__steps" aria-hidden="true">
<span class="cr__dot"></span>
<span class="cr__dot"></span>
<span class="cr__dot"></span>
<span class="cr__dot"></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 단계 값마다 둥근 반경이 RADII 산술(12→16→24→알약)으로 커지고(index.html),
// 크기 성장 곡선이 같은 transition 에 물려 함께 이어진다.
@include stage($stage-orange);
.cr {
display: grid;
gap: $sp-4;
justify-items: center;
}
.cr__meter {
width: 220px;
height: $sp-1;
border-radius: $r-xs;
background: rgba($subject-cream, .22);
overflow: hidden;
}
.cr__meterFill {
width: 100%;
height: 100%;
border-radius: $r-xs;
background: $subject-cream;
transform-origin: left center;
transform: scaleX(.25);
transition: transform $dur-base $ease-out;
}
.cr__read {
margin: 0;
color: $subject-ink;
font-size: 13px;
font-weight: 600;
}
.cr__btn {
display: grid;
place-items: center;
width: 200px;
height: 64px;
border-radius: $r-control;
background: $stage-paper;
color: $subject-ink;
box-shadow: $shadow-raised;
cursor: pointer;
transition: border-radius $dur-enter $ease-elastic, width $dur-enter $ease-elastic, height $dur-enter $ease-elastic;
}
.cr__label {
font-weight: 700;
font-size: 16px;
}
.cr__steps {
display: flex;
gap: $sp-2;
}
.cr__dot {
width: $sp-2;
height: $sp-2;
border-radius: 50%;
background: rgba($subject-cream, .28);
transition: background-color $dur-quick $ease-out, transform $dur-quick $ease-elastic;
}
.cr__dot--on {
background: $stage-paper;
transform: scale(1.18);
}
@media (max-width: 340px) {
.cr__btn { width: 180px; height: 56px; }
.cr__meter { width: 180px; }
}
@media (prefers-reduced-motion: reduce) {
.cr__btn, .cr__meterFill, .cr__dot { 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);
}
.cr {
display: grid;
gap: 16px;
justify-items: center;
}
.cr__meter {
width: 220px;
height: 4px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.22);
overflow: hidden;
}
.cr__meterFill {
width: 100%;
height: 100%;
border-radius: 4px;
background: #fff7e6;
transform-origin: left center;
transform: scaleX(0.25);
transition: transform 300ms ease-out;
}
.cr__read {
margin: 0;
color: #17141a;
font-size: 13px;
font-weight: 600;
}
.cr__btn {
display: grid;
place-items: center;
width: 200px;
height: 64px;
border-radius: 12px;
background: #ffffff;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: pointer;
transition: border-radius 600ms cubic-bezier(0.34, 1.56, 0.64, 1), width 600ms cubic-bezier(0.34, 1.56, 0.64, 1), height 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.cr__label {
font-weight: 700;
font-size: 16px;
}
.cr__steps {
display: flex;
gap: 8px;
}
.cr__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 247, 230, 0.28);
transition: background-color 220ms ease-out, transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.cr__dot--on {
background: #ffffff;
transform: scale(1.18);
}
@media (max-width: 340px) {
.cr__btn {
width: 180px;
height: 56px;
}
.cr__meter {
width: 180px;
}
}
@media (prefers-reduced-motion: reduce) {
.cr__btn, .cr__meterFill, .cr__dot {
transition: none;
}
}(function () {
var root = document.getElementById('cr');
var btn = document.getElementById('crBtn');
var fill = document.getElementById('crFill');
var read = document.getElementById('crRead');
var dots = root.querySelectorAll('.cr__dot');
var RADII = [12, 16, 24, 999]; // 계산 겹: 단계마다 커지는 둥근 값(px) — 마지막은 알약
var RADII_TEXT = ['둥근 값 12px', '둥근 값 16px', '둥근 값 24px', '둥근 값 알약'];
var step = 0;
function paint() {
var r = RADII[step];
// 둥근 값은 % 가 아니라 px 로 보간해야 성장이 선형으로 보인다 — 999px 는 알약 표기
btn.style.borderRadius = (r === 999 ? '999px' : r + 'px');
read.textContent = RADII_TEXT[step];
fill.style.transform = 'scaleX(' + ((step + 1) / RADII.length) + ')';
dots.forEach(function (d, i) {
d.classList.toggle('cr__dot--on', i <= step);
});
}
function advance() {
step = (step + 1) % RADII.length;
paint();
}
function human() {
root.classList.remove('is-demo');
stopAutoplay();
advance();
}
btn.addEventListener('click', human);
btn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); }
});
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
paint();
if (!reduce) { autoTimer = setInterval(advance, 900); }
})();import { useEffect, useRef } from "react";
import styles from "./CornerRoundGrow.module.scss";
type Box<T> = { current: T };
const RADII = [12, 16, 24, 999]; // 계산 겹: 단계마다 커지는 둥근 값(px) — 마지막은 알약
const RADII_TEXT = ["둥근 값 12px", "둥근 값 16px", "둥근 값 24px", "둥근 값 알약"];
export default function CornerRoundGrow() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
const fill = root.querySelector(`.${styles.fill}`) as HTMLElement | null;
const read = root.querySelector(`.${styles.read}`) as HTMLElement | null;
const dots = root.querySelectorAll(`.${styles.dot}`);
if (!btn || !fill || !read) return;
let step = 0;
function paint() {
const r = RADII[step];
btn!.style.borderRadius = (r === 999 ? "999px" : r + "px");
read!.textContent = RADII_TEXT[step];
fill!.style.transform = "scaleX(" + ((step + 1) / RADII.length) + ")";
dots.forEach((d, i) => {
d.classList.toggle(styles["dot--on"], i <= step);
});
}
function advance() {
step = (step + 1) % RADII.length;
paint();
}
function human() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
advance();
}
function onClick() { human(); }
function onKey(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); human(); }
}
btn.addEventListener("click", onClick);
btn.addEventListener("keydown", onKey);
let autoTimer: number | null = null;
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
paint();
if (!reduce) { autoTimer = window.setInterval(advance, 900); }
return () => {
stopAutoplay();
btn.removeEventListener("click", onClick);
btn.removeEventListener("keydown", onKey);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.cr} ${styles["is-demo"]}`}>
<div className={styles.meter} aria-hidden="true">
<div className={styles.fill} />
</div>
<p className={styles.read}>둥근 값 12px</p>
<div className={styles.btn} role="button" tabIndex={0} aria-label="단계 값 올리기">
<span className={styles.label}>다음 단계</span>
</div>
<div className={styles.steps} aria-hidden="true">
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
</div>
</div>
);
}03 · 모서리 둥근 값 성장 — 열림 값이 커질수록 버튼의 모서리 둥근 값이 함께 커지는 방식 — 측정 누적면적 1.665% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
04너비 관성 전환 — 펼침 값이 늘어나는 동안 너비 값이 목표를 살짝 넘었다가 되돌아오는 방식
tick()이 매 프레임 -K*(cur-target)-D*vel 스프링 감쇠 값을 적분해 너비 값을 목표(openW)까지 넘쳤다가 되돌리고, toggle()은 이 목표 값만 바꿀 뿐 너비 자체는 계산하지 않습니다. 본문 중간의 더 보기 단추, 인라인 도구 모음 열기에 쓰는 계산입니다.
var open = false;
var cur = REST_W, vel = 0, target = REST_W;
var raf = null;
// 스프링 감쇠 계산: 매 프레임 a = -K*(cur-target) - D*vel — 오버슈트 후 안착
function tick() {
var a = -K * (cur - target) * 0.001 - D * vel * 0.01;
vel += a;
cur += vel;
if (Math.abs(cur - target) < 0.4 && Math.abs(vel) < 0.05) { cur = target; vel = 0; }
btn.style.width = cur.toFixed(1) + 'px';
var p = Math.max(0, Math.min(1, (cur - REST_W) / (openW - REST_W)));
more.style.opacity = String(p);
raf = requestAnimationFrame(tick);
}
function toggle() {
open = !open;
target = open ? openW : REST_W;
btn.setAttribute('aria-expanded', String(open));
}

<main class="stage">
<div class="we is-demo" id="we">
<p class="we__line">본문 한 줄이 흐르는 자리에 있습니다. 아래 단추가 그 줄을 흐리지 않게 절제되게 늘어납니다.</p>
<button type="button" class="we__btn" id="weBtn" aria-expanded="false">
<span class="we__base">더 보기</span>
<span class="we__more" id="weMore">전문 값 보기</span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 너비가 CSS transition 이 아니라 index.html 의 스프링 감쇠 계산(K·D)으로 매 프레임
// 결정된다 — 목표를 넘었다가 되돌아오는 관성. more 글자는 진행률 p 로만 얹힌다.
@include stage($stage-paper);
.we {
width: 100%;
max-width: 340px;
display: grid;
gap: $sp-4;
justify-items: start;
}
.we__line {
margin: 0;
color: $subject-ink;
font-size: 13px;
line-height: 1.7;
}
.we__btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: flex-start;
height: 44px;
padding: 0 $sp-4;
box-sizing: border-box;
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 600;
font-size: 14px;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
will-change: width;
}
.we__more {
margin-left: $sp-2;
opacity: 0;
white-space: nowrap;
font-weight: 600;
}
@media (max-width: 340px) {
.we { max-width: 280px; }
.we__btn { height: 40px; font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.we__more { 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);
}
.we {
width: 100%;
max-width: 340px;
display: grid;
gap: 16px;
justify-items: start;
}
.we__line {
margin: 0;
color: #17141a;
font-size: 13px;
line-height: 1.7;
}
.we__btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: flex-start;
height: 44px;
padding: 0 16px;
box-sizing: border-box;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 600;
font-size: 14px;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
will-change: width;
}
.we__more {
margin-left: 8px;
opacity: 0;
white-space: nowrap;
font-weight: 600;
}
@media (max-width: 340px) {
.we {
max-width: 280px;
}
.we__btn {
height: 40px;
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.we__more {
transition: none;
}
}(function () {
var root = document.getElementById('we');
var btn = document.getElementById('weBtn');
var more = document.getElementById('weMore');
var REST_W = 104; // 접힘 너비(px) — getBoundingClientRect 로 실측해 갱신한다
var K = 170, D = 12; // 스프링 상수·감쇠 — 목표를 넘었다가 되돌아오는 관성 값
// 계산 겹: 목표 폭을 getBoundingClientRect 로 실측한다(짐작 px 아님).
// 늘어난 상태의 텍스트를 잠깐 펼쳐 재고 다시 접는다 — 글꼴마다 값이 다르다.
function measure() {
var prev = more.style.width;
more.style.width = 'auto';
var w = more.getBoundingClientRect().width;
more.style.width = prev || '0px';
return Math.ceil(w);
}
var openW = measure() + REST_W + 24; // 24 = 기준 너비에 더할 여유
var open = false;
var cur = REST_W, vel = 0, target = REST_W;
var raf = null;
// 스프링 감쇠 계산: 매 프레임 a = -K*(cur-target) - D*vel — 오버슈트 후 안착
function tick() {
var a = -K * (cur - target) * 0.001 - D * vel * 0.01;
vel += a;
cur += vel;
if (Math.abs(cur - target) < 0.4 && Math.abs(vel) < 0.05) { cur = target; vel = 0; }
btn.style.width = cur.toFixed(1) + 'px';
var p = Math.max(0, Math.min(1, (cur - REST_W) / (openW - REST_W)));
more.style.opacity = String(p);
raf = requestAnimationFrame(tick);
}
function toggle() {
open = !open;
target = open ? openW : REST_W;
btn.setAttribute('aria-expanded', String(open));
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
toggle();
});
raf = requestAnimationFrame(tick);
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(toggle, 1300); }
else { open = true; target = openW; cur = openW; btn.style.width = openW + 'px'; more.style.opacity = '1'; }
})();import { useEffect, useRef } from "react";
import styles from "./WidthExpandInline.module.scss";
type Box<T> = { current: T };
const REST_W = 104; // 접힘 너비(px) — getBoundingClientRect 로 실측해 갱신한다
const K = 170, D = 12; // 스프링 상수·감쇠 — 목표를 넘었다가 되돌아오는 관성 값
export default function WidthExpandInline() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector(`.${styles.btn}`) as HTMLButtonElement | null;
const more = root.querySelector(`.${styles.more}`) as HTMLElement | null;
if (!btn || !more) return;
// 계산 겹: 목표 폭을 getBoundingClientRect 로 실측한다(짐작 px 아님).
function measure() {
const prev = more!.style.width;
more!.style.width = "auto";
const w = more!.getBoundingClientRect().width;
more!.style.width = prev || "0px";
return Math.ceil(w);
}
const openW = measure() + REST_W + 24; // 24 = 기준 너비에 더할 여유
let open = false;
let cur = REST_W, vel = 0, target = REST_W;
let raf: number | null = null;
function tick() {
const a = -K * (cur - target) * 0.001 - D * vel * 0.01;
vel += a;
cur += vel;
if (Math.abs(cur - target) < 0.4 && Math.abs(vel) < 0.05) { cur = target; vel = 0; }
btn!.style.width = cur.toFixed(1) + "px";
const p = Math.max(0, Math.min(1, (cur - REST_W) / (openW - REST_W)));
more!.style.opacity = String(p);
raf = requestAnimationFrame(tick);
}
function toggle() {
open = !open;
target = open ? openW : REST_W;
btn!.setAttribute("aria-expanded", String(open));
}
function onClick() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
toggle();
}
btn.addEventListener("click", onClick);
raf = requestAnimationFrame(tick);
let autoTimer: number | null = null;
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(toggle, 1300); }
else {
open = true; target = openW; cur = openW;
btn!.style.width = openW + "px"; more!.style.opacity = "1";
}
return () => {
stopAutoplay();
if (raf !== null) cancelAnimationFrame(raf);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.we} ${styles["is-demo"]}`}>
<p className={styles.line}>본문 한 줄이 흐르는 자리에 있습니다. 아래 단추가 그 줄을 흐리지 않게 절제되게 늘어납니다.</p>
<button type="button" className={styles.btn} aria-expanded="false">
<span className={styles.base}>더 보기</span>
<span className={styles.more}>전문 값 보기</span>
</button>
</div>
);
}04 · 너비 관성 전환 — 펼침 값이 늘어나는 동안 너비 값이 목표를 살짝 넘었다가 되돌아오는 방식 — 측정 누적면적 4.333% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05둘로 갈라짐 전환 — 확정 값이 켜지면 버튼이 두 조각으로 갈라지며 각각 다른 값을 드러내는 방식
measure()가 셸 폭을 실측해 --shell-w로 넘기고, PART_PX(26px)만큼 두 절반이 각자 반대 방향으로 벌어지며 .sd__face--split 값이 드러납니다. 삭제 확인 단추, 이중 선택 확인에 쓰는 계산입니다.
var PART_PX = 26; // 계산 겹: 두 절반이 벌어지는 거리(px) — 셸 폭 절반 기준 산술
// 계산 겹: 셸 폭을 getBoundingClientRect 로 실측해 --shell-w 로 넘긴다.
// 절반은 각자 셸 폭의 50% 로 그려지므로 벌어짐 거리도 이 값에 맞춘다.
function measure() {
shell.style.setProperty('--shell-w', shell.getBoundingClientRect().width + 'px');
}
var split = false;
function paint() {
root.classList.toggle('sd--split', split);
shell.setAttribute('aria-expanded', String(split));
}
function toggle() {
split = !split;
paint();
}

<main class="stage">
<div class="sd is-demo" id="sd">
<div class="sd__shell" id="sdShell">
<div class="sd__half sd__half--l" aria-hidden="true"></div>
<div class="sd__half sd__half--r" aria-hidden="true"></div>
<div class="sd__face sd__face--rest" id="sdRest">
<span class="sd__label">항목 값 삭제</span>
</div>
<div class="sd__face sd__face--split" id="sdSplit">
<button type="button" class="sd__choice" id="sdNo">그만두기</button>
<button type="button" class="sd__choice sd__choice--danger" id="sdYes">지우기</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 두 절반이 PART_PX(26px) 만큼 각자 바깥으로 벌어지고(translateX ±), 그 사이로
// 안의 선택 값 두 개가 크로스페이드로 뒤바뀐다(index.html). 절반 폭은 셸 폭 50% 실측.
@include stage($stage-ink);
.sd {
display: grid;
justify-items: center;
}
.sd__shell {
position: relative;
width: 240px;
height: 56px;
cursor: pointer;
}
.sd__half {
position: absolute;
top: 0;
width: calc(var(--shell-w, 240px) / 2);
height: 100%;
border-radius: $r-pill;
background: $error-soft;
transition: transform $dur-enter $ease-elastic, background-color $dur-quick $ease-out;
&--l { left: 0; border-top-right-radius: 0; border-bottom-right-radius: 0; }
&--r { right: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; }
}
.sd__face {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-3;
}
.sd__face--rest {
transition: opacity $dur-quick $ease-out;
}
.sd__label {
color: $error;
font-weight: 700;
font-size: 16px;
}
.sd__face--split {
opacity: 0;
transition: opacity $dur-quick $ease-out;
}
.sd__choice {
border: 0;
border-radius: $r-pill;
background: $stage-paper;
color: $subject-ink;
font-weight: 600;
font-size: 13px;
padding: $sp-2 $sp-4;
cursor: pointer;
&--danger { background: $error; color: $subject-cream; }
}
.sd--split {
.sd__half--l { transform: translateX(-26px); background: $error-soft; }
.sd__half--r { transform: translateX(26px); }
.sd__face--rest { opacity: 0; }
.sd__face--split { opacity: 1; }
}
@media (max-width: 340px) {
.sd__shell { width: 210px; height: 52px; }
}
@media (prefers-reduced-motion: reduce) {
.sd__half, .sd__face--rest, .sd__face--split { 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);
}
.sd {
display: grid;
justify-items: center;
}
.sd__shell {
position: relative;
width: 240px;
height: 56px;
cursor: pointer;
}
.sd__half {
position: absolute;
top: 0;
width: calc(var(--shell-w, 240px) / 2);
height: 100%;
border-radius: 999px;
background: #ffe0d8;
transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1), background-color 220ms ease-out;
}
.sd__half--l {
left: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.sd__half--r {
right: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.sd__face {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.sd__face--rest {
transition: opacity 220ms ease-out;
}
.sd__label {
color: #c2361a;
font-weight: 700;
font-size: 16px;
}
.sd__face--split {
opacity: 0;
transition: opacity 220ms ease-out;
}
.sd__choice {
border: 0;
border-radius: 999px;
background: #ffffff;
color: #17141a;
font-weight: 600;
font-size: 13px;
padding: 8px 16px;
cursor: pointer;
}
.sd__choice--danger {
background: #c2361a;
color: #fff7e6;
}
.sd--split .sd__half--l {
transform: translateX(-26px);
background: #ffe0d8;
}
.sd--split .sd__half--r {
transform: translateX(26px);
}
.sd--split .sd__face--rest {
opacity: 0;
}
.sd--split .sd__face--split {
opacity: 1;
}
@media (max-width: 340px) {
.sd__shell {
width: 210px;
height: 52px;
}
}
@media (prefers-reduced-motion: reduce) {
.sd__half, .sd__face--rest, .sd__face--split {
transition: none;
}
}(function () {
var root = document.getElementById('sd');
var shell = document.getElementById('sdShell');
var PART_PX = 26; // 계산 겹: 두 절반이 벌어지는 거리(px) — 셸 폭 절반 기준 산술
// 계산 겹: 셸 폭을 getBoundingClientRect 로 실측해 --shell-w 로 넘긴다.
// 절반은 각자 셸 폭의 50% 로 그려지므로 벌어짐 거리도 이 값에 맞춘다.
function measure() {
shell.style.setProperty('--shell-w', shell.getBoundingClientRect().width + 'px');
}
var split = false;
function paint() {
root.classList.toggle('sd--split', split);
shell.setAttribute('aria-expanded', String(split));
}
function toggle() {
split = !split;
paint();
}
shell.addEventListener('click', function (e) {
if (e.target.closest('.sd__choice')) return;
root.classList.remove('is-demo');
stopAutoplay();
if (!split) toggle();
});
document.getElementById('sdNo').addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
if (split) toggle();
});
document.getElementById('sdYes').addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
if (split) toggle();
});
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
measure();
if (!reduce) { autoTimer = setInterval(toggle, 1400); }
else { split = true; paint(); }
})();import { useEffect, useRef } from "react";
import styles from "./SplitDualReveal.module.scss";
type Box<T> = { current: T };
const PART_PX = 26; // 계산 겹: 두 절반이 벌어지는 거리(px) — 셸 폭 절반 기준 산술
export default function SplitDualReveal() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const shell = root.querySelector(`.${styles.shell}`) as HTMLElement | null;
if (!shell) return;
function measure() {
shell!.style.setProperty("--shell-w", shell!.getBoundingClientRect().width + "px");
}
let split = false;
function paint() {
root!.classList.toggle(styles["root--split"], split);
shell!.setAttribute("aria-expanded", String(split));
}
function toggle() {
split = !split;
paint();
}
function onShellClick(e: Event) {
const t = e.target as HTMLElement;
if (t.closest("." + styles.choice)) return;
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
if (!split) toggle();
}
function onChoice() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
if (split) toggle();
}
shell.addEventListener("click", onShellClick);
root.querySelectorAll("." + styles.choice).forEach((b) => {
b.addEventListener("click", onChoice);
});
let autoTimer: number | null = null;
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
measure();
if (!reduce) { autoTimer = window.setInterval(toggle, 1400); }
else { split = true; paint(); }
return () => {
stopAutoplay();
shell.removeEventListener("click", onShellClick);
root.querySelectorAll("." + styles.choice).forEach((b) => {
b.removeEventListener("click", onChoice);
});
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.sd} ${styles["is-demo"]}`}>
<div className={styles.shell} aria-expanded="false">
<div className={`${styles.half} ${styles["half--l"]}`} aria-hidden="true" />
<div className={`${styles.half} ${styles["half--r"]}`} aria-hidden="true" />
<div className={`${styles.face} ${styles["face--rest"]}`}>
<span className={styles.label}>항목 값 삭제</span>
</div>
<div className={`${styles.face} ${styles["face--split"]}`}>
<button type="button" className={styles.choice}>그만두기</button>
<button type="button" className={`${styles.choice} ${styles["choice--danger"]}`}>지우기</button>
</div>
</div>
</div>
);
}05 · 둘로 갈라짐 전환 — 확정 값이 켜지면 버튼이 두 조각으로 갈라지며 각각 다른 값을 드러내는 방식 — 측정 누적면적 6.995% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06알약에서 막대로 전환 — 찾기 값이 켜지면 알약 모양 값이 막대 모양 값으로 바뀌며 입력 자리가 생기는 방식
SEEDS 배열은 시드가 고정된 로컬 값이라 실제 검색 API를 부르지 않고, paintHints()가 입력 값이 있을 때만 두 개까지 힌트 값을 켭니다. 머리글의 찾기 단추, 좁은 화면의 찾기 열기에 쓰는 계산입니다.
// 시드 고정 로컬 값 — 실제 검색 API 는 쓰지 않는다
var SEEDS = ['샘플 값 1', '샘플 값 2', '샘플 값 3'];
function paintHints() {
var hints = root.querySelectorAll('.ps__hint');
var q = input.value.trim();
var n = q ? Math.min(2, SEEDS.length) : 0;
hints.forEach(function (h, i) {
h.classList.toggle('ps__hint--on', i < n);
if (i < n) h.textContent = SEEDS[i];
});
}

<main class="stage">
<div class="ps is-demo" id="ps">
<div class="ps__bar" id="psBar">
<span class="ps__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="2.4"/><line x1="15.5" y1="15.5" x2="21" y2="21" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>
</span>
<input class="ps__input" id="psInput" type="text" placeholder="찾기 값을 입력" aria-label="찾기 값 입력">
<button type="button" class="ps__close" id="psClose" aria-label="찾기 값 닫기">
<svg viewBox="0 0 24 24" width="14" height="14"><line x1="5" y1="5" x2="19" y2="19" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/><line x1="19" y1="5" x2="5" y2="19" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>
</button>
</div>
<ul class="ps__hints" id="psHints" aria-hidden="true">
<li class="ps__hint">샘플 값 1</li>
<li class="ps__hint">샘플 값 2</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 폭 확장은 transition(width) 이 아니라 index.html 이 실측한 --input-w 값으로
// 커서 자리를 잡고, 힌트 목록은 시드 고정 로컬 배열로만 그린다(실 fetch 0).
@include stage($stage-yellow);
.ps {
display: grid;
gap: $sp-2;
justify-items: center;
}
.ps__bar {
display: inline-flex;
align-items: center;
gap: $sp-2;
width: 56px;
height: 44px;
padding: 0 $sp-2;
box-sizing: border-box;
border-radius: $r-pill;
background: $subject-ink;
overflow: hidden;
transition: width 700ms $ease-spring, padding 700ms $ease-spring;
cursor: pointer;
}
.ps--open .ps__bar {
width: 320px;
padding: 0 $sp-3;
cursor: default;
}
.ps__icon {
display: inline-flex;
color: $stage-yellow;
flex: 0 0 auto;
}
.ps__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
color: $subject-cream;
font-size: 14px;
font-weight: 500;
opacity: 0;
transition: opacity $dur-quick $ease-out;
}
.ps__input:focus { outline: none; }
.ps--open .ps__input { opacity: 1; }
.ps__input::placeholder { color: rgba($subject-cream, .72); }
.ps.is-demo .ps__input::placeholder { color: transparent; }
.ps__close {
display: grid;
place-items: center;
width: 28px;
height: 28px;
flex: 0 0 auto;
border: 0;
border-radius: 50%;
background: rgba($stage-yellow, .18);
color: $stage-yellow;
cursor: pointer;
opacity: 0;
transform: rotate(-90deg);
transition: opacity $dur-quick $ease-out, transform $dur-base $ease-elastic;
}
.ps--open .ps__close { opacity: 1; transform: rotate(0deg); }
.ps__hints {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: $sp-1;
width: 320px;
}
.ps__hint {
padding: $sp-2 $sp-3;
border-radius: $r-control;
background: rgba($subject-ink, .9);
color: $subject-cream;
font-size: 13px;
opacity: 0;
transform: translateY(-4px);
transition: opacity $dur-quick $ease-out, transform $dur-quick $ease-out;
}
.ps__hint--on { opacity: 1; transform: translateY(0); }
@media (max-width: 340px) {
.ps--open .ps__bar { width: 260px; }
.ps__hints { width: 260px; }
}
@media (prefers-reduced-motion: reduce) {
.ps__bar, .ps__input, .ps__close, .ps__hint { transition: none; }
}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);
}
.ps {
display: grid;
gap: 8px;
justify-items: center;
}
.ps__bar {
display: inline-flex;
align-items: center;
gap: 8px;
width: 56px;
height: 44px;
padding: 0 8px;
box-sizing: border-box;
border-radius: 999px;
background: #17141a;
overflow: hidden;
transition: width 700ms cubic-bezier(0.2, 0.8, 0.2, 1), padding 700ms cubic-bezier(0.2, 0.8, 0.2, 1);
cursor: pointer;
}
.ps--open .ps__bar {
width: 320px;
padding: 0 12px;
cursor: default;
}
.ps__icon {
display: inline-flex;
color: #ffd23f;
flex: 0 0 auto;
}
.ps__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
color: #fff7e6;
font-size: 14px;
font-weight: 500;
opacity: 0;
transition: opacity 220ms ease-out;
}
.ps__input:focus {
outline: none;
}
.ps--open .ps__input {
opacity: 1;
}
.ps__input::placeholder {
color: rgba(255, 247, 230, 0.72);
}
.ps.is-demo .ps__input::placeholder {
color: transparent;
}
.ps__close {
display: grid;
place-items: center;
width: 28px;
height: 28px;
flex: 0 0 auto;
border: 0;
border-radius: 50%;
background: rgba(255, 210, 63, 0.18);
color: #ffd23f;
cursor: pointer;
opacity: 0;
transform: rotate(-90deg);
transition: opacity 220ms ease-out, transform 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.ps--open .ps__close {
opacity: 1;
transform: rotate(0deg);
}
.ps__hints {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
width: 320px;
}
.ps__hint {
padding: 8px 12px;
border-radius: 12px;
background: rgba(23, 20, 26, 0.9);
color: #fff7e6;
font-size: 13px;
opacity: 0;
transform: translateY(-4px);
transition: opacity 220ms ease-out, transform 220ms ease-out;
}
.ps__hint--on {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 340px) {
.ps--open .ps__bar {
width: 260px;
}
.ps__hints {
width: 260px;
}
}
@media (prefers-reduced-motion: reduce) {
.ps__bar, .ps__input, .ps__close, .ps__hint {
transition: none;
}
}(function () {
var root = document.getElementById('ps');
var bar = document.getElementById('psBar');
var input = document.getElementById('psInput');
var closeBtn = document.getElementById('psClose');
var BAR_W = 320; // 목표 막대 폭(px)
// 계산 겹: 열릴 때 입력 폭을 getBoundingClientRect 로 실측해 커서가 놓일 자리를 잡는다.
// 폰트·패딩이 달라져도 열림 폭이 글자보다 좁아지지 않게 max 로 비교한다.
var open = false;
function paint() {
root.classList.toggle('ps--open', open);
bar.setAttribute('aria-expanded', String(open));
if (open) {
// 열린 직후 실측: 입력 요소 폭이 0 이면(아직 숨김) 측정을 다음 프레임으로 미룬다
var w = input.getBoundingClientRect().width || 1;
bar.style.setProperty('--input-w', w + 'px');
input.focus({ preventScroll: true });
}
}
function toggle() {
open = !open;
paint();
}
closeBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
if (open) toggle();
});
input.addEventListener('input', function () {
root.classList.remove('is-demo');
stopAutoplay();
paintHints();
});
input.addEventListener('focus', function () {
root.classList.remove('is-demo');
stopAutoplay();
});
// 시드 고정 로컬 값 — 실제 검색 API 는 쓰지 않는다
var SEEDS = ['샘플 값 1', '샘플 값 2', '샘플 값 3'];
function paintHints() {
var hints = root.querySelectorAll('.ps__hint');
var q = input.value.trim();
var n = q ? Math.min(2, SEEDS.length) : 0;
hints.forEach(function (h, i) {
h.classList.toggle('ps__hint--on', i < n);
if (i < n) h.textContent = SEEDS[i];
});
}
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
var step = 0;
var TYPES = ['', '샘', ''];
autoTimer = setInterval(function () {
open = (step % 2 === 0);
input.value = TYPES[(step >> 1) % TYPES.length];
paint();
paintHints();
step++;
}, 1300);
} else { open = true; input.value = ''; paint(); }
})();import { useEffect, useRef } from "react";
import styles from "./PillToBarSearch.module.scss";
type Box<T> = { current: T };
// 시드 고정 로컬 값 — 실제 검색 API 는 쓰지 않는다
const SEEDS = ["샘플 값 1", "샘플 값 2", "샘플 값 3"];
export default function PillToBarSearch() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const bar = root.querySelector(`.${styles.bar}`) as HTMLElement | null;
const input = root.querySelector(`.${styles.input}`) as HTMLInputElement | null;
const closeBtn = root.querySelector(`.${styles.close}`) as HTMLButtonElement | null;
if (!bar || !input || !closeBtn) return;
let open = false;
function paint() {
root!.classList.toggle(styles["root--open"], open);
bar!.setAttribute("aria-expanded", String(open));
if (open) {
const w = input!.getBoundingClientRect().width || 1;
bar!.style.setProperty("--input-w", w + "px");
input!.focus({ preventScroll: true });
}
}
function toggle() {
open = !open;
paint();
}
function paintHints() {
const hints = root!.querySelectorAll("." + styles.hint);
const q = input!.value.trim();
const n = q ? Math.min(2, SEEDS.length) : 0;
hints.forEach((h, i) => {
h.classList.toggle(styles["hint--on"], i < n);
if (i < n) (h as HTMLElement).textContent = SEEDS[i];
});
}
function onClose() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
if (open) toggle();
}
function onInput() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
paintHints();
}
function onFocus() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
}
closeBtn.addEventListener("click", onClose);
input.addEventListener("input", onInput);
input.addEventListener("focus", onFocus);
let autoTimer: number | null = null;
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
let step = 0;
const TYPES = ["", "샘", ""];
autoTimer = window.setInterval(() => {
open = (step % 2 === 0);
input!.value = TYPES[(step >> 1) % TYPES.length];
paint();
paintHints();
step++;
}, 1300);
} else { open = true; input!.value = ""; paint(); }
return () => {
stopAutoplay();
closeBtn.removeEventListener("click", onClose);
input.removeEventListener("input", onInput);
input.removeEventListener("focus", onFocus);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.ps} ${styles["is-demo"]}`}>
<div className={styles.bar} aria-expanded="false">
<span className={styles.icon} aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" strokeWidth="2.4" /><line x1="15.5" y1="15.5" x2="21" y2="21" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" /></svg>
</span>
<input className={styles.input} type="text" placeholder="찾기 값을 입력" aria-label="찾기 값 입력" />
<button type="button" className={styles.close} aria-label="찾기 값 닫기">
<svg viewBox="0 0 24 24" width="14" height="14"><line x1="5" y1="5" x2="19" y2="19" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" /><line x1="19" y1="5" x2="5" y2="19" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" /></svg>
</button>
</div>
<ul className={styles.hints} aria-hidden="true">
<li className={styles.hint}>샘플 값 1</li>
<li className={styles.hint}>샘플 값 2</li>
</ul>
</div>
);
}06 · 알약에서 막대로 전환 — 찾기 값이 켜지면 알약 모양 값이 막대 모양 값으로 바뀌며 입력 자리가 생기는 방식 — 측정 누적면적 8.393% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07겹짐 들어올림 전환 — 자세히 값이 켜지면 뒤에 겹쳐 있던 같은 모양 값들이 위로 차례로 들리는 방식
STEP_PX(34px)와 STAGGER_MS(80ms)가 겹친 카드마다 다른 y 오프셋과 지연 값을 정해, 뒤에 있는 카드일수록 늦게 들리는 순서를 transitionDelay로 만듭니다. 카드 더보기 단추, 알림 목록 열기에 쓰는 계산입니다.
var STEP_PX = 34; // 계산 겹: 겹침 카드 하나가 위로 들리는 y 오프셋(px) — 스프링 감쇠로 산출
var STAGGER_MS = 80; // 계산 겹: 뒤 카드부터 이 값씩 늦게 들린다
ghosts.forEach(function (g, i) {
g.style.transitionDelay = ((ghosts.length - 1 - i) * STAGGER_MS) + 'ms';
});

<main class="stage">
<div class="sl is-demo" id="sl">
<div class="sl__pile" id="slPile">
<div class="sl__ghost" style="--n: 3"></div>
<div class="sl__ghost" style="--n: 2"></div>
<div class="sl__ghost" style="--n: 1"></div>
<div class="sl__card">
<span class="sl__title">알림 값 묶음</span>
<span class="sl__sub">뒤에 값 3개 겹침</span>
</div>
</div>
<button type="button" class="sl__btn" id="slBtn" aria-expanded="false">
<span class="sl__label">자세히 값 열기</span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 겹친 카드의 y 오프셋이 --n(뒤에서부터의 차례) * STEP_PX(34px) 산술로 결정되고,
// 들림은 $ease-lerp-settle 표본곡선(오버슈트 후 빠른 정착)으로 스프링 감쇠를 흉내낸다.
@include stage($stage-orange);
.sl {
display: grid;
gap: $sp-5;
justify-items: center;
}
.sl__pile {
position: relative;
width: 230px;
height: 118px;
}
.sl__ghost {
position: absolute;
inset: 0;
border-radius: $r-card;
background: rgba($stage-paper, .35);
border: 1px solid rgba($stage-paper, .5);
transform: translateY(calc((4 - var(--n)) * 4px));
transition: transform $dur-enter $ease-lerp-settle;
}
.sl__card {
position: absolute;
inset: 0;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
display: grid;
align-content: center;
gap: $sp-1;
padding: 0 $sp-5;
box-sizing: border-box;
}
.sl__title {
color: $subject-ink;
font-weight: 700;
font-size: 15px;
}
.sl__sub {
color: $subject-orange-deep;
font-weight: 600;
font-size: 12px;
}
.sl__btn {
border: 0;
border-radius: $r-pill;
background: $stage-paper;
color: $subject-ink;
padding: $sp-2 $sp-5;
font-weight: 600;
font-size: 14px;
cursor: pointer;
}
.sl--open {
.sl__ghost {
transform: translateY(calc(-1 * var(--n) * 34px)) scale(calc(1 - var(--n) * .03));
}
}
@media (max-width: 340px) {
.sl__pile { width: 200px; height: 108px; }
}
@media (prefers-reduced-motion: reduce) {
.sl__ghost { 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);
}
.sl {
display: grid;
gap: 20px;
justify-items: center;
}
.sl__pile {
position: relative;
width: 230px;
height: 118px;
}
.sl__ghost {
position: absolute;
inset: 0;
border-radius: 16px;
background: rgba(255, 255, 255, 0.35);
border: 1px solid rgba(255, 255, 255, 0.5);
transform: translateY(calc((4 - var(--n)) * 4px));
transition: transform 600ms linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1);
}
.sl__card {
position: absolute;
inset: 0;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: grid;
align-content: center;
gap: 4px;
padding: 0 20px;
box-sizing: border-box;
}
.sl__title {
color: #17141a;
font-weight: 700;
font-size: 15px;
}
.sl__sub {
color: #c93000;
font-weight: 600;
font-size: 12px;
}
.sl__btn {
border: 0;
border-radius: 999px;
background: #ffffff;
color: #17141a;
padding: 8px 20px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
}
.sl--open .sl__ghost {
transform: translateY(calc(-1 * var(--n) * 34px)) scale(calc(1 - var(--n) * 0.03));
}
@media (max-width: 340px) {
.sl__pile {
width: 200px;
height: 108px;
}
}
@media (prefers-reduced-motion: reduce) {
.sl__ghost {
transition: none;
}
}(function () {
var root = document.getElementById('sl');
var btn = document.getElementById('slBtn');
var ghosts = root.querySelectorAll('.sl__ghost');
var sub = root.querySelector('.sl__sub');
var STEP_PX = 34; // 계산 겹: 겹침 카드 하나가 위로 들리는 y 오프셋(px) — 스프링 감쇠로 산출
var STAGGER_MS = 80; // 계산 겹: 뒤 카드부터 이 값씩 늦게 들린다
ghosts.forEach(function (g, i) {
g.style.transitionDelay = ((ghosts.length - 1 - i) * STAGGER_MS) + 'ms';
});
var open = false;
function paint() {
root.classList.toggle('sl--open', open);
btn.setAttribute('aria-expanded', String(open));
sub.textContent = open ? '값 3개 펼침' : '뒤에 값 3개 겹침';
}
function toggle() {
open = !open;
paint();
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
toggle();
});
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(toggle, 1400); }
else { open = true; paint(); }
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./StackCardLift.module.scss";
type Box<T> = { current: T };
const STEP_PX = 34; // 계산 겹: 겹침 카드 하나가 위로 들리는 y 오프셋(px) — 스프링 감쇠로 산출
const STAGGER_MS = 80; // 계산 겹: 뒤 카드부터 이 값씩 늦게 들린다
export default function StackCardLift() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector(`.${styles.btn}`) as HTMLButtonElement | null;
const ghosts = root.querySelectorAll(`.${styles.ghost}`);
const sub = root.querySelector(`.${styles.sub}`) as HTMLElement | null;
if (!btn) return;
ghosts.forEach((g, i) => {
(g as HTMLElement).style.transitionDelay = ((ghosts.length - 1 - i) * STAGGER_MS) + "ms";
});
let open = false;
function paint() {
root!.classList.toggle(styles["root--open"], open);
btn!.setAttribute("aria-expanded", String(open));
if (sub) sub.textContent = open ? "값 3개 펼침" : "뒤에 값 3개 겹침";
}
function toggle() {
open = !open;
paint();
}
function onClick() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
toggle();
}
btn.addEventListener("click", onClick);
let autoTimer: number | null = null;
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(toggle, 1400); }
else { open = true; paint(); }
return () => {
stopAutoplay();
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.sl} ${styles["is-demo"]}`}>
<div className={styles.pile}>
<div className={styles.ghost} style={{ "--n": 3 } as CSSProperties} />
<div className={styles.ghost} style={{ "--n": 2 } as CSSProperties} />
<div className={styles.ghost} style={{ "--n": 1 } as CSSProperties} />
<div className={styles.card}>
<span className={styles.title}>알림 값 묶음</span>
<span className={styles.sub}>뒤에 값 3개 겹침</span>
</div>
</div>
<button type="button" className={styles.btn} aria-expanded="false">
<span className={styles.label}>자세히 값 열기</span>
</button>
</div>
);
}07 · 겹짐 들어올림 전환 — 자세히 값이 켜지면 뒤에 겹쳐 있던 같은 모양 값들이 위로 차례로 들리는 방식 — 측정 누적면적 12.562% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08표시와 이름 나타남 전환 — 초점 값이 켜지면 표시 크기 값이 커지고 이름 너비 값이 늘어나는 방식
measure()가 paint()를 돌 때마다 이름 값의 실제 폭을 다시 실측해 --label-w로 넘기고, 표시 값이 커지는 계산은 ig--on 클래스가 붙을 때 걸리는 CSS scale(1.25)(ICON_ON과 같은 값)가 맡습니다. 둥근 도구 단추, 이름 나타나는 동작 막대에 쓰는 계산입니다.
var ICON_ON = 1.25, ICON_OFF = 1; // 계산 겹: 표시 스케일 값 — 초점 값에 따라 곱해진다
// 계산 겹: 라벨 폭을 getBoundingClientRect 로 실측해 --label-w 로 넘긴다.
// max-width 전환은 실측 폭과 짝이어야 글자가 중간에 잘리지 않는다.
function measure() {
var prev = label.style.maxWidth;
label.style.maxWidth = 'none';
var w = Math.ceil(label.getBoundingClientRect().width);
label.style.maxWidth = prev || '0px';
tool.style.setProperty('--label-w', w + 'px');
}
var on = false;
function paint() {
measure();
root.classList.toggle('ig--on', on);
tool.setAttribute('aria-pressed', String(on));
}
<main class="stage">
<div class="ig is-demo" id="ig">
<div class="ig__bar" role="toolbar" aria-label="동작 값 모음">
<button type="button" class="ig__tool" id="igTool" aria-pressed="false">
<span class="ig__ic" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20">
<path class="ig__body" d="M4 20h16v-2.2c0-1.9-3.6-3.3-8-3.3s-8 1.4-8 3.3V20z" />
<circle class="ig__head" cx="12" cy="8" r="4" />
</svg>
</span>
<span class="ig__label" id="igLabel">프로필 값</span>
</button>
<span class="ig__peer" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18"><circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="2.2"/></svg>
</span>
<span class="ig__peer" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18"><rect x="5" y="5" width="14" height="14" rx="3" fill="none" stroke="currentColor" stroke-width="2.2"/></svg>
</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 표시 스케일(ICON_ON 1.25 곱)과 라벨 폭(실측 --label-w)이 초점 값에 따라
// 같은 easing 으로 이어져 늘어난다(index.html). 라벨은 폭 안에서 mask 로 밀려난다.
@include stage($stage-ink);
.ig {
display: grid;
justify-items: center;
}
.ig__bar {
display: inline-flex;
align-items: center;
gap: $sp-2;
padding: $sp-2;
border-radius: $r-pill;
background: rgba($subject-cream, .08);
}
.ig__tool {
display: inline-flex;
align-items: center;
gap: $sp-2;
border: 0;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
padding: $sp-2;
cursor: pointer;
transition: padding $dur-enter $ease-elastic, background-color $dur-quick $ease-out;
}
.ig__ic {
display: grid;
place-items: center;
width: 28px;
height: 28px;
transition: transform $dur-base $ease-elastic;
}
.ig--on .ig__ic { transform: scale(1.25); }
.ig__body { fill: $subject-blue; }
.ig__head { fill: $subject-blue-light; }
.ig__label {
max-width: 0;
overflow: hidden;
white-space: nowrap;
font-weight: 600;
font-size: 13px;
opacity: 0;
transition: max-width $dur-enter $ease-elastic, opacity $dur-quick $ease-out, padding $dur-enter $ease-elastic;
}
.ig--on .ig__label {
max-width: var(--label-w, 96px);
opacity: 1;
padding-right: $sp-2;
}
.ig__peer {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 50%;
color: rgba($subject-cream, .72);
}
@media (max-width: 340px) {
.ig__bar { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.ig__tool, .ig__ic, .ig__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ig {
display: grid;
justify-items: center;
}
.ig__bar {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.08);
}
.ig__tool {
display: inline-flex;
align-items: center;
gap: 8px;
border: 0;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
padding: 8px;
cursor: pointer;
transition: padding 600ms cubic-bezier(0.34, 1.56, 0.64, 1), background-color 220ms ease-out;
}
.ig__ic {
display: grid;
place-items: center;
width: 28px;
height: 28px;
transition: transform 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.ig--on .ig__ic {
transform: scale(1.25);
}
.ig__body {
fill: #2f6df6;
}
.ig__head {
fill: #5b8cf8;
}
.ig__label {
max-width: 0;
overflow: hidden;
white-space: nowrap;
font-weight: 600;
font-size: 13px;
opacity: 0;
transition: max-width 600ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 220ms ease-out, padding 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.ig--on .ig__label {
max-width: var(--label-w, 96px);
opacity: 1;
padding-right: 8px;
}
.ig__peer {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 50%;
color: rgba(255, 247, 230, 0.72);
}
@media (max-width: 340px) {
.ig__bar {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.ig__tool, .ig__ic, .ig__label {
transition: none;
}
}(function () {
var root = document.getElementById('ig');
var tool = document.getElementById('igTool');
var label = document.getElementById('igLabel');
var ICON_ON = 1.25, ICON_OFF = 1; // 계산 겹: 표시 스케일 값 — 초점 값에 따라 곱해진다
// 계산 겹: 라벨 폭을 getBoundingClientRect 로 실측해 --label-w 로 넘긴다.
// max-width 전환은 실측 폭과 짝이어야 글자가 중간에 잘리지 않는다.
function measure() {
var prev = label.style.maxWidth;
label.style.maxWidth = 'none';
var w = Math.ceil(label.getBoundingClientRect().width);
label.style.maxWidth = prev || '0px';
tool.style.setProperty('--label-w', w + 'px');
}
var on = false;
function paint() {
measure();
root.classList.toggle('ig--on', on);
tool.setAttribute('aria-pressed', String(on));
}
function toggle() {
on = !on;
paint();
}
tool.addEventListener('pointerenter', function () {
root.classList.remove('is-demo');
stopAutoplay();
if (!on) toggle();
});
tool.addEventListener('pointerleave', function () {
if (autoTimer === null && on) toggle();
});
tool.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
toggle();
});
tool.addEventListener('focus', function () {
root.classList.remove('is-demo');
stopAutoplay();
});
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(toggle, 1300); }
else { on = true; paint(); }
})();import { useEffect, useRef } from "react";
import styles from "./IconGrowLabel.module.scss";
type Box<T> = { current: T };
const ICON_ON = 1.25, ICON_OFF = 1; // 계산 겹: 표시 스케일 값 — 초점 값에 따라 곱해진다
export default function IconGrowLabel() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const tool = root.querySelector(`.${styles.tool}`) as HTMLButtonElement | null;
const label = root.querySelector(`.${styles.label}`) as HTMLElement | null;
if (!tool || !label) return;
function measure() {
const prev = label!.style.maxWidth;
label!.style.maxWidth = "none";
const w = Math.ceil(label!.getBoundingClientRect().width);
label!.style.maxWidth = prev || "0px";
tool!.style.setProperty("--label-w", w + "px");
}
let on = false;
function paint() {
measure();
root!.classList.toggle(styles["root--on"], on);
tool!.setAttribute("aria-pressed", String(on));
}
function toggle() {
on = !on;
paint();
}
function onEnter() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
if (!on) toggle();
}
function onLeave() {
if (autoTimer === null && on) toggle();
}
function onClick() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
toggle();
}
function onFocus() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
}
tool.addEventListener("pointerenter", onEnter);
tool.addEventListener("pointerleave", onLeave);
tool.addEventListener("click", onClick);
tool.addEventListener("focus", onFocus);
let autoTimer: number | null = null;
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(toggle, 1300); }
else { on = true; paint(); }
return () => {
stopAutoplay();
tool.removeEventListener("pointerenter", onEnter);
tool.removeEventListener("pointerleave", onLeave);
tool.removeEventListener("click", onClick);
tool.removeEventListener("focus", onFocus);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.ig} ${styles["is-demo"]}`}>
<div className={styles.bar} role="toolbar" aria-label="동작 값 모음">
<button type="button" className={styles.tool} aria-pressed="false">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20">
<path className={styles.body} d="M4 20h16v-2.2c0-1.9-3.6-3.3-8-3.3s-8 1.4-8 3.3V20z" />
<circle className={styles.head} cx="12" cy="8" r="4" />
</svg>
</span>
<span className={styles.label}>프로필 값</span>
</button>
<span className={styles.peer} aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18"><circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" strokeWidth="2.2" /></svg>
</span>
<span className={styles.peer} aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18"><rect x="5" y="5" width="14" height="14" rx="3" fill="none" stroke="currentColor" strokeWidth="2.2" /></svg>
</span>
</div>
</div>
);
}08 · 표시와 이름 나타남 전환 — 초점 값이 켜지면 표시 크기 값이 커지고 이름 너비 값이 늘어나는 방식 — 측정 누적면적 1.013% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09되튐 복귀 전환 — 접힘 값이 켜지면 형태 값이 줄어들다가 반대로 살짝 늘었다가 제 형태로 돌아가는 방식
tick()이 K(190)·D(14) 스프링 값으로 scaleX를 계산해 눌렀다 놓는 순간 형태 값이 줄었다가 반대로 살짝 늘었다가 돌아오고, --press 값이 같은 계산을 칩 목록의 opacity·위치에도 물립니다. 필터 접기 단추, 댓글 창 닫기에 쓰는 계산입니다.
var K = 190, D = 14; // 계산 겹: 스프링 상수·감쇠 — 눌렀다 놓는 순간의 되튐 값을 정한다
var collapsed = false;
var cur = 1, vel = 0, target = 1;
var raf = null;
// 스프링 감쇠 계산: 매 프레임 a = -K*(cur-target) - D*vel — 줄어들었다가 반대로 살짝 늘었다가 제 형태
function tick() {
var a = -K * (cur - target) * 0.001 - D * vel * 0.01;
vel += a;
cur += vel;
if (Math.abs(cur - target) < 0.004 && Math.abs(vel) < 0.005) { cur = target; vel = 0; }
btn.style.transform = 'scaleX(' + cur.toFixed(3) + ')';
var p = Math.max(0, Math.min(1, (1 - cur) / 0.28));
root.style.setProperty('--press', p.toFixed(3));
raf = requestAnimationFrame(tick);
}
function toggle() {
collapsed = !collapsed;
target = collapsed ? 0.72 : 1;
btn.setAttribute('aria-pressed', String(collapsed));
root.classList.toggle('eb--collapsed', collapsed);
}

<main class="stage">
<div class="eb is-demo" id="eb">
<button type="button" class="eb__btn" id="ebBtn" aria-pressed="false">
<span class="eb__label">필터 값 접기</span>
</button>
<div class="eb__chipRow" id="ebRow" aria-hidden="true">
<span class="eb__chip">값 걸러내기 1</span>
<span class="eb__chip">값 걸러내기 2</span>
<span class="eb__chip">값 걸러내기 3</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 형태 값(scaleX)이 CSS transition 이 아니라 index.html 의 스프링 감쇠 계산(K·D)으로
// 매 프레임 결정된다 — 줄어들었다가 반대로 살짝 늘었다가 제 형태로 되돌아온다.
@include stage($stage-paper);
.eb {
display: grid;
gap: $sp-4;
justify-items: center;
}
.eb__btn {
display: inline-grid;
place-items: center;
width: 220px;
height: 56px;
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 700;
font-size: 15px;
cursor: pointer;
box-shadow: $shadow-raised;
will-change: transform;
transform-origin: center;
}
.eb__label {
pointer-events: none;
}
.eb__chipRow {
display: flex;
gap: $sp-2;
opacity: calc(1 - var(--press, 0));
transform: translateY(calc(var(--press, 0) * 8px));
transition: opacity $dur-quick $ease-out;
}
.eb__chip {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba($subject-blue-deep, .12);
color: $subject-ink;
font-weight: 600;
font-size: 12px;
}
@media (max-width: 340px) {
.eb__btn { width: 190px; height: 50px; }
.eb__chipRow { flex-wrap: wrap; justify-content: center; }
}
@media (prefers-reduced-motion: reduce) {
.eb__chipRow { 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);
}
.eb {
display: grid;
gap: 16px;
justify-items: center;
}
.eb__btn {
display: inline-grid;
place-items: center;
width: 220px;
height: 56px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 15px;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
will-change: transform;
transform-origin: center;
}
.eb__label {
pointer-events: none;
}
.eb__chipRow {
display: flex;
gap: 8px;
opacity: calc(1 - var(--press, 0));
transform: translateY(calc(var(--press, 0) * 8px));
transition: opacity 220ms ease-out;
}
.eb__chip {
padding: 4px 12px;
border-radius: 999px;
background: rgba(10, 79, 232, 0.12);
color: #17141a;
font-weight: 600;
font-size: 12px;
}
@media (max-width: 340px) {
.eb__btn {
width: 190px;
height: 50px;
}
.eb__chipRow {
flex-wrap: wrap;
justify-content: center;
}
}
@media (prefers-reduced-motion: reduce) {
.eb__chipRow {
transition: none;
}
}(function () {
var root = document.getElementById('eb');
var btn = document.getElementById('ebBtn');
var K = 190, D = 14; // 계산 겹: 스프링 상수·감쇠 — 눌렀다 놓는 순간의 되튐 값을 정한다
var collapsed = false;
var cur = 1, vel = 0, target = 1;
var raf = null;
// 스프링 감쇠 계산: 매 프레임 a = -K*(cur-target) - D*vel — 줄어들었다가 반대로 살짝 늘었다가 제 형태
function tick() {
var a = -K * (cur - target) * 0.001 - D * vel * 0.01;
vel += a;
cur += vel;
if (Math.abs(cur - target) < 0.004 && Math.abs(vel) < 0.005) { cur = target; vel = 0; }
btn.style.transform = 'scaleX(' + cur.toFixed(3) + ')';
var p = Math.max(0, Math.min(1, (1 - cur) / 0.28));
root.style.setProperty('--press', p.toFixed(3));
raf = requestAnimationFrame(tick);
}
function toggle() {
collapsed = !collapsed;
target = collapsed ? 0.72 : 1;
btn.setAttribute('aria-pressed', String(collapsed));
root.classList.toggle('eb--collapsed', collapsed);
}
btn.addEventListener('pointerdown', function () {
root.classList.remove('is-demo');
stopAutoplay();
target = Math.min(target, 0.86); // 누르는 순간 살짝 줄어든다
});
btn.addEventListener('pointerup', function () {
root.classList.remove('is-demo');
stopAutoplay();
toggle();
});
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
toggle();
});
raf = requestAnimationFrame(tick);
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(toggle, 1200); }
else { collapsed = true; target = 0.72; cur = 0.72; btn.style.transform = 'scaleX(0.72)'; root.classList.add('eb--collapsed'); btn.setAttribute('aria-pressed', 'true'); }
})();import { useEffect, useRef } from "react";
import styles from "./ElasticSnapBack.module.scss";
type Box<T> = { current: T };
const K = 190, D = 14; // 계산 겹: 스프링 상수·감쇠 — 눌렀다 놓는 순간의 되튐 값을 정한다
export default function ElasticSnapBack() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector(`.${styles.btn}`) as HTMLButtonElement | null;
if (!btn) return;
let collapsed = false;
let cur = 1, vel = 0, target = 1;
let raf: number | null = null;
function tick() {
const a = -K * (cur - target) * 0.001 - D * vel * 0.01;
vel += a;
cur += vel;
if (Math.abs(cur - target) < 0.004 && Math.abs(vel) < 0.005) { cur = target; vel = 0; }
btn!.style.transform = "scaleX(" + cur.toFixed(3) + ")";
const p = Math.max(0, Math.min(1, (1 - cur) / 0.28));
root!.style.setProperty("--press", p.toFixed(3));
raf = requestAnimationFrame(tick);
}
function toggle() {
collapsed = !collapsed;
target = collapsed ? 0.72 : 1;
btn!.setAttribute("aria-pressed", String(collapsed));
root!.classList.toggle(styles["root--collapsed"], collapsed);
}
function onDown() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
target = Math.min(target, 0.86); // 누르는 순간 살짝 줄어든다
}
function onUp() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
toggle();
}
function onClick() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
toggle();
}
btn.addEventListener("pointerdown", onDown);
btn.addEventListener("pointerup", onUp);
btn.addEventListener("click", onClick);
raf = requestAnimationFrame(tick);
let autoTimer: number | null = null;
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(toggle, 1200); }
else {
collapsed = true; target = 0.72; cur = 0.72;
btn!.style.transform = "scaleX(0.72)";
root!.classList.add(styles["root--collapsed"]);
btn!.setAttribute("aria-pressed", "true");
}
return () => {
stopAutoplay();
if (raf !== null) cancelAnimationFrame(raf);
btn.removeEventListener("pointerdown", onDown);
btn.removeEventListener("pointerup", onUp);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.eb} ${styles["is-demo"]}`}>
<button type="button" className={styles.btn} aria-pressed="false">
<span className={styles.label}>필터 값 접기</span>
</button>
<div className={styles.chipRow} aria-hidden="true">
<span className={styles.chip}>값 걸러내기 1</span>
<span className={styles.chip}>값 걸러내기 2</span>
<span className={styles.chip}>값 걸러내기 3</span>
</div>
</div>
);
}09 · 되튐 복귀 전환 — 접힘 값이 켜지면 형태 값이 줄어들다가 반대로 살짝 늘었다가 제 형태로 돌아가는 방식 — 측정 누적면적 11.242% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
값이 패턴을 바꾸는 곳 — 둥근 반지름·회전 각도·너비 기준
둥근 반지름 기준은 01과 03이 다르게 잡습니다. 01은 border-radius를 열림 여부 두 값(닫힘·열림)만 오가며 $r-control 토큰 그대로 쓰는 반면, 03은 RADII 배열 값 자체가 [12px, 16px, 24px, 999px] 네 단계로 늘어나 마지막 단계에서 알약 형태(999px)까지 이어집니다.
회전 각도 기준은 02의 SPIN_DEG(135도) 하나가 정합니다. 90도가 아니라 135도를 목표로 잡아 $ease-elastic 곡선이 그 값을 넘겼다가 되돌아오는 폭을 크게 만들고, 되돌아오는 시간 자체는 .pc__icon에 걸린 360ms 전환 값이 정합니다.
너비를 바꾸는 기준은 세 갈래입니다. 04는 K(170)·D(12) 스프링 값으로 목표 너비를 넘쳤다가 돌아오는 관성 계산이고, 09는 같은 스프링 공식을 K(190)·D(14)로 형태 값(scaleX)에 적용해 접힘·복귀를 계산하며, 06은 스프링 계산 없이 700ms $ease-spring CSS 전환 하나로 알약 폭에서 막대 폭까지 늘어납니다. 04·09처럼 접기 펼치기 버튼이 자바스크립트로 값을 미는 쪽과, 06처럼 CSS 전환 값 하나로 끝나는 쪽이 갈립니다.
어디서 깨지는가 — 글자 폭이 바뀌는 순간·좁은 화면·모션 감소
글자 폭이 바뀌는 순간을 다루는 방식이 04와 08에서 갈립니다. 04는 openW를 measure()로 페이지가 열릴 때 딱 한 번만 재고 그 뒤로는 다시 재지 않아, 만약 웹폰트가 늦게 로드돼 안에 든 글자 폭이 나중에 달라지면 목표 너비 값이 어긋난 채로 굳습니다. 08은 반대로 paint()가 돌 때마다(초점·클릭마다) measure()를 다시 불러 --label-w를 갱신하므로 같은 문제가 생기지 않습니다.
좁은 화면(340px 이하)에서는 항목마다 축소 폭이 다릅니다. 06은 알약이 열렸을 때 320px에서 260px로, 09는 버튼이 220px에서 190px로 줄어들지만, 04·09의 스프링 계산 자체에는 리사이즈 리스너가 따로 없어 페이지 로드 시점의 크기를 기준으로 굳고 이후 화면 폭이 바뀌어도 다시 재지 않습니다. 아홉 개 버튼 형태의 html·scss·js 소스 전부가 비번 pv87z5gp 으로 열리는 zip 안에 들어 있어, 위에서 짚은 04·09의 리사이즈 미대응 지점을 직접 코드에서 확인하고 고쳐볼 수 있습니다.
모션을 줄이는 처리는 아홉 항목 모두 자바스크립트에서 window.matchMedia('(prefers-reduced-motion: reduce)')로 판정하고, SCSS 쪽에서도 prefers-reduced-motion 미디어 쿼리로 같은 판정을 한 번 더 걸어 둡니다. 다만 꺼지는 대상은 항목마다 다릅니다 — 01·02·05·06·07·08은 reduce 값이 켜지면 setInterval 자동 재생 대신 곧장 열림·닫힘 같은 최종 상태 하나로 다시 그려 넣고 멈추는 반면, 03은 별도 분기 없이 setInterval 자체만 시작하지 않아 처음 그려진 단계(둥근 값 12px)에서 그대로 멈춥니다. 04·09는 자동 재생을 멈추는 것과 별개로 tick() 안의 목표 값(target)과 현재 값(cur)을 처음부터 같은 수로 맞춰 두어, requestAnimationFrame 루프는 계속 돌아도 화면에는 아무 변화가 나타나지 않습니다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 아코디언 겹침 간격 | 01 STAGGER_MS 70 → 140 | 펼침 항목이 두 배 천천히 차례로 나타나 열림 값의 전파가 한눈에 읽힌다 |
| 너비 스프링 강성 | 04 K 170 → 90 | 목표 폭을 지나치는 오버슈트가 커지고 느리게 안착해 늘어남이 무겁고 탱탱해 보인다 |
| 되튐 감쇠 비율 | 09 D 14 → 22 | 되튐이 한 번에 빨리 잦아들어 탄력이 짧고 단단한 쪽으로 돌아간다 |
접근성은 버튼 요소를 실제로 쓴 일곱 항목(01·02·04·06·07·08·09)이 aria-expanded·aria-pressed·aria-label 값을 상태에 맞춰 갱신합니다 — 01·04·07은 열림 여부를 aria-expanded로, 02·08·09는 눌림 여부를 aria-pressed로, 06은 입력창과 닫기 버튼에 각각 aria-label을 답니다. 03은 <div role="button" tabindex="0">에 keydown(Enter·Space) 리스너까지 달아 버튼이 아닌 요소도 키보드로 조작할 수 있게 했지만, 05는 반대로 .sd__shell이 div 그대로라 role이나 tabindex가 없어 처음 갈라지게 여는 동작은 클릭에서만 되고 안의 그만두기·지우기 버튼만 키보드로 닿습니다. 장식용 아이콘·점(01의 화살표, 03의 단계 점, 08의 동료 아이콘)에는 aria-hidden="true"를 달아 스크린리더가 읽지 않게 뺐습니다.
FAQ
01 같은 아코디언 버튼 ui 는 라이브러리 없이 그대로 붙여넣어도 동작하나요?
네, 동작합니다. 01은 순수 자바스크립트로 getBoundingClientRect()와 transitionDelay만 쓰고 GSAP·jQuery 같은 외부 라이브러리를 부르지 않습니다. HTML·SCSS·JS 세 파일을 그대로 옮기면 같은 열림 값·둥근 값·그림자 값 전환이 그대로 재현됩니다.
02의 더하기 회전과 04·09의 스프링 되돌림은 왜 계산 방식이 다른가요?
02는 자바스크립트 계산 없이 SPIN_DEG(135도)라는 목표 각도 값 하나만 정해 두고, 실제로 지나쳤다 돌아오는 움직임은 CSS $ease-elastic 곡선이 만듭니다. 04·09는 반대로 tick()이 매 프레임 K·D 스프링 값을 직접 적분해 너비·형태 값을 계산하므로, 같은 '되돌아오는' 느낌이라도 하나는 CSS 곡선이, 다른 둘은 자바스크립트 루프가 만드는 값입니다.
이 아홉 가지 버튼 형태 전환을 실제 서비스 폼에 그대로 옮겨 써도 되나요?
형태를 바꾸는 계산(둥근 값·회전 값·너비 값 등)은 그대로 옮길 수 있지만, 05의 삭제 확인처럼 실제 삭제 동작이 필요한 화면은 서버 쪽 처리 로직을 따로 붙여야 합니다. 이 아홉 항목은 화면이 어떻게 반응하는지 보여 주는 프런트엔드 계산이라, 실제 데이터 처리는 각 서비스의 백엔드 검증을 거쳐야 확정됩니다.
더 읽을거리
CSS 버튼 애니메이션 9종은 hover 시 색·크기 값이 트윈으로 바뀌는 장식 축을 다루는 데 비해, 이 편은 클릭 값 하나로 버튼 자체의 모양(둥근·회전·너비·형태) 값이 목표 형태까지 전환되는 계산만 깊게 판다는 점에서 계산 축이 다릅니다.
CSS 아코디언 애니메이션 9종은 패널·목록 콘텐츠의 높이 값이 늘고 줄어드는 아코디언을 다루는 반면, 이 편의 01은 그 패널을 여는 버튼 자체의 둥근 값·그림자 값이 열림 값과 함께 바뀐다는 점에서 콘텐츠 축이 아니라 버튼 축의 계산입니다.
플로팅 버튼 아이콘 버튼 패턴 9종은 아이콘 버튼의 형태 모음을 폭넓게 다루는데, 이 편의 02 더하기 회전은 회전 각도 값 하나가 지나쳤다 되돌아오는 계산 자체를 깊게 본다는 점에서 계산 축이 다릅니다.







