03스프링으로 튕겨 커지는 북마크 버튼
누르는 순간 아이콘이 정해진 크기보다 한 번 더 커졌다가 튕기듯 제자리로 줄어든다.
쓰임댓글의 좋아요 옆 저장 버튼
BOOKMARK_SPRING_OVERSHOOT_PERCENTSPRING_STIFFNESSintegrate
<main class="stage">
<div class="sp is-demo" id="sp">
<p class="sp__caption">스프링으로 튕겨 커지는 북마크 버튼</p>
<div class="sp__scene" id="spScene">
<button class="sp__btn" id="spBtn" type="button" aria-pressed="false">
<svg class="sp__icon" viewBox="0 0 24 24" width="30" height="30" aria-hidden="true" focusable="false">
<path class="sp__shape" d="M6.5 3A1.5 1.5 0 0 1 8 1.5h8A1.5 1.5 0 0 1 17.5 3v18.2a.9.9 0 0 1-1.38.76L12 19.1l-4.12 2.86A.9.9 0 0 1 6.5 21.2Z"/>
</svg>
</button>
</div>
<p class="sp__status" id="spStatus" aria-live="polite">저장 전</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 스프링 방정식(강성·감쇠) 을 적분한 위치가 버튼 scale 이 된다.
// BOOKMARK_SPRING_OVERSHOOT_PERCENT 가 넘길 수 있는 최대 비율을 정한다.
$duration: $dur-loop;
$easing: $ease-elastic;
$color: $subject-blue-deep;
@include stage($stage-ink);
.sp {
display: grid;
gap: $sp-3;
justify-items: center;
}
.sp__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-cream;
}
.sp__scene {
position: relative;
width: 200px;
height: 140px;
display: grid;
place-items: center;
--tone: 0;
}
.sp__scene::before {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
border-radius: $r-card;
background: radial-gradient(circle, rgba($subject-blue-light, .68) 0%, rgba($subject-blue-light, .3) 55%, transparent 88%);
opacity: var(--tone);
transform: scale(calc(0.7 + var(--tone) * 0.4));
pointer-events: none;
}
.sp__btn {
position: relative;
z-index: 1;
border: 0;
background: $stage-paper;
border-radius: $r-pill;
padding: $sp-4;
cursor: pointer;
box-shadow: $shadow-raised;
transform-origin: center;
will-change: transform;
}
.sp__btn:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.sp__shape {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-linejoin: round;
}
.sp.is-saved .sp__shape {
fill: $color;
}
.sp__status {
margin: 0;
font-size: 11px;
color: $subject-cream;
font-weight: 600;
}
@media (max-width: 340px) {
.sp__scene { width: 150px; height: 96px; }
.sp__scene::before { width: 130px; height: 78px; }
}
@media (prefers-reduced-motion: reduce) {
.sp__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);
}
.sp {
display: grid;
gap: 12px;
justify-items: center;
}
.sp__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #fff7e6;
}
.sp__scene {
position: relative;
width: 200px;
height: 140px;
display: grid;
place-items: center;
--tone: 0;
}
.sp__scene::before {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
border-radius: 16px;
background: radial-gradient(circle, rgba(91, 140, 248, 0.68) 0%, rgba(91, 140, 248, 0.3) 55%, transparent 88%);
opacity: var(--tone);
transform: scale(calc(0.7 + var(--tone) * 0.4));
pointer-events: none;
}
.sp__btn {
position: relative;
z-index: 1;
border: 0;
background: #ffffff;
border-radius: 999px;
padding: 16px;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: center;
will-change: transform;
}
.sp__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.sp__shape {
fill: none;
stroke: #0a4fe8;
stroke-width: 1.6;
stroke-linejoin: round;
}
.sp.is-saved .sp__shape {
fill: #0a4fe8;
}
.sp__status {
margin: 0;
font-size: 11px;
color: #fff7e6;
font-weight: 600;
}
@media (max-width: 340px) {
.sp__scene {
width: 150px;
height: 96px;
}
.sp__scene::before {
width: 130px;
height: 78px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp__btn {
transition: none;
}
}(function () {
"use strict";
// 변형 3: BOOKMARK_SPRING_OVERSHOOT_PERCENT 가 스프링이 목표를 넘길 수 있는 최대치를 정한다.
var BOOKMARK_SPRING_OVERSHOOT_PERCENT = 12;
var SPRING_STIFFNESS = 210;
var SPRING_DAMPING = 18;
var SPRING_KICK_VELOCITY = 7.4;
var CYCLE_S = 2;
var root = document.getElementById("sp");
var scene = document.getElementById("spScene");
var btn = document.getElementById("spBtn");
var status = document.getElementById("spStatus");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var saved = false;
function setSaved(v) {
saved = v;
btn.setAttribute("aria-pressed", String(v));
root.classList.toggle("is-saved", v);
status.textContent = v ? "저장됨" : "저장 전";
}
var maxScale = 1 + BOOKMARK_SPRING_OVERSHOOT_PERCENT / 100;
var pos = 1;
var vel = 0;
function render(p) {
var s = clamp01to(p);
btn.style.transform = "scale(" + s.toFixed(4) + ")";
var tone = clamp(s - 1, 0, maxScale - 1) / (maxScale - 1);
scene.style.setProperty("--tone", tone.toFixed(3));
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function clamp01to(v) { return v; }
// 스프링 방정식(강성 SPRING_STIFFNESS · 감쇠 SPRING_DAMPING) 을 매 프레임 적분한다.
// 눌리는 순간 속도를 한 번 차 넣으면(kick), 스프링이 목표(1)를 넘겼다가 되돌아온다.
function integrate(dt) {
var accel = SPRING_STIFFNESS * (1 - pos) - SPRING_DAMPING * vel;
vel += accel * dt;
pos += vel * dt;
if (pos > maxScale) { pos = maxScale; vel *= -0.35; }
if (pos < 1 && vel < 0 && Math.abs(pos - 1) < 0.001 && Math.abs(vel) < 0.01) { pos = 1; vel = 0; }
}
function kick() {
vel += SPRING_KICK_VELOCITY;
}
btn.addEventListener("click", function () {
root.classList.remove("is-demo");
setSaved(!saved);
kick();
});
var lastNow = null;
var start = null;
var prevT = 0;
var toggledA = false;
var toggledB = false;
function loop(now) {
if (start === null) { start = now; lastNow = now; }
var dt = Math.min((now - lastNow) / 1000, 0.05);
lastNow = now;
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { toggledA = false; toggledB = false; }
prevT = t;
if (!reduce && root.classList.contains("is-demo")) {
if (!toggledA && t > 0.3) { toggledA = true; setSaved(true); kick(); }
if (!toggledB && t > 1.3) { toggledB = true; setSaved(false); kick(); }
}
if (reduce) {
pos = 1; vel = 0;
} else {
integrate(dt);
}
render(pos);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// SpringPopScaleBookmark — vanilla 판(run/416/demo/03-bookmark-btn-spring-pop-scale/index.html) 의 React 포트.
// 스프링 방정식(강성·감쇠)을 매 프레임 적분한 위치가 버튼 scale 이 된다. 누르는 순간 속도를 한 번 차 넣으면
// 스프링이 목표를 BOOKMARK_SPRING_OVERSHOOT_PERCENT 만큼 넘겼다가 되돌아온다.
import { useEffect, useRef, useState } from "react";
import styles from "./SpringPopScaleBookmark.module.scss";
type Box<T> = { current: T };
const BOOKMARK_SPRING_OVERSHOOT_PERCENT = 12;
const SPRING_STIFFNESS = 210;
const SPRING_DAMPING = 18;
const SPRING_KICK_VELOCITY = 7.4;
const CYCLE_S = 2;
const MAX_SCALE = 1 + BOOKMARK_SPRING_OVERSHOOT_PERCENT / 100;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function SpringPopScaleBookmark() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const posRef = useRef<number>(1) as Box<number>;
const velRef = useRef<number>(0) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [saved, setSaved] = useState(false);
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(p: number): void {
const btn = btnRef.current;
const scene = sceneRef.current;
if (!btn || !scene) return;
btn.style.transform = "scale(" + p.toFixed(4) + ")";
const tone = clamp(p - 1, 0, MAX_SCALE - 1) / (MAX_SCALE - 1);
scene.style.setProperty("--tone", tone.toFixed(3));
}
function integrate(dt: number): void {
const accel = SPRING_STIFFNESS * (1 - posRef.current) - SPRING_DAMPING * velRef.current;
velRef.current += accel * dt;
posRef.current += velRef.current * dt;
if (posRef.current > MAX_SCALE) { posRef.current = MAX_SCALE; velRef.current *= -0.35; }
if (posRef.current < 1 && velRef.current < 0 && Math.abs(posRef.current - 1) < 0.001 && Math.abs(velRef.current) < 0.01) {
posRef.current = 1; velRef.current = 0;
}
}
function kick(): void {
velRef.current += SPRING_KICK_VELOCITY;
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let lastNow = 0;
let start = 0;
let prevT = 0;
let toggledA = false;
let toggledB = false;
function loop(now: number): void {
if (start === 0) { start = now; lastNow = now; }
const dt = Math.min((now - lastNow) / 1000, 0.05);
lastNow = now;
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { toggledA = false; toggledB = false; }
prevT = t;
if (!reduce && demo) {
if (!toggledA && t > 0.3) { toggledA = true; setSaved(true); kick(); }
if (!toggledB && t > 1.3) { toggledB = true; setSaved(false); kick(); }
}
if (reduce) {
posRef.current = 1; velRef.current = 0;
} else {
integrate(dt);
}
render(posRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onClick(): void {
setDemo(false);
setSaved(!saved);
kick();
}
return (
<main className={styles.stage}>
<div className={styles.sp}>
<p className={styles.caption}>스프링으로 튕겨 커지는 북마크 버튼</p>
<div className={styles.scene} ref={sceneRef}>
<button className={styles.btn} ref={btnRef} type="button" aria-pressed={saved} onClick={onClick}>
<svg className={styles.icon} viewBox="0 0 24 24" width={30} height={30} aria-hidden="true" focusable="false">
<path className={styles.shape} d="M6.5 3A1.5 1.5 0 0 1 8 1.5h8A1.5 1.5 0 0 1 17.5 3v18.2a.9.9 0 0 1-1.38.76L12 19.1l-4.12 2.86A.9.9 0 0 1 6.5 21.2Z" />
</svg>
</button>
</div>
<p className={styles.status} aria-live="polite">{saved ? "저장됨" : "저장 전"}</p>
</div>
</main>
);
}03 스프링으로 튕겨 커지는 북마크 버튼 — 측정 누적면적 9.194% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 북마크 버튼 애니메이션 9가지 — 즐겨찾기 버튼도 같은 원리다

