북마크 버튼 애니메이션 9가지 — 즐겨찾기 버튼도 같은 원리다
북마크 버튼 애니메이션 아홉 가지를 한 페이지에 모았다. 저장. 한 글자면 충분한 이 말에 아홉 가지 표정이 있다 — 03번은 눌리는 순간 스프링 값이 튀어 오르게 하고, 06번은 저장 수 값이 한 자리씩 쌓이며 배지를 밀어 올리고, 09번은 드래그가 놓이는 거리 값으로 목록에 훅
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 호버로 살짝 기울며 빛나는 북마크 아이콘
- 02 윤곽선에서 채움으로 바뀌는 북마크 버튼
- 03 스프링으로 튕겨 커지는 북마크 버튼
- 04 리본이 접히며 떨어지는 북마크 버튼
- 05 액체가 차오르며 채워지는 북마크 버튼
- 06 저장 수가 한 자리씩 올라가는 배지
- 07 저장을 확인해 주는 토스트 슬라이드
- 08 저장할 컬렉션을 고르는 팝오버
- 09 드래그해서 읽기 목록에 놓는 북마크
북마크 버튼 애니메이션 9가지 — 값 하나가 저장 느낌을 어떻게 바꾸는가
아홉 개는 손끝 하나의 움직임에서 시작해 화면 여러 곳이 함께 반응하는 순서로 늘어놓았다. 01·02는 마우스 위치·채움 진행률처럼 값 하나만 계산하고, 03·04·05는 누르는 순간의 물리량(스프링 속도·접힘 각도·물 높이)을 매 프레임 적분하며, 06·07은 저장 이후 배지·토스트라는 별도 요소의 타이머를 돌린다. 08·09는 롱프레스·드래그처럼 더 긴 제스처의 거리·시간 값을 문턱값과 비교해 열림·스냅 여부를 가른다.
01포인터 벡터 값이 기울기와 광택 각도를 함께 정하는 북마크 아이콘
마우스가 다가오면 포인터와 버튼 중심 사이 벡터(dx,dy)를 매 프레임 다시 재고, 그 벡터의 크기·각도가 기울기(rotateX·rotateY)와 가장자리를 스치는 광택 각도를 동시에 정한다. GLOW_RADIUS_PX 밖으로 벗어나면 strength 값이 0에 가까워져 기울기·광택이 함께 가라앉는다.
<main class="stage">
<div class="rb is-demo" id="rb">
<p class="rb__caption">호버로 살짝 기울며 빛나는 북마크 아이콘</p>
<div class="rb__zone" id="rbZone">
<div class="rb__glow" id="rbGlow" aria-hidden="true"></div>
<button class="rb__btn" id="rbBtn" type="button" aria-pressed="false">
<svg class="rb__icon" viewBox="0 0 24 24" width="30" height="30" aria-hidden="true" focusable="false">
<path class="rb__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="rb__status" id="rbStatus" aria-live="polite">저장 전</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 포인터 벡터→각도. 그 각도가 기울기(rotateX/rotateY)와 광택 sweep 위치를 함께 정한다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.rb {
display: grid;
gap: $sp-3;
justify-items: center;
}
.rb__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
}
.rb__zone {
position: relative;
width: 220px;
height: 150px;
display: grid;
place-items: center;
}
.rb__glow {
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
--glow-deg: 0deg;
--glow-strength: 0;
background: conic-gradient(from var(--glow-deg), transparent 0deg, rgba($subject-blue, .55) 40deg, transparent 90deg);
opacity: var(--glow-strength);
border-radius: $r-card;
pointer-events: none;
}
.rb__btn {
position: relative;
z-index: 1;
border: 0;
background: $stage-paper;
border-radius: $r-pill;
padding: $sp-4;
cursor: pointer;
color: $color;
box-shadow: $shadow-press;
transform-origin: center;
will-change: transform;
}
.rb__btn:focus-visible {
outline: 2px solid $subject-blue;
outline-offset: 3px;
}
.rb__shape {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-linejoin: round;
}
.rb.is-saved .rb__shape {
fill: $color;
}
.rb__status {
margin: 0;
font-size: 11px;
color: $subject-ink;
font-weight: 600;
}
@media (max-width: 340px) {
.rb__zone { width: 170px; height: 96px; }
.rb__glow { width: 150px; height: 86px; }
}
@media (prefers-reduced-motion: reduce) {
.rb__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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rb {
display: grid;
gap: 12px;
justify-items: center;
}
.rb__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.rb__zone {
position: relative;
width: 220px;
height: 150px;
display: grid;
place-items: center;
}
.rb__glow {
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
--glow-deg: 0deg;
--glow-strength: 0;
background: conic-gradient(from var(--glow-deg), transparent 0deg, rgba(47, 109, 246, 0.55) 40deg, transparent 90deg);
opacity: var(--glow-strength);
border-radius: 16px;
pointer-events: none;
}
.rb__btn {
position: relative;
z-index: 1;
border: 0;
background: #ffffff;
border-radius: 999px;
padding: 16px;
cursor: pointer;
color: #0a4fe8;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform-origin: center;
will-change: transform;
}
.rb__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 3px;
}
.rb__shape {
fill: none;
stroke: #0a4fe8;
stroke-width: 1.6;
stroke-linejoin: round;
}
.rb.is-saved .rb__shape {
fill: #0a4fe8;
}
.rb__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.rb__zone {
width: 170px;
height: 96px;
}
.rb__glow {
width: 150px;
height: 86px;
}
}
@media (prefers-reduced-motion: reduce) {
.rb__btn {
transition: none;
}
}(function () {
"use strict";
var TILT_MAX_DEG = 14;
var GLOW_RADIUS_PX = 150;
var CYCLE_S = 2;
var root = document.getElementById("rb");
var zone = document.getElementById("rbZone");
var glow = document.getElementById("rbGlow");
var btn = document.getElementById("rbBtn");
var status = document.getElementById("rbStatus");
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 ? "저장됨" : "저장 전";
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
// 값 하나: 포인터 벡터(dx,dy) → 각도. 그 각도가 기울기(rotateX/rotateY)와
// 광택 sweep 위치(conic-gradient 각도)를 동시에 결정한다.
function applyVector(dx, dy) {
var dist = Math.hypot(dx, dy);
var strength = clamp(1 - dist / GLOW_RADIUS_PX, 0, 1);
var angleDeg = Math.atan2(dy, dx) * 180 / Math.PI;
var tiltX = clamp(-dy / GLOW_RADIUS_PX, -1, 1) * TILT_MAX_DEG * strength;
var tiltY = clamp(dx / GLOW_RADIUS_PX, -1, 1) * TILT_MAX_DEG * strength;
btn.style.transform = "perspective(600px) rotateX(" + tiltX.toFixed(2) + "deg) rotateY(" + tiltY.toFixed(2) + "deg)";
glow.style.setProperty("--glow-deg", angleDeg.toFixed(1) + "deg");
glow.style.setProperty("--glow-strength", strength.toFixed(3));
}
var hasPointer = false;
zone.addEventListener("pointermove", function (e) {
hasPointer = true;
root.classList.remove("is-demo");
var r = btn.getBoundingClientRect();
var cx = r.left + r.width / 2;
var cy = r.top + r.height / 2;
applyVector(e.clientX - cx, e.clientY - cy);
});
zone.addEventListener("pointerleave", function () {
hasPointer = false;
applyVector(0, 0);
});
btn.addEventListener("focus", function () {
applyVector(0, -GLOW_RADIUS_PX * 0.4);
});
btn.addEventListener("blur", function () {
if (!hasPointer) applyVector(0, 0);
});
btn.addEventListener("click", function () {
setSaved(!saved);
});
// .is-demo 자동 시연: 가상 포인터가 원을 그리며 지나가고, 그 사이 두 번 저장 상태를 바꾼다.
var start = null;
var prevT = 0;
var toggledA = false;
var toggledB = false;
function loop(now) {
if (start === null) start = now;
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { toggledA = false; toggledB = false; }
prevT = t;
if (!reduce && !hasPointer && root.classList.contains("is-demo")) {
var ang = (t / CYCLE_S) * Math.PI * 2;
var x = Math.cos(ang) * 120;
var y = Math.sin(ang) * 70;
applyVector(x, y);
if (!toggledA && t > 0.45) { toggledA = true; setSaved(true); }
if (!toggledB && t > 1.45) { toggledB = true; setSaved(false); }
}
requestAnimationFrame(loop);
}
if (reduce) {
applyVector(0, 0);
} else {
requestAnimationFrame(loop);
}
})();// HoverTiltGlowBookmark — vanilla 판(run/416/demo/01-bookmark-btn-hover-tilt-glow/index.html) 의 React 포트.
// 값 하나: 포인터 벡터(dx,dy) → 각도. 그 각도가 버튼 기울기(rotateX/rotateY)와 광택 sweep 위치(conic-gradient)를 함께 정한다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./HoverTiltGlowBookmark.module.scss";
type Box<T> = { current: T };
const TILT_MAX_DEG = 14;
const GLOW_RADIUS_PX = 150;
const CYCLE_S = 2;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function HoverTiltGlowBookmark() {
const btnRef = useRef<HTMLButtonElement | null>(null);
const glowRef = useRef<HTMLDivElement | null>(null);
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const [saved, setSaved] = useState(false);
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function applyVector(dx: number, dy: number): void {
const btn = btnRef.current;
const glow = glowRef.current;
if (!btn || !glow) return;
const dist = Math.hypot(dx, dy);
const strength = clamp(1 - dist / GLOW_RADIUS_PX, 0, 1);
const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
const tiltX = clamp(-dy / GLOW_RADIUS_PX, -1, 1) * TILT_MAX_DEG * strength;
const tiltY = clamp(dx / GLOW_RADIUS_PX, -1, 1) * TILT_MAX_DEG * strength;
btn.style.transform = "perspective(600px) rotateX(" + tiltX.toFixed(2) + "deg) rotateY(" + tiltY.toFixed(2) + "deg)";
glow.style.setProperty("--glow-deg", angleDeg.toFixed(1) + "deg");
glow.style.setProperty("--glow-strength", strength.toFixed(3));
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
applyVector(0, 0);
return;
}
let start = 0;
let prevT = 0;
let toggledA = false;
let toggledB = false;
function loop(now: number): void {
if (start === 0) start = now;
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { toggledA = false; toggledB = false; }
prevT = t;
if (!hasPointerRef.current && demo) {
const ang = (t / CYCLE_S) * Math.PI * 2;
const x = Math.cos(ang) * 120;
const y = Math.sin(ang) * 70;
applyVector(x, y);
if (!toggledA && t > 0.45) { toggledA = true; setSaved(true); }
if (!toggledB && t > 1.45) { toggledB = true; setSaved(false); }
}
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const btn = btnRef.current;
if (!btn) return;
const r = btn.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
applyVector(event.clientX - cx, event.clientY - cy);
}
function onPointerLeave(): void {
hasPointerRef.current = false;
applyVector(0, 0);
}
function onFocus(): void {
applyVector(0, -GLOW_RADIUS_PX * 0.4);
}
function onBlur(): void {
if (!hasPointerRef.current) applyVector(0, 0);
}
function onClick(): void {
setDemo(false);
setSaved(!saved);
}
return (
<main className={styles.stage}>
<div className={styles.rb}>
<p className={styles.caption}>호버로 살짝 기울며 빛나는 북마크 아이콘</p>
<div className={styles.zone} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<div className={styles.glow} aria-hidden="true" ref={glowRef} />
<button className={styles.btn} ref={btnRef} type="button" aria-pressed={saved} onFocus={onFocus} onBlur={onBlur} 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>
);
}01 · 호버로 살짝 기울며 빛나는 북마크 아이콘 — 측정 누적면적 17.316% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 값 하나: 포인터 벡터(dx,dy) → 각도. 그 각도가 기울기(rotateX/rotateY)와
// 광택 sweep 위치(conic-gradient 각도)를 동시에 결정한다.
function applyVector(dx, dy) {
var dist = Math.hypot(dx, dy);
var strength = clamp(1 - dist / GLOW_RADIUS_PX, 0, 1);
var angleDeg = Math.atan2(dy, dx) * 180 / Math.PI;
var tiltX = clamp(-dy / GLOW_RADIUS_PX, -1, 1) * TILT_MAX_DEG * strength;
var tiltY = clamp(dx / GLOW_RADIUS_PX, -1, 1) * TILT_MAX_DEG * strength;
btn.style.transform = "perspective(600px) rotateX(" + tiltX.toFixed(2) + "deg) rotateY(" + tiltY.toFixed(2) + "deg)";
glow.style.setProperty("--glow-deg", angleDeg.toFixed(1) + "deg");
glow.style.setProperty("--glow-strength", strength.toFixed(3));
}
측정: 누적면적 17.3% · 강도 평균 24.3 · 21/23 프레임에서 움직였다.
02채움 진행률 값이 윤곽선을 안쪽부터 채우는 북마크 버튼
누르기 전에는 리본이 선으로만 보이다가, 클릭하는 순간부터 fill 진행률 값이 0에서 1로 수렴하며 안쪽부터 채운 모양을 만든다. reduced-motion 이면 이 수렴 계산을 건너뛰고 진행률이 목표 값으로 곧장 넘어간다.
<main class="stage">
<div class="ob is-demo" id="ob">
<p class="ob__caption">윤곽선에서 채움으로 바뀌는 북마크 버튼</p>
<div class="ob__scene" id="obScene">
<div class="ob__panel" id="obPanel" aria-hidden="true"></div>
<button class="ob__btn" id="obBtn" type="button" aria-pressed="false">
<svg class="ob__icon" viewBox="0 0 24 24" width="34" height="34" aria-hidden="true" focusable="false">
<defs>
<clipPath id="obClip">
<path 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"/>
</clipPath>
</defs>
<path class="ob__outline" 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"/>
<rect class="ob__fill" id="obFill" x="0" y="24" width="24" height="24" clip-path="url(#obClip)"/>
</svg>
</button>
</div>
<p class="ob__status" id="obStatus" aria-live="polite">저장 전</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: fill 진행률(0~1) → SVG rect 의 y/height, clip-path 로 리본 모양 안에서만 차오른다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-yellow);
.ob {
display: grid;
gap: $sp-3;
justify-items: center;
}
.ob__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
}
.ob__scene {
position: relative;
width: 200px;
height: 140px;
display: grid;
place-items: center;
}
.ob__panel {
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
--fill-p: 0;
border-radius: $r-card;
background: radial-gradient(circle, rgba($subject-blue-deep, calc(var(--fill-p) * .62)) 0%, rgba($subject-blue-deep, calc(var(--fill-p) * .28)) 55%, transparent 90%);
pointer-events: none;
}
.ob__btn {
position: relative;
z-index: 1;
border: 0;
background: $stage-paper;
border-radius: $r-pill;
padding: $sp-4;
cursor: pointer;
box-shadow: $shadow-press;
transform-origin: center;
}
.ob__btn:focus-visible {
outline: 2px solid $subject-blue;
outline-offset: 3px;
}
.ob__outline {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-linejoin: round;
}
.ob__fill {
fill: $color;
}
.ob__status {
margin: 0;
font-size: 11px;
color: $subject-ink;
font-weight: 600;
}
@media (max-width: 340px) {
.ob__scene { width: 150px; height: 96px; }
.ob__panel { width: 130px; height: 78px; }
}
@media (prefers-reduced-motion: reduce) {
.ob__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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ob {
display: grid;
gap: 12px;
justify-items: center;
}
.ob__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.ob__scene {
position: relative;
width: 200px;
height: 140px;
display: grid;
place-items: center;
}
.ob__panel {
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
--fill-p: 0;
border-radius: 16px;
background: radial-gradient(circle, rgba(10, 79, 232, calc(var(--fill-p) * 0.62)) 0%, rgba(10, 79, 232, calc(var(--fill-p) * 0.28)) 55%, transparent 90%);
pointer-events: none;
}
.ob__btn {
position: relative;
z-index: 1;
border: 0;
background: #ffffff;
border-radius: 999px;
padding: 16px;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform-origin: center;
}
.ob__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 3px;
}
.ob__outline {
fill: none;
stroke: #0a4fe8;
stroke-width: 1.6;
stroke-linejoin: round;
}
.ob__fill {
fill: #0a4fe8;
}
.ob__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.ob__scene {
width: 150px;
height: 96px;
}
.ob__panel {
width: 130px;
height: 78px;
}
}
@media (prefers-reduced-motion: reduce) {
.ob__btn {
transition: none;
}
}(function () {
"use strict";
var CYCLE_S = 2;
var root = document.getElementById("ob");
var scene = document.getElementById("obScene");
var panel = document.getElementById("obPanel");
var btn = document.getElementById("obBtn");
var fillRect = document.getElementById("obFill");
var status = document.getElementById("obStatus");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var saved = false;
var progress = 0;
var target = 0;
function render(p) {
// 값 하나: fill 진행률(p, 0~1) → SVG rect 의 y·height(=clip-path 로 잘려 채움 비율이 된다)
var y = 24 * (1 - p);
fillRect.setAttribute("y", y.toFixed(3));
fillRect.setAttribute("height", (24 - y).toFixed(3));
panel.style.setProperty("--fill-p", p.toFixed(3));
}
function setSaved(v) {
saved = v;
target = v ? 1 : 0;
btn.setAttribute("aria-pressed", String(v));
status.textContent = v ? "저장됨" : "저장 전";
}
btn.addEventListener("click", function () {
root.classList.remove("is-demo");
setSaved(!saved);
});
var start = null;
var prevT = 0;
var toggledA = false;
var toggledB = false;
function loop(now) {
if (start === null) start = 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); }
if (!toggledB && t > 1.3) { toggledB = true; setSaved(false); }
}
if (reduce) {
progress = target;
} else {
// 관성 lerp: 진행률이 목표를 향해 부드럽게 수렴한다 (속성 트윈이 아니라 매 프레임 계산)
progress += (target - progress) * 0.22;
}
render(progress);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// OutlineFillMorphBookmark — vanilla 판(run/416/demo/02-bookmark-btn-outline-fill-morph/index.html) 의 React 포트.
// 값 하나: fill 진행률(0~1, 관성 lerp) → SVG rect 의 y/height(clip-path 로 리본 모양 안에서만 차오른다) + 배경 패널 강도.
import { useEffect, useRef, useState } from "react";
import styles from "./OutlineFillMorphBookmark.module.scss";
type Box<T> = { current: T };
const CYCLE_S = 2;
const LERP_RATE = 0.22;
export default function OutlineFillMorphBookmark() {
const panelRef = useRef<HTMLDivElement | null>(null);
const fillRef = useRef<SVGRectElement | null>(null);
const progressRef = useRef<number>(0) as Box<number>;
const targetRef = 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 fill = fillRef.current;
const panel = panelRef.current;
if (!fill || !panel) return;
const y = 24 * (1 - p);
fill.setAttribute("y", y.toFixed(3));
fill.setAttribute("height", (24 - y).toFixed(3));
panel.style.setProperty("--fill-p", p.toFixed(3));
}
function toggle(v: boolean): void {
setSaved(v);
targetRef.current = v ? 1 : 0;
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0;
let prevT = 0;
let toggledA = false;
let toggledB = false;
function loop(now: number): void {
if (start === 0) start = 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; toggle(true); }
if (!toggledB && t > 1.3) { toggledB = true; toggle(false); }
}
if (reduce) {
progressRef.current = targetRef.current;
} else {
progressRef.current += (targetRef.current - progressRef.current) * LERP_RATE;
}
render(progressRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onClick(): void {
setDemo(false);
toggle(!saved);
}
return (
<main className={styles.stage}>
<div className={styles.ob}>
<p className={styles.caption}>윤곽선에서 채움으로 바뀌는 북마크 버튼</p>
<div className={styles.scene}>
<div className={styles.panel} aria-hidden="true" ref={panelRef} />
<button className={styles.btn} type="button" aria-pressed={saved} onClick={onClick}>
<svg className={styles.icon} viewBox="0 0 24 24" width={34} height={34} aria-hidden="true" focusable="false">
<defs>
<clipPath id="obClip">
<path 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" />
</clipPath>
</defs>
<path className={styles.outline} 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" />
<rect className={styles.fill} ref={fillRef} x={0} y={24} width={24} height={24} clipPath="url(#obClip)" />
</svg>
</button>
</div>
<p className={styles.status} aria-live="polite">{saved ? "저장됨" : "저장 전"}</p>
</div>
</main>
);
}02 · 윤곽선에서 채움으로 바뀌는 북마크 버튼 — 측정 누적면적 16.04% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function render(p) {
// 값 하나: fill 진행률(p, 0~1) → SVG rect 의 y·height(=clip-path 로 잘려 채움 비율이 된다)
var y = 24 * (1 - p);
fillRect.setAttribute("y", y.toFixed(3));
fillRect.setAttribute("height", (24 - y).toFixed(3));
panel.style.setProperty("--fill-p", p.toFixed(3));
}
측정: 누적면적 16.0% · 강도 평균 37.6 · 7/23 프레임에서 움직였다.
03스프링 적분 값이 튕겨 커졌다 되돌아오는 북마크 버튼
누르는 순간 아이콘 크기(pos)가 목표(1)를 넘어 BOOKMARK_SPRING_OVERSHOOT_PERCENT 만큼 더 커졌다가 스프링처럼 튕겨 돌아온다. 이 크기는 가속도를 매 프레임 적분(integrate)해서 얻은 값이라 트랜지션 곡선이 아니라 실제 속도·가속도 계산의 결과다.
<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 포함 · 비밀번호 보기
// 스프링 방정식(강성 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; }
}
측정: 누적면적 9.2% · 강도 평균 28.0 · 12/23 프레임에서 움직였다.
04접힘 각도 값이 그림자까지 진하게 만드는 리본 북마크
누르면 리본 끝이 안쪽으로 접히는 각도(deg)와 살짝 떨어지는 거리(drop)가 같은 진행률 p 하나에서 함께 나오고, p 가 커질수록 그림자의 블러·번짐·불투명도까지 같이 짙어진다. 접힘이 끝나 p 가 0으로 돌아오면 그림자도 원래 옅기로 되돌아온다.
<main class="stage">
<div class="fd is-demo" id="fd">
<p class="fd__caption">리본이 접히며 떨어지는 북마크 버튼</p>
<div class="fd__scene" id="fdScene">
<div class="fd__shadow" id="fdShadow" aria-hidden="true"></div>
<button class="fd__btn" id="fdBtn" type="button" aria-pressed="false">
<span class="fd__wrap" id="fdWrap">
<svg class="fd__icon" viewBox="0 0 24 24" width="30" height="30" aria-hidden="true" focusable="false">
<path class="fd__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>
</span>
</button>
</div>
<p class="fd__status" id="fdStatus" aria-live="polite">저장 전</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 접힘 진행률(사인 봉우리) → rotateX 접힘 각 + 낙하 거리 + 그림자 블러/농도.
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-orange-deep;
@include stage($stage-orange);
.fd {
display: grid;
gap: $sp-3;
justify-items: center;
}
.fd__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
}
.fd__scene {
position: relative;
width: 200px;
height: 140px;
display: grid;
place-items: center;
--tone: 0;
}
.fd__scene::before {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
border-radius: $r-card;
background: radial-gradient(circle, rgba($subject-orange-deep, calc(.05 + var(--tone) * .72)) 0%, rgba($subject-orange-deep, calc(.02 + var(--tone) * .3)) 55%, transparent 88%);
pointer-events: none;
}
.fd__shadow {
position: absolute;
bottom: 28px;
width: 60px;
height: 14px;
border-radius: $r-pill;
background: transparent;
pointer-events: none;
}
.fd__btn {
position: relative;
z-index: 1;
border: 0;
background: $stage-paper;
border-radius: $r-pill;
padding: $sp-4;
cursor: pointer;
perspective: 300px;
}
.fd__btn:focus-visible {
outline: 2px solid $subject-orange-deep;
outline-offset: 3px;
}
.fd__wrap {
display: block;
transform-origin: top center;
will-change: transform;
}
.fd__shape {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-linejoin: round;
}
.fd.is-saved .fd__shape {
fill: $color;
}
.fd__status {
margin: 0;
font-size: 11px;
color: $subject-ink;
font-weight: 600;
}
@media (max-width: 340px) {
.fd__scene { width: 150px; height: 96px; }
.fd__shadow { bottom: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.fd__wrap { 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);
}
.fd {
display: grid;
gap: 12px;
justify-items: center;
}
.fd__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.fd__scene {
position: relative;
width: 200px;
height: 140px;
display: grid;
place-items: center;
--tone: 0;
}
.fd__scene::before {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
border-radius: 16px;
background: radial-gradient(circle, rgba(201, 48, 0, calc(0.05 + var(--tone) * 0.72)) 0%, rgba(201, 48, 0, calc(0.02 + var(--tone) * 0.3)) 55%, transparent 88%);
pointer-events: none;
}
.fd__shadow {
position: absolute;
bottom: 28px;
width: 60px;
height: 14px;
border-radius: 999px;
background: transparent;
pointer-events: none;
}
.fd__btn {
position: relative;
z-index: 1;
border: 0;
background: #ffffff;
border-radius: 999px;
padding: 16px;
cursor: pointer;
perspective: 300px;
}
.fd__btn:focus-visible {
outline: 2px solid #c93000;
outline-offset: 3px;
}
.fd__wrap {
display: block;
transform-origin: top center;
will-change: transform;
}
.fd__shape {
fill: none;
stroke: #c93000;
stroke-width: 1.6;
stroke-linejoin: round;
}
.fd.is-saved .fd__shape {
fill: #c93000;
}
.fd__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.fd__scene {
width: 150px;
height: 96px;
}
.fd__shadow {
bottom: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.fd__wrap {
transition: none;
}
}(function () {
"use strict";
var FOLD_MAX_DEG = 56;
var FOLD_DROP_MAX_PX = 12;
var FOLD_MS = 560;
var CYCLE_S = 2;
var root = document.getElementById("fd");
var scene = document.getElementById("fdScene");
var shadow = document.getElementById("fdShadow");
var btn = document.getElementById("fdBtn");
var wrap = document.getElementById("fdWrap");
var status = document.getElementById("fdStatus");
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 ? "저장됨" : "저장 전";
}
// 값 하나: 접힘 진행률 p(0~1, 사인 곡선 한 번의 봉우리) → 접힘 각도(rotateX) + 낙하 거리
// + 그림자 블러·투명도(높이가 낮아질수록 그림자가 진해진다).
function render(p) {
scene.style.setProperty("--tone", p.toFixed(3));
var deg = p * FOLD_MAX_DEG;
var drop = p * FOLD_DROP_MAX_PX;
wrap.style.transform = "perspective(300px) rotateX(" + deg.toFixed(2) + "deg) translateY(" + drop.toFixed(2) + "px)";
var blur = 3 + p * 10;
var spread = 6 - p * 4;
var alpha = 0.22 + p * 0.28;
shadow.style.boxShadow = "0 " + (2 + p * 6).toFixed(1) + "px " + blur.toFixed(1) + "px " + spread.toFixed(1) + "px rgba(23,20,26," + alpha.toFixed(2) + ")";
shadow.style.transform = "scaleX(" + (1 - p * 0.18).toFixed(3) + ")";
}
var foldStart = null;
var pending = false;
function triggerFold() {
pending = true;
}
btn.addEventListener("click", function () {
root.classList.remove("is-demo");
setSaved(!saved);
triggerFold();
});
var start = null;
var prevT = 0;
var toggledA = false;
var toggledB = false;
function loop(now) {
if (start === null) start = now;
if (pending) { foldStart = now; pending = false; }
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.35) { toggledA = true; setSaved(true); triggerFold(); }
if (!toggledB && t > 1.35) { toggledB = true; setSaved(false); triggerFold(); }
}
var p = 0;
if (!reduce && foldStart !== null) {
var ft = (now - foldStart) / FOLD_MS;
if (ft >= 1) {
foldStart = null;
p = 0;
} else {
p = Math.sin(Math.PI * ft);
}
}
render(p);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// RibbonFoldDropBookmark — vanilla 판(run/416/demo/04-bookmark-btn-ribbon-fold-drop/index.html) 의 React 포트.
// 값 하나: 접힘 진행률(사인 곡선 한 번의 봉우리) → 접힘 각도(rotateX) + 낙하 거리 + 그림자 블러·농도 + 배경 톤.
import { useEffect, useRef, useState } from "react";
import styles from "./RibbonFoldDropBookmark.module.scss";
type Box<T> = { current: T };
const FOLD_MAX_DEG = 56;
const FOLD_DROP_MAX_PX = 12;
const FOLD_MS = 560;
const CYCLE_S = 2;
export default function RibbonFoldDropBookmark() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const shadowRef = useRef<HTMLDivElement | null>(null);
const wrapRef = useRef<HTMLSpanElement | null>(null);
const foldStartRef = useRef<number | null>(null);
const pendingRef = useRef<boolean>(false) as Box<boolean>;
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 scene = sceneRef.current;
const wrap = wrapRef.current;
const shadow = shadowRef.current;
if (!scene || !wrap || !shadow) return;
scene.style.setProperty("--tone", p.toFixed(3));
const deg = p * FOLD_MAX_DEG;
const drop = p * FOLD_DROP_MAX_PX;
wrap.style.transform = "perspective(300px) rotateX(" + deg.toFixed(2) + "deg) translateY(" + drop.toFixed(2) + "px)";
const blur = 3 + p * 10;
const spread = 6 - p * 4;
const alpha = 0.22 + p * 0.28;
shadow.style.boxShadow = "0 " + (2 + p * 6).toFixed(1) + "px " + blur.toFixed(1) + "px " + spread.toFixed(1) + "px rgba(23,20,26," + alpha.toFixed(2) + ")";
shadow.style.transform = "scaleX(" + (1 - p * 0.18).toFixed(3) + ")";
}
function triggerFold(): void {
pendingRef.current = true;
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0;
let prevT = 0;
let toggledA = false;
let toggledB = false;
function loop(now: number): void {
if (start === 0) start = now;
if (pendingRef.current) { foldStartRef.current = now; pendingRef.current = false; }
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { toggledA = false; toggledB = false; }
prevT = t;
if (!reduce && demo) {
if (!toggledA && t > 0.35) { toggledA = true; setSaved(true); triggerFold(); }
if (!toggledB && t > 1.35) { toggledB = true; setSaved(false); triggerFold(); }
}
let p = 0;
if (!reduce && foldStartRef.current !== null) {
const ft = (now - foldStartRef.current) / FOLD_MS;
if (ft >= 1) {
foldStartRef.current = null;
p = 0;
} else {
p = Math.sin(Math.PI * ft);
}
}
render(p);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onClick(): void {
setDemo(false);
setSaved(!saved);
triggerFold();
}
return (
<main className={styles.stage}>
<div className={styles.fd}>
<p className={styles.caption}>리본이 접히며 떨어지는 북마크 버튼</p>
<div className={styles.scene} ref={sceneRef}>
<div className={styles.shadow} ref={shadowRef} aria-hidden="true" />
<button className={styles.btn} type="button" aria-pressed={saved} onClick={onClick}>
<span className={styles.wrap} ref={wrapRef}>
<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>
</span>
</button>
</div>
<p className={styles.status} aria-live="polite">{saved ? "저장됨" : "저장 전"}</p>
</div>
</main>
);
}04 · 리본이 접히며 떨어지는 북마크 버튼 — 측정 누적면적 10.631% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 값 하나: 접힘 진행률 p(0~1, 사인 곡선 한 번의 봉우리) → 접힘 각도(rotateX) + 낙하 거리
// + 그림자 블러·투명도(높이가 낮아질수록 그림자가 진해진다).
function render(p) {
scene.style.setProperty("--tone", p.toFixed(3));
var deg = p * FOLD_MAX_DEG;
var drop = p * FOLD_DROP_MAX_PX;
wrap.style.transform = "perspective(300px) rotateX(" + deg.toFixed(2) + "deg) translateY(" + drop.toFixed(2) + "px)";
var blur = 3 + p * 10;
var spread = 6 - p * 4;
var alpha = 0.22 + p * 0.28;
shadow.style.boxShadow = "0 " + (2 + p * 6).toFixed(1) + "px " + blur.toFixed(1) + "px " + spread.toFixed(1) + "px rgba(23,20,26," + alpha.toFixed(2) + ")";
shadow.style.transform = "scaleX(" + (1 - p * 0.18).toFixed(3) + ")";
}
측정: 누적면적 10.6% · 강도 평균 64.4 · 17/23 프레임에서 움직였다.
05물 높이 값이 사인 물결로 차오르는 북마크 아이콘
저장하면 물 높이 값(level)이 0에서 1로 올라가고, 그 값과 시간 위상(phase)을 매 프레임 다시 넣어 사인 곡선 물결 path(waveD)를 새로 그린다. 속성 트랜지션이 아니라 좌표를 프레임마다 계산해 그리는 방식이라 물결이 흘러가는 것처럼 보인다.
<main class="stage">
<div class="lq is-demo" id="lq">
<p class="lq__caption">액체가 차오르며 채워지는 북마크 버튼</p>
<div class="lq__scene" id="lqScene">
<button class="lq__btn" id="lqBtn" type="button" aria-pressed="false">
<svg class="lq__icon" viewBox="0 0 24 24" width="60" height="60" aria-hidden="true" focusable="false">
<defs>
<clipPath id="lqClip">
<path 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"/>
</clipPath>
</defs>
<path class="lq__outline" 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"/>
<path class="lq__wave" id="lqWave" clip-path="url(#lqClip)" d="M0,24 L24,24 L24,24 L0,24 Z"/>
</svg>
</button>
</div>
<p class="lq__status" id="lqStatus" aria-live="polite">저장 전</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 물 높이(level) + 시간 위상 → 매 프레임 사인 물결 path 를 다시 계산한다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-sky;
@include stage($stage-paper);
.lq {
display: grid;
gap: $sp-3;
justify-items: center;
}
.lq__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
}
.lq__scene {
position: relative;
width: 200px;
height: 140px;
display: grid;
place-items: center;
--level: 0;
}
.lq__scene::before {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
border-radius: $r-card;
background: radial-gradient(circle, rgba($subject-sky, calc(.05 + var(--level) * .78)) 0%, rgba($subject-sky, calc(.02 + var(--level) * .32)) 55%, transparent 88%);
pointer-events: none;
}
.lq__btn {
position: relative;
z-index: 1;
border: 0;
background: $subject-ink;
border-radius: $r-card;
padding: $sp-6;
cursor: pointer;
}
.lq__btn:focus-visible {
outline: 2px solid $subject-sky;
outline-offset: 3px;
}
.lq__outline {
fill: none;
stroke: $subject-cream;
stroke-width: 1.4;
stroke-linejoin: round;
}
.lq__wave {
fill: $color;
}
.lq__status {
margin: 0;
font-size: 11px;
color: $subject-ink;
font-weight: 600;
}
@media (max-width: 340px) {
.lq__scene { width: 150px; height: 96px; }
.lq__btn { padding: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.lq__wave { 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);
}
.lq {
display: grid;
gap: 12px;
justify-items: center;
}
.lq__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.lq__scene {
position: relative;
width: 200px;
height: 140px;
display: grid;
place-items: center;
--level: 0;
}
.lq__scene::before {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
border-radius: 16px;
background: radial-gradient(circle, rgba(110, 200, 255, calc(0.05 + var(--level) * 0.78)) 0%, rgba(110, 200, 255, calc(0.02 + var(--level) * 0.32)) 55%, transparent 88%);
pointer-events: none;
}
.lq__btn {
position: relative;
z-index: 1;
border: 0;
background: #17141a;
border-radius: 16px;
padding: 24px;
cursor: pointer;
}
.lq__btn:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 3px;
}
.lq__outline {
fill: none;
stroke: #fff7e6;
stroke-width: 1.4;
stroke-linejoin: round;
}
.lq__wave {
fill: #6ec8ff;
}
.lq__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.lq__scene {
width: 150px;
height: 96px;
}
.lq__btn {
padding: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.lq__wave {
transition: none;
}
}(function () {
"use strict";
var WAVE_AMPLITUDE = 1.15;
var WAVE_FREQUENCY = 0.85;
var WAVE_SPEED = 2.6;
var LEVEL_LERP = 0.16;
var CYCLE_S = 2;
var root = document.getElementById("lq");
var scene = document.getElementById("lqScene");
var btn = document.getElementById("lqBtn");
var wave = document.getElementById("lqWave");
var status = document.getElementById("lqStatus");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var saved = false;
function setSaved(v) {
saved = v;
btn.setAttribute("aria-pressed", String(v));
status.textContent = v ? "저장됨" : "저장 전";
}
var level = 0;
var target = 0;
// 값 하나: 물 높이(level, 0~1) + 시간 위상(phase) → 매 프레임 다시 계산한 사인 물결 path.
function waveD(lv, phase) {
var baseY = 24 * (1 - lv);
var d = "M0," + (baseY + WAVE_AMPLITUDE * Math.sin(phase)).toFixed(2) + " ";
for (var x = 0; x <= 24; x += 2) {
var y = baseY + WAVE_AMPLITUDE * Math.sin(x * WAVE_FREQUENCY * 0.26 + phase);
d += "L" + x + "," + y.toFixed(2) + " ";
}
d += "L24,24 L0,24 Z";
return d;
}
btn.addEventListener("click", function () {
root.classList.remove("is-demo");
setSaved(!saved);
target = saved ? 1 : 0;
});
var start = null;
var prevT = 0;
var toggledA = false;
var toggledB = false;
var phase = 0;
function loop(now) {
if (start === null) start = 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.25) { toggledA = true; setSaved(true); target = 1; }
if (!toggledB && t > 1.25) { toggledB = true; setSaved(false); target = 0; }
}
if (reduce) {
// 정지: 물 높이는 목표값으로 즉시 고정, 위상은 갱신하지 않아 물결이 멈춘 채로 표시된다.
level = target;
} else {
level += (target - level) * LEVEL_LERP;
phase = ((now - start) / 1000) * WAVE_SPEED;
}
wave.setAttribute("d", waveD(level, phase));
scene.style.setProperty("--level", level.toFixed(3));
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// LiquidFillWaveBookmark — vanilla 판(run/416/demo/05-bookmark-btn-liquid-fill-wave/index.html) 의 React 포트.
// 값 하나: 물 높이(level) + 시간 위상(phase) → 매 프레임 다시 계산한 사인 물결 SVG path + 배경 톤.
import { useEffect, useRef, useState } from "react";
import styles from "./LiquidFillWaveBookmark.module.scss";
type Box<T> = { current: T };
const WAVE_AMPLITUDE = 1.15;
const WAVE_FREQUENCY = 0.85;
const WAVE_SPEED = 2.6;
const LEVEL_LERP = 0.16;
const CYCLE_S = 2;
function waveD(lv: number, phase: number): string {
const baseY = 24 * (1 - lv);
let d = "M0," + (baseY + WAVE_AMPLITUDE * Math.sin(phase)).toFixed(2) + " ";
for (let x = 0; x <= 24; x += 2) {
const y = baseY + WAVE_AMPLITUDE * Math.sin(x * WAVE_FREQUENCY * 0.26 + phase);
d += "L" + x + "," + y.toFixed(2) + " ";
}
d += "L24,24 L0,24 Z";
return d;
}
export default function LiquidFillWaveBookmark() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const waveRef = useRef<SVGPathElement | null>(null);
const levelRef = useRef<number>(0) as Box<number>;
const targetRef = 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);
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0;
let prevT = 0;
let toggledA = false;
let toggledB = false;
let phase = 0;
function loop(now: number): void {
if (start === 0) start = now;
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { toggledA = false; toggledB = false; }
prevT = t;
if (!reduce && demo) {
if (!toggledA && t > 0.25) { toggledA = true; setSaved(true); targetRef.current = 1; }
if (!toggledB && t > 1.25) { toggledB = true; setSaved(false); targetRef.current = 0; }
}
if (reduce) {
// 정지: 물 높이는 목표값으로 즉시 고정, 위상은 갱신하지 않아 물결이 멈춘 채로 표시된다.
levelRef.current = targetRef.current;
} else {
levelRef.current += (targetRef.current - levelRef.current) * LEVEL_LERP;
phase = ((now - start) / 1000) * WAVE_SPEED;
}
const wave = waveRef.current;
const scene = sceneRef.current;
if (wave) wave.setAttribute("d", waveD(levelRef.current, phase));
if (scene) scene.style.setProperty("--level", levelRef.current.toFixed(3));
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onClick(): void {
setDemo(false);
const v = !saved;
setSaved(v);
targetRef.current = v ? 1 : 0;
}
return (
<main className={styles.stage}>
<div className={styles.lq}>
<p className={styles.caption}>액체가 차오르며 채워지는 북마크 버튼</p>
<div className={styles.scene} ref={sceneRef}>
<button className={styles.btn} type="button" aria-pressed={saved} onClick={onClick}>
<svg className={styles.icon} viewBox="0 0 24 24" width={60} height={60} aria-hidden="true" focusable="false">
<defs>
<clipPath id="lqClip">
<path 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" />
</clipPath>
</defs>
<path className={styles.outline} 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" />
<path className={styles.wave} ref={waveRef} clipPath="url(#lqClip)" d="M0,24 L24,24 L24,24 L0,24 Z" />
</svg>
</button>
</div>
<p className={styles.status} aria-live="polite">{saved ? "저장됨" : "저장 전"}</p>
</div>
</main>
);
}05 · 액체가 차오르며 채워지는 북마크 버튼 — 측정 누적면적 10.059% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 값 하나: 물 높이(level, 0~1) + 시간 위상(phase) → 매 프레임 다시 계산한 사인 물결 path.
function waveD(lv, phase) {
var baseY = 24 * (1 - lv);
var d = "M0," + (baseY + WAVE_AMPLITUDE * Math.sin(phase)).toFixed(2) + " ";
for (var x = 0; x <= 24; x += 2) {
var y = baseY + WAVE_AMPLITUDE * Math.sin(x * WAVE_FREQUENCY * 0.26 + phase);
d += "L" + x + "," + y.toFixed(2) + " ";
}
d += "L24,24 L0,24 Z";
return d;
}
측정: 누적면적 10.1% · 강도 평균 68.8 · 11/23 프레임에서 움직였다.
06자릿수 지연 값이 한 칸씩 굴려 올리는 저장 수 배지
저장할 때마다 배지 안 두 자릿수가 각각 다른 시점에 구르기 시작하는데, 그 시작 지연은 자릿수 인덱스(오른쪽부터)에 BOOKMARK_COUNT_STAGGER_MS 를 곱한 값이다. 오른쪽 한 자리가 먼저 구르고 왼쪽 자리가 뒤따르는 순서로 값이 하나씩 어긋난다.
<main class="stage">
<div class="cb is-demo" id="cb">
<p class="cb__caption">저장 수</p>
<div class="cb__row" id="cbRow">
<span class="cb__rowTitle">디자인 영감 모음</span>
<button class="cb__btn" id="cbBtn" type="button" aria-pressed="false">
<svg class="cb__icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<path class="cb__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>
<span class="cb__badge" id="cbBadge" aria-live="polite">
<span class="cb__digit"><span class="cb__digitOld" id="cbOld0">0</span><span class="cb__digitNew" id="cbNew0">0</span></span>
<span class="cb__digit"><span class="cb__digitOld" id="cbOld1">9</span><span class="cb__digitNew" id="cbNew1">9</span></span>
</span>
</button>
</div>
<p class="cb__status" id="cbStatus">저장 전</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 자릿수 인덱스 × BOOKMARK_COUNT_STAGGER_MS 지연 → 그 칸이 구르기 시작하는 시각.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.cb {
display: grid;
gap: $sp-3;
justify-items: center;
width: 100%;
}
.cb__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
}
.cb__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-3;
width: 260px;
padding: $sp-4 $sp-5;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-press;
transform: scale(1);
}
.cb__row--pulse {
background: mix($subject-cream, $subject-blue, 88%);
transform: scale(1.03);
}
.cb__rowTitle {
font-size: 13px;
font-weight: 700;
color: $subject-ink;
}
.cb__btn {
position: relative;
display: inline-flex;
align-items: center;
gap: $sp-1;
border: 0;
background: transparent;
cursor: pointer;
color: $color;
}
.cb__btn:focus-visible {
outline: 2px solid $subject-blue;
outline-offset: 3px;
}
.cb__shape {
fill: $color;
stroke: $color;
stroke-width: 1.4;
}
.cb__badge {
display: inline-flex;
overflow: hidden;
border-radius: $r-xs;
background: $subject-ink;
padding: $sp-1 $sp-2;
font-weight: 700;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: $subject-cream;
}
.cb__digit {
position: relative;
display: inline-block;
width: 8px;
height: 14px;
overflow: hidden;
}
.cb__digitOld,
.cb__digitNew {
position: absolute;
inset: 0;
display: block;
will-change: transform;
}
.cb__status {
margin: 0;
font-size: 11px;
color: $subject-ink;
font-weight: 600;
}
@media (max-width: 340px) {
.cb__row { width: 220px; padding: $sp-3 $sp-4; }
}
@media (prefers-reduced-motion: reduce) {
.cb__digitOld, .cb__digitNew { 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);
}
.cb {
display: grid;
gap: 12px;
justify-items: center;
width: 100%;
}
.cb__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.cb__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 260px;
padding: 16px 20px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: scale(1);
}
.cb__row--pulse {
background: rgb(90.2117647059%, 90.368627451%, 90.9490196078%);
transform: scale(1.03);
}
.cb__rowTitle {
font-size: 13px;
font-weight: 700;
color: #17141a;
}
.cb__btn {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
cursor: pointer;
color: #0a4fe8;
}
.cb__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 3px;
}
.cb__shape {
fill: #0a4fe8;
stroke: #0a4fe8;
stroke-width: 1.4;
}
.cb__badge {
display: inline-flex;
overflow: hidden;
border-radius: 4px;
background: #17141a;
padding: 4px 8px;
font-weight: 700;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: #fff7e6;
}
.cb__digit {
position: relative;
display: inline-block;
width: 8px;
height: 14px;
overflow: hidden;
}
.cb__digitOld,
.cb__digitNew {
position: absolute;
inset: 0;
display: block;
will-change: transform;
}
.cb__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.cb__row {
width: 220px;
padding: 12px 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.cb__digitOld, .cb__digitNew {
transition: none;
}
}(function () {
"use strict";
var BOOKMARK_COUNT_STAGGER_MS = 40;
var DIGIT_ROLL_MS = 260;
var CYCLE_S = 2;
var root = document.getElementById("cb");
var row = document.getElementById("cbRow");
var btn = document.getElementById("cbBtn");
var status = document.getElementById("cbStatus");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var oldEls = [document.getElementById("cbOld0"), document.getElementById("cbOld1")];
var newEls = [document.getElementById("cbNew0"), document.getElementById("cbNew1")];
var count = 9;
var saved = false;
// 값 하나: 자릿수 인덱스(오른쪽부터) × BOOKMARK_COUNT_STAGGER_MS = 그 칸이 구르기 시작하는 지연 시간.
var columns = [
{ delay: 1 * BOOKMARK_COUNT_STAGGER_MS, start: null, from: "0", to: "0" },
{ delay: 0 * BOOKMARK_COUNT_STAGGER_MS, start: null, from: "9", to: "9" }
];
function pad2(n) { return (n < 10 ? "0" : "") + n; }
function beginRoll(now) {
var next = saved ? count + 1 : count - 1;
var fromStr = pad2(count);
var toStr = pad2(next);
count = next;
for (var i = 0; i < 2; i++) {
columns[i].from = fromStr[i];
columns[i].to = toStr[i];
columns[i].start = now + columns[i].delay;
}
status.textContent = saved ? "저장됨" : "저장 전";
row.classList.add("cb__row--pulse");
window.setTimeout(function () { row.classList.remove("cb__row--pulse"); }, 320);
}
function renderColumns(now) {
for (var i = 0; i < 2; i++) {
var col = columns[i];
oldEls[i].textContent = col.from;
newEls[i].textContent = col.to;
var p = 0;
if (col.start !== null) {
var raw = (now - col.start) / DIGIT_ROLL_MS;
p = Math.max(0, Math.min(1, raw));
if (raw >= 1) { col.start = null; }
}
var eased = 1 - Math.pow(1 - p, 2);
oldEls[i].style.transform = "translateY(" + (-eased * 100).toFixed(1) + "%)";
newEls[i].style.transform = "translateY(" + ((1 - eased) * 100).toFixed(1) + "%)";
}
}
var pendingRoll = false;
btn.addEventListener("click", function () {
root.classList.remove("is-demo");
saved = !saved;
btn.setAttribute("aria-pressed", String(saved));
pendingRoll = true;
});
var start = null;
var prevT = 0;
var toggledA = false;
var toggledB = false;
function loop(now) {
if (start === null) start = now;
if (pendingRoll) { beginRoll(now); pendingRoll = false; }
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; saved = true; btn.setAttribute("aria-pressed", "true"); beginRoll(now);
}
if (!toggledB && t > 1.3) {
toggledB = true; saved = false; btn.setAttribute("aria-pressed", "false"); beginRoll(now);
}
}
if (reduce) {
for (var i = 0; i < 2; i++) { columns[i].start = null; }
}
renderColumns(now);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// StackCountBadgeBookmark — vanilla 판(run/416/demo/06-bookmark-btn-stack-count-badge/index.html) 의 React 포트.
// 값 하나: 자릿수 인덱스(오른쪽부터) × BOOKMARK_COUNT_STAGGER_MS = 그 칸이 구르기 시작하는 지연 시간.
import { useEffect, useRef, useState } from "react";
import styles from "./StackCountBadgeBookmark.module.scss";
type Box<T> = { current: T };
type Column = { delay: number; start: number | null; from: string; to: string };
const BOOKMARK_COUNT_STAGGER_MS = 40;
const DIGIT_ROLL_MS = 260;
const CYCLE_S = 2;
function pad2(n: number): string {
return (n < 10 ? "0" : "") + n;
}
export default function StackCountBadgeBookmark() {
const rowRef = useRef<HTMLDivElement | null>(null);
const oldRefs = useRef<Array<HTMLSpanElement | null>>([null, null]) as Box<Array<HTMLSpanElement | null>>;
const newRefs = useRef<Array<HTMLSpanElement | null>>([null, null]) as Box<Array<HTMLSpanElement | null>>;
const countRef = useRef<number>(9) as Box<number>;
const savedRef = useRef<boolean>(false) as Box<boolean>;
const pendingRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const columnsRef = useRef<Column[]>([
{ delay: 1 * BOOKMARK_COUNT_STAGGER_MS, start: null, from: "0", to: "0" },
{ delay: 0 * BOOKMARK_COUNT_STAGGER_MS, start: null, from: "9", to: "9" },
]) as Box<Column[]>;
const [status, setStatus] = useState("저장 전");
const [pressed, setPressed] = useState(false);
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function beginRoll(now: number): void {
const next = savedRef.current ? countRef.current + 1 : countRef.current - 1;
const fromStr = pad2(countRef.current);
const toStr = pad2(next);
countRef.current = next;
const cols = columnsRef.current;
for (let i = 0; i < 2; i++) {
cols[i].from = fromStr[i];
cols[i].to = toStr[i];
cols[i].start = now + cols[i].delay;
}
setStatus(savedRef.current ? "저장됨" : "저장 전");
const row = rowRef.current;
if (row) {
row.classList.add(styles.rowPulse);
window.setTimeout(() => { row.classList.remove(styles.rowPulse); }, 320);
}
}
function renderColumns(now: number): void {
const cols = columnsRef.current;
for (let i = 0; i < 2; i++) {
const col = cols[i];
const oldEl = oldRefs.current[i];
const newEl = newRefs.current[i];
if (!oldEl || !newEl) continue;
oldEl.textContent = col.from;
newEl.textContent = col.to;
let p = 0;
if (col.start !== null) {
const raw = (now - col.start) / DIGIT_ROLL_MS;
p = Math.max(0, Math.min(1, raw));
if (raw >= 1) { col.start = null; }
}
const eased = 1 - Math.pow(1 - p, 2);
oldEl.style.transform = "translateY(" + (-eased * 100).toFixed(1) + "%)";
newEl.style.transform = "translateY(" + ((1 - eased) * 100).toFixed(1) + "%)";
}
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0;
let prevT = 0;
let toggledA = false;
let toggledB = false;
function loop(now: number): void {
if (start === 0) start = now;
if (pendingRef.current) { beginRoll(now); pendingRef.current = false; }
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; savedRef.current = true; setPressed(true); beginRoll(now);
}
if (!toggledB && t > 1.3) {
toggledB = true; savedRef.current = false; setPressed(false); beginRoll(now);
}
}
if (reduce) {
for (let i = 0; i < 2; i++) { columnsRef.current[i].start = null; }
}
renderColumns(now);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onClick(): void {
setDemo(false);
const v = !savedRef.current;
savedRef.current = v;
setPressed(v);
pendingRef.current = true;
}
return (
<main className={styles.stage}>
<div className={styles.cb}>
<p className={styles.caption}>저장 수</p>
<div className={styles.row} ref={rowRef}>
<span className={styles.rowTitle}>디자인 영감 모음</span>
<button className={styles.btn} type="button" aria-pressed={pressed} onClick={onClick}>
<svg className={styles.icon} viewBox="0 0 24 24" width={20} height={20} 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>
<span className={styles.badge} aria-live="polite">
<span className={styles.digit}>
<span className={styles.digitOld} ref={(el: HTMLSpanElement | null) => { oldRefs.current[0] = el; }}>0</span>
<span className={styles.digitNew} ref={(el: HTMLSpanElement | null) => { newRefs.current[0] = el; }}>0</span>
</span>
<span className={styles.digit}>
<span className={styles.digitOld} ref={(el: HTMLSpanElement | null) => { oldRefs.current[1] = el; }}>9</span>
<span className={styles.digitNew} ref={(el: HTMLSpanElement | null) => { newRefs.current[1] = el; }}>9</span>
</span>
</span>
</button>
</div>
<p className={styles.status}>{status}</p>
</div>
</main>
);
}06 · 저장 수가 한 자리씩 올라가는 배지 — 측정 누적면적 12.285% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 값 하나: 자릿수 인덱스(오른쪽부터) × BOOKMARK_COUNT_STAGGER_MS = 그 칸이 구르기 시작하는 지연 시간.
var columns = [
{ delay: 1 * BOOKMARK_COUNT_STAGGER_MS, start: null, from: "0", to: "0" },
{ delay: 0 * BOOKMARK_COUNT_STAGGER_MS, start: null, from: "9", to: "9" }
];
function pad2(n) { return (n < 10 ? "0" : "") + n; }
function beginRoll(now) {
var next = saved ? count + 1 : count - 1;
var fromStr = pad2(count);
var toStr = pad2(next);
count = next;
for (var i = 0; i < 2; i++) {
columns[i].from = fromStr[i];
columns[i].to = toStr[i];
columns[i].start = now + columns[i].delay;
}
status.textContent = saved ? "저장됨" : "저장 전";
row.classList.add("cb__row--pulse");
window.setTimeout(function () { row.classList.remove("cb__row--pulse"); }, 320);
}
측정: 누적면적 12.3% · 강도 평균 69.5 · 12/23 프레임에서 움직였다.
07경과 시간 값이 진입·유지·퇴장을 가르는 저장 토스트
저장을 마치면 화면 아래에서 짧은 문구 띠가 올라오는데, 뜬 뒤 경과 시간을 진입·유지·퇴장 세 구간의 길이(TOAST_ENTER_MS·TOAST_HOLD_MS·나머지)와 견줘 지금이 어느 구간인지, 그 구간 안에서 얼마나 지났는지를 매 프레임 다시 계산한다. 유지 구간에서는 위치·불투명도가 고정값이고 진입·퇴장 구간에서만 값이 바뀐다.
<main class="stage">
<div class="ts is-demo" id="ts">
<p class="ts__caption">저장 완료 알림</p>
<div class="ts__scene" id="tsScene">
<button class="ts__btn" id="tsBtn" type="button" aria-pressed="false">
<svg class="ts__icon" viewBox="0 0 24 24" width="30" height="30" aria-hidden="true" focusable="false">
<path class="ts__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 class="ts__toast" id="tsToast" role="status" aria-live="polite">
<span id="tsToastText">저장했어요</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 토스트가 뜬 뒤 경과 시간(rAF dt 누적) → 진입/유지/퇴장 구간과 그 진행률(위치·투명도).
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-ink);
.ts {
display: grid;
gap: $sp-3;
justify-items: center;
}
.ts__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-cream;
}
.ts__scene {
position: relative;
width: 260px;
height: 150px;
display: grid;
place-items: center;
overflow: hidden;
}
.ts__btn {
border: 0;
background: $stage-paper;
border-radius: $r-pill;
padding: $sp-4;
cursor: pointer;
color: $color;
}
.ts__btn:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.ts__shape {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-linejoin: round;
}
.ts__btn[aria-pressed="true"] .ts__shape {
fill: $color;
}
.ts__toast {
position: absolute;
bottom: $sp-6;
left: $sp-5;
right: $sp-5;
padding: $sp-3 $sp-5;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 13px;
text-align: center;
box-shadow: $shadow-raised;
will-change: transform, opacity;
}
@media (max-width: 340px) {
.ts__scene { width: 220px; height: 130px; }
}
@media (prefers-reduced-motion: reduce) {
.ts__toast { 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);
}
.ts {
display: grid;
gap: 12px;
justify-items: center;
}
.ts__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #fff7e6;
}
.ts__scene {
position: relative;
width: 260px;
height: 150px;
display: grid;
place-items: center;
overflow: hidden;
}
.ts__btn {
border: 0;
background: #ffffff;
border-radius: 999px;
padding: 16px;
cursor: pointer;
color: #0a4fe8;
}
.ts__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.ts__shape {
fill: none;
stroke: #0a4fe8;
stroke-width: 1.6;
stroke-linejoin: round;
}
.ts__btn[aria-pressed=true] .ts__shape {
fill: #0a4fe8;
}
.ts__toast {
position: absolute;
bottom: 24px;
left: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 13px;
text-align: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
will-change: transform, opacity;
}
@media (max-width: 340px) {
.ts__scene {
width: 220px;
height: 130px;
}
}
@media (prefers-reduced-motion: reduce) {
.ts__toast {
transition: none;
}
}(function () {
"use strict";
var TOAST_ENTER_MS = 180;
var TOAST_HOLD_MS = 520;
var TOAST_EXIT_MS = 180;
var CYCLE_S = 2;
var root = document.getElementById("ts");
var btn = document.getElementById("tsBtn");
var toast = document.getElementById("tsToast");
var toastText = document.getElementById("tsToastText");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var saved = false;
var toastStart = null;
var pendingMsg = null;
function showToast(now, msg) {
toastText.textContent = msg;
toastStart = now;
}
// 값 하나: 토스트가 뜬 뒤 경과 시간(rAF dt 누적) → 진입/유지/퇴장 세 구간 중 어디인지, 그 구간의 진행률.
function render(now) {
if (toastStart === null) {
toast.style.transform = "translateY(120%)";
toast.style.opacity = "0";
return;
}
var elapsed = now - toastStart;
var enterMs = reduce ? 1 : TOAST_ENTER_MS;
var exitMs = reduce ? 1 : TOAST_EXIT_MS;
var enterEnd = enterMs;
var holdEnd = enterEnd + TOAST_HOLD_MS;
var exitEnd = holdEnd + exitMs;
var y, op;
if (elapsed < enterEnd) {
var p = elapsed / enterEnd;
var eased = 1 - Math.pow(1 - p, 3);
y = 120 - eased * 120;
op = eased;
} else if (elapsed < holdEnd) {
y = 0; op = 1;
} else if (elapsed < exitEnd) {
var q = (elapsed - holdEnd) / exitMs;
var easedQ = Math.pow(q, 2);
y = easedQ * 120;
op = 1 - easedQ;
} else {
y = 120; op = 0;
toastStart = null;
}
toast.style.transform = "translateY(" + y.toFixed(2) + "%)";
toast.style.opacity = op.toFixed(3);
}
btn.addEventListener("click", function () {
root.classList.remove("is-demo");
saved = !saved;
btn.setAttribute("aria-pressed", String(saved));
pendingMsg = saved ? "저장했어요" : "저장을 취소했어요";
});
var start = null;
var prevT = 0;
var toggledA = false;
var toggledB = false;
function loop(now) {
if (start === null) start = now;
if (pendingMsg) { showToast(now, pendingMsg); pendingMsg = null; }
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.05) {
toggledA = true; saved = true; btn.setAttribute("aria-pressed", "true");
showToast(now, "저장했어요");
}
if (!toggledB && t > 1.05) {
toggledB = true; saved = false; btn.setAttribute("aria-pressed", "false");
showToast(now, "저장을 취소했어요");
}
}
render(now);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// ToastConfirmSlideBookmark — vanilla 판(run/416/demo/07-bookmark-btn-toast-confirm-slide/index.html) 의 React 포트.
// 값 하나: 토스트가 뜬 뒤 경과 시간(rAF now 기준) → 진입/유지/퇴장 세 구간 중 어디인지, 그 구간의 진행률.
import { useEffect, useRef, useState } from "react";
import styles from "./ToastConfirmSlideBookmark.module.scss";
type Box<T> = { current: T };
const TOAST_ENTER_MS = 180;
const TOAST_HOLD_MS = 520;
const TOAST_EXIT_MS = 180;
const CYCLE_S = 2;
export default function ToastConfirmSlideBookmark() {
const toastRef = useRef<HTMLDivElement | null>(null);
const toastTextRef = useRef<HTMLSpanElement | null>(null);
const toastStartRef = useRef<number | null>(null);
const pendingMsgRef = useRef<string | null>(null);
const frameRef = useRef<number>(0) as Box<number>;
const [pressed, setPressed] = useState(false);
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function showToast(now: number, msg: string): void {
if (toastTextRef.current) toastTextRef.current.textContent = msg;
toastStartRef.current = now;
}
function render(now: number): void {
const toast = toastRef.current;
if (!toast) return;
if (toastStartRef.current === null) {
toast.style.transform = "translateY(120%)";
toast.style.opacity = "0";
return;
}
const elapsed = now - toastStartRef.current;
const enterMs = reduce ? 1 : TOAST_ENTER_MS;
const exitMs = reduce ? 1 : TOAST_EXIT_MS;
const enterEnd = enterMs;
const holdEnd = enterEnd + TOAST_HOLD_MS;
const exitEnd = holdEnd + exitMs;
let y: number;
let op: number;
if (elapsed < enterEnd) {
const p = elapsed / enterEnd;
const eased = 1 - Math.pow(1 - p, 3);
y = 120 - eased * 120;
op = eased;
} else if (elapsed < holdEnd) {
y = 0; op = 1;
} else if (elapsed < exitEnd) {
const q = (elapsed - holdEnd) / exitMs;
const easedQ = Math.pow(q, 2);
y = easedQ * 120;
op = 1 - easedQ;
} else {
y = 120; op = 0;
toastStartRef.current = null;
}
toast.style.transform = "translateY(" + y.toFixed(2) + "%)";
toast.style.opacity = op.toFixed(3);
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0;
let prevT = 0;
let toggledA = false;
let toggledB = false;
function loop(now: number): void {
if (start === 0) start = now;
if (pendingMsgRef.current) { showToast(now, pendingMsgRef.current); pendingMsgRef.current = null; }
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { toggledA = false; toggledB = false; }
prevT = t;
if (!reduce && demo) {
if (!toggledA && t > 0.05) {
toggledA = true; setPressed(true);
showToast(now, "저장했어요");
}
if (!toggledB && t > 1.05) {
toggledB = true; setPressed(false);
showToast(now, "저장을 취소했어요");
}
}
render(now);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onClick(): void {
setDemo(false);
const v = !pressed;
setPressed(v);
pendingMsgRef.current = v ? "저장했어요" : "저장을 취소했어요";
}
return (
<main className={styles.stage}>
<div className={styles.ts}>
<p className={styles.caption}>저장 완료 알림</p>
<div className={styles.scene}>
<button className={styles.btn} type="button" aria-pressed={pressed} 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 className={styles.toast} ref={toastRef} role="status" aria-live="polite">
<span ref={toastTextRef}>저장했어요</span>
</div>
</div>
</div>
</main>
);
}07 · 저장을 확인해 주는 토스트 슬라이드 — 측정 누적면적 10.493% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function showToast(now, msg) {
toastText.textContent = msg;
toastStart = now;
}
// 값 하나: 토스트가 뜬 뒤 경과 시간(rAF dt 누적) → 진입/유지/퇴장 세 구간 중 어디인지, 그 구간의 진행률.
function render(now) {
if (toastStart === null) {
toast.style.transform = "translateY(120%)";
toast.style.opacity = "0";
return;
}
var elapsed = now - toastStart;
var enterMs = reduce ? 1 : TOAST_ENTER_MS;
var exitMs = reduce ? 1 : TOAST_EXIT_MS;
var enterEnd = enterMs;
var holdEnd = enterEnd + TOAST_HOLD_MS;
var exitEnd = holdEnd + exitMs;
var y, op;
if (elapsed < enterEnd) {
var p = elapsed / enterEnd;
var eased = 1 - Math.pow(1 - p, 3);
y = 120 - eased * 120;
op = eased;
측정: 누적면적 10.5% · 강도 평균 109.0 · 11/23 프레임에서 움직였다.
08롱프레스 임계 값이 목록을 여닫는 컬렉션 팝오버
버튼을 누른 채로 있는 시간이 LONGPRESS_MS 를 넘기면 목록 패널이 열리고, 안에 있는 항목들은 인덱스에 POPOVER_STAGGER_MS 를 곱한 값만큼씩 지연을 두고 차례로 나타난다. 임계값을 넘기지 못하고 손을 떼면 패널은 열리지 않고 짧은 저장 토글만 실행된다.
<main class="stage">
<div class="cp is-demo" id="cp">
<p class="cp__caption">폴더에 저장</p>
<div class="cp__scene" id="cpScene">
<button class="cp__btn" id="cpBtn" type="button" aria-pressed="false" aria-expanded="false" aria-haspopup="true">
<svg class="cp__icon" viewBox="0 0 24 24" width="28" height="28" aria-hidden="true" focusable="false">
<path class="cp__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 class="cp__panel" id="cpPanel" role="menu">
<p class="cp__panelTitle">저장할 목록 고르기</p>
<button class="cp__item" type="button" role="menuitem" data-name="읽기 목록">읽기 목록</button>
<button class="cp__item" type="button" role="menuitem" data-name="나중에 볼 글">나중에 볼 글</button>
<button class="cp__item" type="button" role="menuitem" data-name="요리 레시피">요리 레시피</button>
</div>
</div>
<p class="cp__status" id="cpStatus" aria-live="polite">저장 전</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 팝오버가 열린 뒤 경과 시간 → 항목 인덱스 × POPOVER_STAGGER_MS 지연을 지나면 그 항목이 뜬다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-orange);
.cp {
display: grid;
gap: $sp-3;
justify-items: center;
width: 100%;
}
.cp__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
}
.cp__scene {
position: relative;
width: 260px;
height: 190px;
display: flex;
justify-content: center;
}
.cp__btn {
position: relative;
z-index: 2;
border: 0;
background: $stage-paper;
border-radius: $r-pill;
padding: $sp-4;
height: max-content;
cursor: pointer;
color: $color;
box-shadow: $shadow-raised;
}
.cp__btn:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.cp__shape {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-linejoin: round;
}
.cp__btn[aria-pressed="true"] .cp__shape {
fill: $color;
}
.cp__panel {
position: absolute;
top: 64px;
left: 50%;
transform: translateX(-50%);
width: 200px;
display: grid;
gap: $sp-2;
padding: $sp-4;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing;
}
.cp__panel--open {
opacity: 1;
pointer-events: auto;
}
.cp__panelTitle {
margin: 0 0 $sp-1;
font-size: 11px;
font-weight: 700;
color: $subject-blue-deep;
}
.cp__item {
border: 0;
border-radius: $r-sm;
background: $subject-sky;
padding: $sp-2 $sp-3;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
text-align: left;
cursor: pointer;
will-change: transform, opacity;
}
.cp__item:hover,
.cp__item:focus-visible {
background: $subject-blue-light;
outline: none;
}
.cp__status {
margin: 0;
font-size: 11px;
color: $subject-ink;
font-weight: 600;
}
@media (max-width: 340px) {
.cp__scene { width: 200px; height: 100px; }
.cp__btn { padding: $sp-3; }
.cp__panel {
top: 42px;
width: 180px;
padding: $sp-2;
display: flex;
flex-wrap: wrap;
gap: $sp-1;
justify-content: center;
}
.cp__panelTitle { display: none; }
.cp__item { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.cp__panel,
.cp__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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cp {
display: grid;
gap: 12px;
justify-items: center;
width: 100%;
}
.cp__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.cp__scene {
position: relative;
width: 260px;
height: 190px;
display: flex;
justify-content: center;
}
.cp__btn {
position: relative;
z-index: 2;
border: 0;
background: #ffffff;
border-radius: 999px;
padding: 16px;
height: max-content;
cursor: pointer;
color: #0a4fe8;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cp__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.cp__shape {
fill: none;
stroke: #0a4fe8;
stroke-width: 1.6;
stroke-linejoin: round;
}
.cp__btn[aria-pressed=true] .cp__shape {
fill: #0a4fe8;
}
.cp__panel {
position: absolute;
top: 64px;
left: 50%;
transform: translateX(-50%);
width: 200px;
display: grid;
gap: 8px;
padding: 16px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
pointer-events: none;
transition: opacity 300ms ease-out;
}
.cp__panel--open {
opacity: 1;
pointer-events: auto;
}
.cp__panelTitle {
margin: 0 0 4px;
font-size: 11px;
font-weight: 700;
color: #0a4fe8;
}
.cp__item {
border: 0;
border-radius: 8px;
background: #6ec8ff;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
color: #17141a;
text-align: left;
cursor: pointer;
will-change: transform, opacity;
}
.cp__item:hover,
.cp__item:focus-visible {
background: #5b8cf8;
outline: none;
}
.cp__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.cp__scene {
width: 200px;
height: 100px;
}
.cp__btn {
padding: 12px;
}
.cp__panel {
top: 42px;
width: 180px;
padding: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: center;
}
.cp__panelTitle {
display: none;
}
.cp__item {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cp__panel,
.cp__item {
transition: none;
}
}(function () {
"use strict";
var LONGPRESS_MS = 420;
var POPOVER_STAGGER_MS = 45;
var POPOVER_ITEM_MS = 210;
var CYCLE_S = 2;
var root = document.getElementById("cp");
var btn = document.getElementById("cpBtn");
var panel = document.getElementById("cpPanel");
var items = Array.prototype.slice.call(panel.querySelectorAll(".cp__item"));
var status = document.getElementById("cpStatus");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var open = false;
var pressStart = null;
var pressing = false;
function setOpen(v, now) {
open = v;
btn.setAttribute("aria-expanded", String(v));
panel.classList.toggle("cp__panel--open", v);
if (v) { openStart = now; }
else { openStart = null; }
}
var openStart = null;
// 값 하나: 목록 항목 인덱스 × POPOVER_STAGGER_MS = 그 항목이 열리기 시작하는 지연.
function renderPanel(now) {
if (openStart === null && !open) {
items.forEach(function (el) { el.style.opacity = "0"; el.style.transform = "translateY(6px)"; });
return;
}
var elapsed = openStart === null ? POPOVER_ITEM_MS * 3 : now - openStart;
items.forEach(function (el, i) {
var delay = i * POPOVER_STAGGER_MS;
var raw = (elapsed - delay) / POPOVER_ITEM_MS;
var p = Math.max(0, Math.min(1, reduce ? 1 : raw));
var eased = 1 - Math.pow(1 - p, 3);
el.style.opacity = eased.toFixed(3);
el.style.transform = "translateY(" + ((1 - eased) * 6).toFixed(2) + "px)";
});
}
function selectCollection(name) {
status.textContent = "저장됨";
btn.setAttribute("aria-pressed", "true");
root.classList.add("cp__scene--picked");
setOpen(false);
}
function focusItem(i) {
var idx = ((i % items.length) + items.length) % items.length;
items[idx].focus({ preventScroll: true });
}
items.forEach(function (el, i) {
el.addEventListener("click", function () {
root.classList.remove("is-demo");
selectCollection(el.getAttribute("data-name"));
btn.focus({ preventScroll: true });
});
el.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") { e.preventDefault(); focusItem(i + 1); }
else if (e.key === "ArrowUp") { e.preventDefault(); focusItem(i - 1); }
else if (e.key === "Escape") {
e.preventDefault();
root.classList.remove("is-demo");
setOpen(false);
btn.focus({ preventScroll: true });
}
});
});
function shortSave() {
var v = btn.getAttribute("aria-pressed") !== "true";
btn.setAttribute("aria-pressed", String(v));
status.textContent = v ? "저장됨" : "저장 전";
}
btn.addEventListener("pointerdown", function () {
root.classList.remove("is-demo");
pressing = true;
pressStart = null;
});
btn.addEventListener("pointerup", function () {
// longpress 임계(LONGPRESS_MS)를 못 넘기고 뗀 경우에만 짧은 저장 토글로 처리한다
if (pressing && !open) { shortSave(); }
pressing = false;
pressStart = null;
});
btn.addEventListener("pointerleave", function () { pressing = false; pressStart = null; });
btn.addEventListener("keydown", function (e) {
// 롱프레스(포인터 전용) 대안: Shift+Enter 또는 ArrowDown 으로 팝오버를 열고 첫 항목으로 이동
if (e.key === "ArrowDown" || (e.key === "Enter" && e.shiftKey)) {
e.preventDefault();
root.classList.remove("is-demo");
if (!open) { setOpen(true, performance.now()); }
focusItem(0);
} else if (e.key === "Escape" && open) {
e.preventDefault();
root.classList.remove("is-demo");
setOpen(false);
}
});
btn.addEventListener("click", function (e) {
// pointerdown/up longpress 경로와 중복되지 않도록: longpress 로 이미 열렸으면 click 무시
if (open) { e.preventDefault(); return; }
// 키보드(Enter/Space) 로 발생한 click 은 pointerup 경로를 거치지 않으므로 여기서 저장 토글
if (e.detail === 0) { root.classList.remove("is-demo"); shortSave(); }
});
var start = null;
var prevT = 0;
var cycleOpened = false;
var cyclePicked = false;
function loop(now) {
if (start === null) start = now;
if (pressing && pressStart === null) { pressStart = now; }
if (pressing && pressStart !== null && !open && (now - pressStart) > LONGPRESS_MS) {
setOpen(true, now);
}
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { cycleOpened = false; cyclePicked = false; }
prevT = t;
if (!reduce && root.classList.contains("is-demo")) {
if (!cycleOpened && t > 0.15) { cycleOpened = true; setOpen(true, now); }
if (!cyclePicked && t > 1.0) { cyclePicked = true; selectCollection("읽기 목록"); }
}
renderPanel(now);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// CollectionPickerPopoverBookmark — vanilla 판(run/416/demo/08-bookmark-btn-collection-picker-popover/index.html) 의 React 포트.
// 값 하나: 팝오버가 열린 뒤 경과 시간 → 항목 인덱스 × POPOVER_STAGGER_MS 지연을 지나면 그 항목이 뜬다.
import { useEffect, useRef, useState } from "react";
import styles from "./CollectionPickerPopoverBookmark.module.scss";
type Box<T> = { current: T };
const LONGPRESS_MS = 420;
const POPOVER_STAGGER_MS = 45;
const POPOVER_ITEM_MS = 210;
const CYCLE_S = 2;
const NAMES = ["읽기 목록", "나중에 볼 글", "요리 레시피"];
export default function CollectionPickerPopoverBookmark() {
const btnRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([null, null, null]) as Box<Array<HTMLButtonElement | null>>;
const openStartRef = useRef<number | null>(null);
const pressStartRef = useRef<number | null>(null);
const pressingRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const [pressed, setPressed] = useState(false);
const [status, setStatus] = useState("저장 전");
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function setOpen(v: boolean, now: number): void {
if (btnRef.current) btnRef.current.setAttribute("aria-expanded", String(v));
if (panelRef.current) panelRef.current.classList.toggle(styles.panelOpen, v);
openStartRef.current = v ? now : null;
}
function renderPanel(now: number): void {
const items = itemRefs.current;
if (openStartRef.current === null) {
items.forEach((el) => {
if (!el) return;
el.style.opacity = "0";
el.style.transform = "translateY(6px)";
});
return;
}
const elapsed = now - openStartRef.current;
items.forEach((el, i) => {
if (!el) return;
const delay = i * POPOVER_STAGGER_MS;
const raw = (elapsed - delay) / POPOVER_ITEM_MS;
const p = Math.max(0, Math.min(1, reduce ? 1 : raw));
const eased = 1 - Math.pow(1 - p, 3);
el.style.opacity = eased.toFixed(3);
el.style.transform = "translateY(" + ((1 - eased) * 6).toFixed(2) + "px)";
});
}
function selectCollection(now: number): void {
setStatus("저장됨");
setPressed(true);
setOpen(false, now);
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0;
let prevT = 0;
let cycleOpened = false;
let cyclePicked = false;
function loop(now: number): void {
if (start === 0) start = now;
if (pressingRef.current && pressStartRef.current === null) { pressStartRef.current = now; }
if (pressingRef.current && pressStartRef.current !== null && openStartRef.current === null && (now - pressStartRef.current) > LONGPRESS_MS) {
setOpen(true, now);
}
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { cycleOpened = false; cyclePicked = false; }
prevT = t;
if (!reduce && demo) {
if (!cycleOpened && t > 0.15) { cycleOpened = true; setOpen(true, now); }
if (!cyclePicked && t > 1.0) { cyclePicked = true; selectCollection(now); }
}
renderPanel(now);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onItemClick(now: number): void {
setDemo(false);
selectCollection(now);
btnRef.current?.focus({ preventScroll: true });
}
function focusItem(i: number): void {
const items = itemRefs.current;
const idx = ((i % items.length) + items.length) % items.length;
items[idx]?.focus({ preventScroll: true });
}
function shortSave(): void {
const v = !pressed;
setPressed(v);
setStatus(v ? "저장됨" : "저장 전");
}
function onPointerDown(): void {
setDemo(false);
pressingRef.current = true;
pressStartRef.current = null;
}
function onPointerUp(): void {
if (pressingRef.current && openStartRef.current === null) { shortSave(); }
pressingRef.current = false;
pressStartRef.current = null;
}
function onPointerLeave(): void {
pressingRef.current = false;
pressStartRef.current = null;
}
function onBtnClick(event: { detail: number; preventDefault: () => void }): void {
if (openStartRef.current !== null) { event.preventDefault(); return; }
// 키보드(Enter/Space) 로 발생한 click 은 pointerup 경로를 거치지 않으므로 여기서 저장 토글
if (event.detail === 0) { setDemo(false); shortSave(); }
}
function onBtnKeyDown(event: { key: string; shiftKey: boolean; preventDefault: () => void }): void {
// 롱프레스(포인터 전용) 대안: Shift+Enter 또는 ArrowDown 으로 팝오버를 열고 첫 항목으로 이동
if (event.key === "ArrowDown" || (event.key === "Enter" && event.shiftKey)) {
event.preventDefault();
setDemo(false);
if (openStartRef.current === null) { setOpen(true, performance.now()); }
focusItem(0);
} else if (event.key === "Escape" && openStartRef.current !== null) {
event.preventDefault();
setDemo(false);
setOpen(false, performance.now());
}
}
function onItemKeyDown(i: number, event: { key: string; preventDefault: () => void }): void {
if (event.key === "ArrowDown") { event.preventDefault(); focusItem(i + 1); }
else if (event.key === "ArrowUp") { event.preventDefault(); focusItem(i - 1); }
else if (event.key === "Escape") {
event.preventDefault();
setDemo(false);
setOpen(false, performance.now());
btnRef.current?.focus({ preventScroll: true });
}
}
return (
<main className={styles.stage}>
<div className={styles.cp}>
<p className={styles.caption}>폴더에 저장</p>
<div className={styles.scene}>
<button
className={styles.btn}
ref={btnRef}
type="button"
aria-pressed={pressed}
aria-expanded={false}
aria-haspopup="true"
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerLeave={onPointerLeave}
onClick={onBtnClick}
onKeyDown={onBtnKeyDown}
>
<svg className={styles.icon} viewBox="0 0 24 24" width={28} height={28} 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 className={styles.panel} ref={panelRef} role="menu">
<p className={styles.panelTitle}>저장할 목록 고르기</p>
{NAMES.map((name, i) => (
<button
key={name}
className={styles.item}
type="button"
role="menuitem"
ref={(el: HTMLButtonElement | null) => { itemRefs.current[i] = el; }}
onClick={() => onItemClick(performance.now())}
onKeyDown={(event: { key: string; preventDefault: () => void }) => onItemKeyDown(i, event)}
>
{name}
</button>
))}
</div>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
</div>
</main>
);
}08 · 저장할 컬렉션을 고르는 팝오버 — 측정 누적면적 31.662% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 값 하나: 목록 항목 인덱스 × POPOVER_STAGGER_MS = 그 항목이 열리기 시작하는 지연.
function renderPanel(now) {
if (openStart === null && !open) {
items.forEach(function (el) { el.style.opacity = "0"; el.style.transform = "translateY(6px)"; });
return;
}
var elapsed = openStart === null ? POPOVER_ITEM_MS * 3 : now - openStart;
items.forEach(function (el, i) {
var delay = i * POPOVER_STAGGER_MS;
var raw = (elapsed - delay) / POPOVER_ITEM_MS;
var p = Math.max(0, Math.min(1, reduce ? 1 : raw));
var eased = 1 - Math.pow(1 - p, 3);
el.style.opacity = eased.toFixed(3);
el.style.transform = "translateY(" + ((1 - eased) * 6).toFixed(2) + "px)";
});
}
측정: 누적면적 31.7% · 강도 평균 79.1 · 6/23 프레임에서 움직였다.
09스냅 거리 값이 목록에 붙을지 되튈지 가르는 드래그 북마크
아이콘을 옆 목록까지 끌고 가서 놓으면, 놓은 지점과 목록 자리 사이의 실제 거리를 계산해 BOOKMARK_DRAG_SNAP_PX 보다 가까우면 그 자리로 붙고 아니면 원래 자리로 돌아간다. 돌아가는 동안에는 매 프레임 속도에 DRAG_INERTIA_DECAY 를 곱해 관성이 점점 줄어들게 한다.
<main class="stage">
<div class="dg is-demo" id="dg">
<p class="dg__caption">끌어서 목록에 저장</p>
<div class="dg__scene" id="dgScene">
<div class="dg__drop" id="dgDrop">
<span class="dg__dropLabel">읽기 목록에 놓기</span>
</div>
<button class="dg__chip" id="dgChip" type="button" aria-pressed="false">
<svg class="dg__icon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true" focusable="false">
<path class="dg__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="dg__status" id="dgStatus" aria-live="polite">목록 밖</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 놓은 지점 - 드롭존 거리 vs BOOKMARK_DRAG_SNAP_PX → 스냅 여부, 이후 관성(감쇠)+정착(lerp) 로 자리를 잡는다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.dg {
display: grid;
gap: $sp-3;
justify-items: center;
width: 100%;
}
.dg__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
}
.dg__scene {
position: relative;
width: 260px;
height: 190px;
display: flex;
flex-direction: column;
align-items: center;
}
.dg__chip {
position: relative;
z-index: 2;
border: 0;
background: $subject-cream;
border-radius: $r-pill;
padding: $sp-3;
cursor: grab;
color: $color;
box-shadow: $shadow-raised;
touch-action: none;
will-change: transform;
}
.dg__chip:active {
cursor: grabbing;
}
.dg__chip:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.dg__shape {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-linejoin: round;
}
.dg__chip[aria-pressed="true"] .dg__shape {
fill: $color;
}
.dg__drop {
position: absolute;
top: 108px;
width: 220px;
height: 56px;
display: grid;
place-items: center;
border-radius: $r-card;
border: 2px dashed $subject-blue-light;
background: transparent;
transition: background $duration $easing, border-color $duration $easing, transform $duration $easing;
}
.dg__drop--near {
border-color: $subject-blue;
background: mix($subject-sky, $stage-paper, 55%);
transform: scale(1.03);
}
.dg__drop--filled {
border-style: solid;
border-color: $subject-blue-deep;
background: $subject-sky;
}
.dg__dropLabel {
font-size: 12px;
font-weight: 700;
color: $subject-blue-deep;
}
.dg__status {
margin: 0;
font-size: 11px;
color: $subject-ink;
font-weight: 600;
}
@media (max-width: 340px) {
.dg__scene { width: 200px; height: 100px; }
.dg__drop { width: 150px; top: 50px; height: 34px; }
}
@media (prefers-reduced-motion: reduce) {
.dg__chip { transition: none; }
.dg__drop { 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);
}
.dg {
display: grid;
gap: 12px;
justify-items: center;
width: 100%;
}
.dg__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.dg__scene {
position: relative;
width: 260px;
height: 190px;
display: flex;
flex-direction: column;
align-items: center;
}
.dg__chip {
position: relative;
z-index: 2;
border: 0;
background: #fff7e6;
border-radius: 999px;
padding: 12px;
cursor: grab;
color: #0a4fe8;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
touch-action: none;
will-change: transform;
}
.dg__chip:active {
cursor: grabbing;
}
.dg__chip:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.dg__shape {
fill: none;
stroke: #0a4fe8;
stroke-width: 1.6;
stroke-linejoin: round;
}
.dg__chip[aria-pressed=true] .dg__shape {
fill: #0a4fe8;
}
.dg__drop {
position: absolute;
top: 108px;
width: 220px;
height: 56px;
display: grid;
place-items: center;
border-radius: 16px;
border: 2px dashed #5b8cf8;
background: transparent;
transition: background 300ms ease-out, border-color 300ms ease-out, transform 300ms ease-out;
}
.dg__drop--near {
border-color: #2f6df6;
background: rgb(68.7254901961%, 88.137254902%, 100%);
transform: scale(1.03);
}
.dg__drop--filled {
border-style: solid;
border-color: #0a4fe8;
background: #6ec8ff;
}
.dg__dropLabel {
font-size: 12px;
font-weight: 700;
color: #0a4fe8;
}
.dg__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.dg__scene {
width: 200px;
height: 100px;
}
.dg__drop {
width: 150px;
top: 50px;
height: 34px;
}
}
@media (prefers-reduced-motion: reduce) {
.dg__chip {
transition: none;
}
.dg__drop {
transition: none;
}
}(function () {
"use strict";
var BOOKMARK_DRAG_SNAP_PX = 24;
var DRAG_INERTIA_DECAY = 0.86;
var DRAG_SETTLE_RATE = 0.22;
var CYCLE_S = 2;
var root = document.getElementById("dg");
var scene = document.getElementById("dgScene");
var chip = document.getElementById("dgChip");
var drop = document.getElementById("dgDrop");
var status = document.getElementById("dgStatus");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var dropX = 0, dropY = 78;
var chipX = 0, chipY = 0;
var velX = 0, velY = 0;
var targetX = 0, targetY = 0;
var dragging = false;
var saved = false;
var lastPX = 0, lastPY = 0;
function setSaved(v) {
saved = v;
chip.setAttribute("aria-pressed", String(v));
status.textContent = v ? "저장됨" : "목록 밖";
drop.classList.toggle("dg__drop--filled", v);
}
// 값 하나: 놓은 지점과 드롭존 사이 거리 vs BOOKMARK_DRAG_SNAP_PX → 목록에 스냅하거나 원위치로 튕긴다.
function release() {
var dx = chipX - dropX, dy = chipY - dropY;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= BOOKMARK_DRAG_SNAP_PX) {
targetX = dropX; targetY = dropY;
setSaved(true);
} else {
targetX = 0; targetY = 0;
setSaved(false);
}
}
function startDrag(clientX, clientY) {
root.classList.remove("is-demo");
dragging = true;
velX = 0; velY = 0;
lastPX = clientX; lastPY = clientY;
}
function moveDrag(clientX, clientY) {
var dx = clientX - lastPX, dy = clientY - lastPY;
chipX += dx; chipY += dy;
velX = dx; velY = dy;
lastPX = clientX; lastPY = clientY;
}
chip.addEventListener("pointerdown", function (e) {
chip.setPointerCapture(e.pointerId);
startDrag(e.clientX, e.clientY);
});
chip.addEventListener("pointermove", function (e) {
if (dragging) moveDrag(e.clientX, e.clientY);
});
chip.addEventListener("pointerup", function () {
if (dragging) { dragging = false; release(); }
});
chip.addEventListener("click", function () {
if (dragging) return;
root.classList.remove("is-demo");
if (saved) { targetX = 0; targetY = 0; setSaved(false); }
else { targetX = dropX; targetY = dropY; setSaved(true); }
});
function settle(dt) {
if (dragging) return;
if (!reduce) {
chipX += velX; chipY += velY;
velX *= DRAG_INERTIA_DECAY; velY *= DRAG_INERTIA_DECAY;
chipX += (targetX - chipX) * DRAG_SETTLE_RATE;
chipY += (targetY - chipY) * DRAG_SETTLE_RATE;
} else {
chipX = targetX; chipY = targetY; velX = 0; velY = 0;
}
}
function render() {
chip.style.transform = "translate(" + chipX.toFixed(2) + "px, " + chipY.toFixed(2) + "px)" +
(dragging ? " scale(1.06)" : "");
var near = Math.abs(chipX - dropX) < 40 && Math.abs(chipY - dropY) < 40;
drop.classList.toggle("dg__drop--near", near && (dragging || !saved && targetX === dropX));
}
// .is-demo 자동재생: 실제 pointer 이벤트 없이 chipX/Y·release() 를 같은 경로로 호출한다.
var start = null;
var prevT = 0;
var phase = "idle";
function synth(t) {
if (t > 0.1 && t < 0.55 && phase === "idle") {
phase = "dragIn";
dragging = true;
}
if (phase === "dragIn") {
var p = (t - 0.1) / 0.45;
p = Math.max(0, Math.min(1, p));
chipX = dropX * p; chipY = dropY * p;
if (t >= 0.55) { dragging = false; phase = "afterIn"; release(); }
}
if (t > 1.0 && t < 1.4 && phase === "afterIn") {
phase = "dragOut";
dragging = true;
}
if (phase === "dragOut") {
var q = (t - 1.0) / 0.4;
q = Math.max(0, Math.min(1, q));
chipX = dropX * (1 - q); chipY = dropY * (1 - q);
if (t >= 1.4) { dragging = false; phase = "done"; release(); }
}
}
function loop(now) {
if (start === null) start = now;
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { phase = "idle"; }
prevT = t;
if (!reduce && root.classList.contains("is-demo")) {
synth(t);
}
settle();
render();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// DragReadingListDropBookmark — vanilla 판(run/416/demo/09-bookmark-btn-drag-reading-list-drop/index.html) 의 React 포트.
// 값 하나: 놓은 지점 - 드롭존 거리 vs BOOKMARK_DRAG_SNAP_PX → 스냅 여부. 이후 관성(감쇠)+정착(lerp) 로 자리를 잡는다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./DragReadingListDropBookmark.module.scss";
type Box<T> = { current: T };
const BOOKMARK_DRAG_SNAP_PX = 24;
const DRAG_INERTIA_DECAY = 0.86;
const DRAG_SETTLE_RATE = 0.22;
const CYCLE_S = 2;
const DROP_X = 0;
const DROP_Y = 78;
export default function DragReadingListDropBookmark() {
const chipRef = useRef<HTMLButtonElement | null>(null);
const dropRef = useRef<HTMLDivElement | null>(null);
const chipXRef = useRef<number>(0) as Box<number>;
const chipYRef = useRef<number>(0) as Box<number>;
const velXRef = useRef<number>(0) as Box<number>;
const velYRef = useRef<number>(0) as Box<number>;
const targetXRef = useRef<number>(0) as Box<number>;
const targetYRef = useRef<number>(0) as Box<number>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const lastPXRef = useRef<number>(0) as Box<number>;
const lastPYRef = useRef<number>(0) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [saved, setSavedState] = useState(false);
const [status, setStatus] = useState("목록 밖");
const [near, setNear] = useState(false);
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function setSaved(v: boolean): void {
setSavedState(v);
setStatus(v ? "저장됨" : "목록 밖");
}
function release(): void {
const dx = chipXRef.current - DROP_X;
const dy = chipYRef.current - DROP_Y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= BOOKMARK_DRAG_SNAP_PX) {
targetXRef.current = DROP_X; targetYRef.current = DROP_Y;
setSaved(true);
} else {
targetXRef.current = 0; targetYRef.current = 0;
setSaved(false);
}
}
function startDrag(clientX: number, clientY: number): void {
setDemo(false);
draggingRef.current = true;
velXRef.current = 0; velYRef.current = 0;
lastPXRef.current = clientX; lastPYRef.current = clientY;
}
function moveDrag(clientX: number, clientY: number): void {
const dx = clientX - lastPXRef.current;
const dy = clientY - lastPYRef.current;
chipXRef.current += dx; chipYRef.current += dy;
velXRef.current = dx; velYRef.current = dy;
lastPXRef.current = clientX; lastPYRef.current = clientY;
}
function settle(): void {
if (draggingRef.current) return;
if (!reduce) {
chipXRef.current += velXRef.current;
chipYRef.current += velYRef.current;
velXRef.current *= DRAG_INERTIA_DECAY;
velYRef.current *= DRAG_INERTIA_DECAY;
chipXRef.current += (targetXRef.current - chipXRef.current) * DRAG_SETTLE_RATE;
chipYRef.current += (targetYRef.current - chipYRef.current) * DRAG_SETTLE_RATE;
} else {
chipXRef.current = targetXRef.current; chipYRef.current = targetYRef.current;
velXRef.current = 0; velYRef.current = 0;
}
}
function render(): void {
const chip = chipRef.current;
if (!chip) return;
chip.style.transform = "translate(" + chipXRef.current.toFixed(2) + "px, " + chipYRef.current.toFixed(2) + "px)" +
(draggingRef.current ? " scale(1.06)" : "");
const isNear = Math.abs(chipXRef.current - DROP_X) < 40 && Math.abs(chipYRef.current - DROP_Y) < 40;
setNear(isNear && (draggingRef.current || (!saved && targetXRef.current === DROP_X)));
}
function synth(t: number, phaseRef: Box<string>): void {
if (t > 0.1 && t < 0.55 && phaseRef.current === "idle") {
phaseRef.current = "dragIn";
draggingRef.current = true;
}
if (phaseRef.current === "dragIn") {
let p = (t - 0.1) / 0.45;
p = Math.max(0, Math.min(1, p));
chipXRef.current = DROP_X * p; chipYRef.current = DROP_Y * p;
if (t >= 0.55) { draggingRef.current = false; phaseRef.current = "afterIn"; release(); }
}
if (t > 1.0 && t < 1.4 && phaseRef.current === "afterIn") {
phaseRef.current = "dragOut";
draggingRef.current = true;
}
if (phaseRef.current === "dragOut") {
let q = (t - 1.0) / 0.4;
q = Math.max(0, Math.min(1, q));
chipXRef.current = DROP_X * (1 - q); chipYRef.current = DROP_Y * (1 - q);
if (t >= 1.4) { draggingRef.current = false; phaseRef.current = "done"; release(); }
}
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0;
let prevT = 0;
const phaseRef: Box<string> = { current: "idle" };
function loop(now: number): void {
if (start === 0) start = now;
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { phaseRef.current = "idle"; }
prevT = t;
if (!reduce && demo) {
synth(t, phaseRef);
}
settle();
render();
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo, saved]);
function onPointerDown(event: ReactPointerEvent<HTMLButtonElement>): void {
event.currentTarget.setPointerCapture(event.pointerId);
startDrag(event.clientX, event.clientY);
}
function onPointerMove(event: ReactPointerEvent<HTMLButtonElement>): void {
if (draggingRef.current) moveDrag(event.clientX, event.clientY);
}
function onPointerUp(): void {
if (draggingRef.current) { draggingRef.current = false; release(); }
}
function onClick(): void {
if (draggingRef.current) return;
setDemo(false);
if (saved) { targetXRef.current = 0; targetYRef.current = 0; setSaved(false); }
else { targetXRef.current = DROP_X; targetYRef.current = DROP_Y; setSaved(true); }
}
const dropCls = [styles.drop, near ? styles.dropNear : "", saved ? styles.dropFilled : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={styles.dg}>
<p className={styles.caption}>끌어서 목록에 저장</p>
<div className={styles.scene}>
<div className={dropCls} ref={dropRef}>
<span className={styles.dropLabel}>읽기 목록에 놓기</span>
</div>
<button
className={styles.chip}
ref={chipRef}
type="button"
aria-pressed={saved}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onClick={onClick}
>
<svg className={styles.icon} viewBox="0 0 24 24" width={26} height={26} 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">{status}</p>
</div>
</main>
);
}09 · 드래그해서 읽기 목록에 놓는 북마크 — 측정 누적면적 14.492% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 값 하나: 놓은 지점과 드롭존 사이 거리 vs BOOKMARK_DRAG_SNAP_PX → 목록에 스냅하거나 원위치로 튕긴다.
function release() {
var dx = chipX - dropX, dy = chipY - dropY;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= BOOKMARK_DRAG_SNAP_PX) {
targetX = dropX; targetY = dropY;
setSaved(true);
} else {
targetX = 0; targetY = 0;
setSaved(false);
}
}
측정: 누적면적 14.5% · 강도 평균 43.1 · 19/23 프레임에서 움직였다.
즐겨찾기 버튼과 북마크 버튼은 뭐가 다른가?
북마크 버튼과 즐겨찾기 버튼은 같은 저장 동작을 가리키는 다른 이름일 뿐이다 — 콘텐츠 사이트는 북마크, 쇼핑몰은 위시리스트, 뉴스 앱은 책갈피 버튼이라는 말을 더 많이 쓰지만 아이콘을 누르면 상태 하나(saved)가 뒤집히는 계산 구조는 같다. 하트 좋아요 애니메이션 9종의 값은 저장이 아니라 반응(공감)의 크기를 키우는 데 쓰이는 반면, 여기 아홉 개는 전부 같은 상태를 저장했는지 아닌지 두 값으로만 가른다는 점에서 계산 목적이 갈린다. 07의 저장 완료 문구는 토스트 알림 9종의 등장·유지·퇴장 세 구간 계산을 그대로 가져와 저장이라는 한 가지 이벤트에만 물렸고, 09의 스냅 거리 계산은 드래그 정렬 UI 패턴 9종에서 목록 순서를 바꾸는 데 쓰던 거리 비교를 저장 여부를 가르는 데로 옮겨 왔다.
북마크 버튼 아홉 개를 숫자로 비교하면
| NN 이름 | 누적면적 % | 강도 평균 | x/23 |
|---|---|---|---|
| 01 포인터 벡터 값이 기울기와 광택 각도를 함께 정하는 북마크 아이콘 | 17.3 | 24.3 | 21/23 |
| 02 채움 진행률 값이 윤곽선을 안쪽부터 채우는 북마크 버튼 | 16.0 | 37.6 | 7/23 |
| 03 스프링 적분 값이 튕겨 커졌다 되돌아오는 북마크 버튼 | 9.2 | 28.0 | 12/23 |
| 04 접힘 각도 값이 그림자까지 진하게 만드는 리본 북마크 | 10.6 | 64.4 | 17/23 |
| 05 물 높이 값이 사인 물결로 차오르는 북마크 아이콘 | 10.1 | 68.8 | 11/23 |
| 06 자릿수 지연 값이 한 칸씩 굴려 올리는 저장 수 배지 | 12.3 | 69.5 | 12/23 |
| 07 경과 시간 값이 진입·유지·퇴장을 가르는 저장 토스트 | 10.5 | 109.0 | 11/23 |
| 08 롱프레스 임계 값이 목록을 여닫는 컬렉션 팝오버 | 31.7 | 79.1 | 6/23 |
| 09 스냅 거리 값이 목록에 붙을지 되튈지 가르는 드래그 북마크 | 14.5 | 43.1 | 19/23 |
면적이 가장 큰 것은 08이다 — 패널 전체가 새로 나타나는 화면이라 아이콘 하나만 움직이는 나머지보다 넓게 반응한다. 02·07·08은 24프레임 중 절반도 안 되는 구간에서만 값이 바뀌는데, 이 셋은 모두 클릭 한 번 뒤에만 짧게 반응하고 나머지 시간은 값이 그대로 멈춰 있는 이벤트형 계산이기 때문이다. 강도 평균은 07이 가장 높다 — 토스트가 짧은 시간 안에 위치·불투명도를 크게 바꾸는 구간이 몰려 있어서다.
값 하나 숫자만 바꿔도 북마크 버튼 반응이 갈라지는 변형 세 가지
| 항목 | 바꾸는 값 | 줄이면 | 늘리면 |
|---|---|---|---|
| 03 | BOOKMARK_SPRING_OVERSHOOT_PERCENT 12 → 38 | 튕김 폭이 짧아져 크기 변화가 거의 트랜지션처럼 차분해진다 | 넘치는 폭이 커져 한 번 크게 부풀었다 가라앉는 동작이 뚜렷해진다 |
| 06 | BOOKMARK_COUNT_STAGGER_MS 40 → 160 | 두 자리가 거의 같이 바뀌어 굴러가는 느낌이 옅어진다 | 오른쪽·왼쪽 자리가 시차를 두고 따로 굴러가는 게 눈에 보인다 |
| 09 | BOOKMARK_DRAG_SNAP_PX 24 → 96 | 자리 바로 위까지 정확히 끌고 가야만 붙는다 | 자리에서 멀리 떨어뜨려도 저절로 끌려가 붙는다 |
위 세 값을 실제로 어떻게 바꿔 넣었는지 원본 그대로 보고 싶다면, 압축을 열 때 w36j826f 을 넣어 변형 전후 코드를 나란히 대조해 볼 수 있다.
북마크 버튼을 실제로 어디에 놓으면 좋은가
북마크 아이콘 만들기를 검색해 여기까지 왔다면 01·02의 기본형만 옮겨도 충분하다 — 호버 반응이나 채움 모양 정도만 있어도 대부분의 카드 목록에 자연스럽게 어울린다. 글 목록 카드에는 01이, 제품 위시리스트에는 02나 05처럼 채워지는 모양이 어울리고, 댓글·짧은 목록의 즉각적인 반응에는 03이 맞는다. 저장 수를 사용자에게 보여줘야 하는 화면에는 06을, 저장 직후 확인이 필요한 모바일 화면에는 07을 붙인다. 폴더별로 나눠 저장해야 하는 레시피·기사 사이트는 08을, 순서를 직접 옮겨 정리하는 북마크 관리 화면은 09를 그대로 쓸 수 있다.
어디서 깨지는가 — 동작 줄임 설정·터치 오탐·중복 클릭
동작 줄임(prefers-reduced-motion)을 켜면 아홉 개 모두 루프 계산을 건너뛰고 최종 상태 값으로 바로 넘어간다 — 03은 스프링 적분 대신 pos 를 1로 고정하고, 06은 컬럼 지연을 전부 0으로 만들어 자릿수가 한 번에 바뀐다(MDN, prefers-reduced-motion). 08은 누른 시간만으로 롱프레스를 판정하고 pointermove·pointercancel 을 듣지 않는다 — 손가락이 눌린 채로 화면을 살짝 움직여도 그 동작이 그대로 눌린 시간으로 잡혀 스크롤 대신 목록 패널이 먼저 열릴 수 있다(MDN, Pointer Events). 09는 반대로 드래그 칩에 touch-action:none 을 걸어 두어 이 문제를 미리 막아 뒀다. 잠금 값이 없는 항목은 연속 클릭이 그대로 쌓인다 — 03을 두 번 빠르게 누르면 속도 값이 두 번 더해져 더 크게 튕기고, 06을 구르는 도중에 다시 누르면 진행 중이던 컬럼 지연이 새 값으로 덮여 자릿수가 중간에 다시 시작한다. 실제 코드에서 이 부분을 어떻게 짰는지는 아홉 개 js 를 열어 직접 대조해 보면 가장 빠르다.
접근성 — reduced-motion·aria-pressed·키보드로 북마크 버튼 다루기
아홉 개 버튼 전부 aria-pressed 값을 저장 여부에 맞춰 true·false 로 바꾸고, 상태 문구는 aria-live="polite" 영역에 다시 쓰여 스크린 리더가 "저장됨" 같은 변화를 읽어 준다. 버튼은 모두 네이티브 button 요소라 Tab 으로 포커스를 옮기고 Enter·Space 로 그대로 누를 수 있다 — 별도의 keydown 처리를 얹지 않아도 기본 동작이 클릭을 대신한다. 08의 컬렉션 팝오버는 롱프레스가 포인터 전용이라 여기에 별도 키보드 대안을 얹어 뒀다 — 트리거에 aria-haspopup·aria-expanded 를 붙이고 목록에 role="menu"·항목마다 role="menuitem" 을 달아, Enter·Space 는 짧은 저장 토글을 그대로 실행하고 ArrowDown 또는 Shift+Enter 는 목록 패널을 열며 첫 항목으로 포커스를 옮긴다. 패널이 열린 뒤에는 ArrowUp·ArrowDown 으로 항목 사이를 오가고 Enter 로 그 항목을 고르며, Escape 는 패널을 닫고 포커스를 트리거 버튼으로 되돌린다.
북마크 버튼을 실무에 붙이기 전, 자주 헷갈리는 세 가지
북마크 아이콘 표시가 저장 전후로 정말 바뀌는지 어떻게 확인하나?
버튼의 aria-pressed 속성과 아이콘에 붙는 class(is-saved 류)가 클릭마다 같이 바뀐다. 요소를 열어 두고 클릭해 보면 속성 값이 true·false 로 뒤집히는 게 그대로 보인다.
아홉 개 전부 같은 아이콘 모양(SVG)을 써야 하나?
아니다. 여기서 계산이 붙는 건 아이콘을 감싼 버튼·패널의 움직임이지 아이콘 모양 자체가 아니다. 리본이든 별이든 원하는 SVG path 로 바꿔도 tiltX·fill 진행률 같은 값 계산은 그대로 옮겨 쓸 수 있다.
저장 수 같은 서버 값이 없는 페이지에도 06을 쓸 수 있나?
쓸 수 있다. 06은 count 변수 하나만 늘리고 줄이는 로컬 계산이라 서버 응답 없이도 그대로 동작한다. 실제로 붙일 때는 저장·해제 응답을 받은 뒤 그 값으로 count 를 갱신하도록 한 줄만 바꾸면 된다.








