09되튐 복귀 전환 — 접힘 값이 켜지면 형태 값이 줄어들다가 반대로 살짝 늘었다가 제 형태로 돌아가는 방식
접힘 값이 켜지면 형태 값이 줄어들었다가 반대 방향으로 살짝 늘어났다가 제 형태로 돌아가, 접힘이 탄력 있게 보입니다.
쓰임필터 접기 단추, 댓글 창 닫기
KDtick

<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 포함 · 펼쳐지는 버튼 UI 9종 — 모양이 달라지는 아홉 가지 방식
이 데모가 속한 글
펼쳐지는 버튼 UI 9종 — 모양이 달라지는 아홉 가지 방식펼쳐지는 버튼은 클릭 값 하나로 버튼 자체의 모양 값이 다른 형태로 바뀌는 인터랙션인데, 아홉 개 중 하나는 열림 값이 바뀌면 버튼의 모서리 둥근 값이 함께 커지고, 하나는 닫힘 값이 켜지면 더하기 표시가 회전하여 이 글의 데모 전부 보기


