장바구니 담기 UI 9종 — 스프링부터 배지 관성까지
장바구니 담기 UI는 상품을 화면 옆 패널로 열어 바로 보여주는 장치인데, 아홉 개 중 하나는 여는 위치 값이 목표를 살짝 지나쳤다가 스프링 곡선을 따라 되돌아오고, 하나는 끄는 거리 값이 기준을 넘은 상태에서 저항이 커지며, 또 하나는 담긴 개수 값이 바뀔 때마다 아이콘 위 숫자가
아홉 화면 모두 실제 결제·재고 서버와 통신하지 않는 상태 전환 시뮬레이션이고, 상품·가격은 전부 가상입니다. 리스너는 01~04·06·08·09 가 클릭, 05 가 pointerdown·pointermove·pointerup, 07 이 wheel·pointerdown·scroll·resize 인데, 자동으로만 재생되고 마는 항목은 아홉 중 하나도 없이 전부 이 실제 입력이 들어오는 순간 같은 함수가 다시 값을 계산합니다.
01 cart-drawer-slide-spring-overshoot
02 cart-drawer-backdrop-depth-scale
03 cart-drawer-item-stagger-cascade
04 cart-drawer-qty-bump-elastic
05 cart-drawer-swipe-dismiss-rubberband
06 cart-drawer-empty-state-crossfade
07 cart-drawer-sticky-checkout-reveal
08 cart-drawer-remove-item-collapse
09 cart-drawer-badge-count-inertia
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 슬라이드 오버슛 — 열림 위치 값이 목표를 넘었다가 되돌아오는 장바구니 패널
- 02 배경 축소 — 패널이 열린 만큼 뒤 화면 크기 값이 줄어드는 깊이 표현
- 03 항목 순차 등장 — 목록 순서 값만큼 지연이 늘어나는 장바구니 줄
- 04 수량 탄성 범프 — 늘어난 값만큼 커졌다가 되돌아오는 수량 숫자
- 05 드래그 러버밴드 — 끄는 거리 값이 한계를 넘으면 저항이 커지는 닫기 동작
- 06 빈 상태 교차 — 항목 수 값이 0에서 바뀌는 순간 겹쳐 사라지는 안내문
- 07 결제 바 높이 반전 — 스크롤 값에 따라 그림자와 높이 값이 바뀌는 하단 바
- 08 삭제 높이 접힘 — 사라지는 줄의 높이 값이 0으로 줄며 아래 줄이 채워지는 자리
- 09 배지 수 관성 — 바뀐 값을 향해 서서히 따라붙는 아이콘 위 숫자
쇼핑몰 장바구니 UI 를 처음 만들 때는 담기 버튼 하나만 붙이고 끝내기 쉬운데, 아래 표는 아홉 패턴이 화면을 실제로 얼마나 흔들었는지 잰 값이고, 목표를 향해 물리적으로 수렴하는 값(01·04·09) → 경계에 닿는 순간 상태가 바뀌는 값(05·06·08) → 하나의 진행 값에서 여러 속성이 함께 파생되는 값(02·03·07) 순으로 묶었습니다.
아홉 개의 해부 — 위치·거리·개수 값이 언제 바뀌는가
실측 한눈에
| 항목 | 초점 | 누적면적 % | 강도 평균 | 움직인 프레임 |
|---|---|---|---|---|
| 01 슬라이드 오버슛 | 열림 위치 값 → 스프링 재계산 | 100.00 | 165.3 | 19/23 |
| 02 배경 축소 | 진행 값 → 크기·어둡기 재계산 | 55.72 | 133.1 | 15/23 |
| 03 항목 순차 등장 | 순서 값 → 지연 값 재계산 | 29.92 | 96.6 | 10/23 |
| 04 수량 탄성 범프 | 변화 폭 값 → 크기 값 재계산 | 8.75 | 45.1 | 16/23 |
| 05 드래그 러버밴드 | 거리 값 → 저항 곡선 재계산 | 8.52 | 161.6 | 16/23 |
| 06 빈 상태 교차 | 개수 0 경계 → 짙기 값 재계산 | 14.41 | 74.6 | 8/23 |
| 07 결제 바 반응 | 스크롤 위치 값 → 그림자·높이 재계산 | 8.05 | 110.1 | 23/23 |
| 08 삭제 높이 접힘 | 실측 높이 값 → 0까지 보간 | 26.95 | 47.6 | 8/23 |
| 09 배지 수 관성 | 개수 값 → 관성 lerp 재계산 | 0.23 | 70.4 | 22/23 |
01 은 패널과 배경이 함께 움직여 누적면적이 100.00%로 9종 중 가장 크고, 09 는 아이콘 위 숫자 하나만 바뀌어 0.23%로 가장 작습니다 — 둘 다 계산 대상 자체의 크기 차이일 뿐, 09 가 덜 계산된 게 아닙니다. 강도 평균은 05 가 161.6 으로 가장 높아 빨간 쓰레기통 배경과 카드 사이의 픽셀 값 차이가 뚜렷하다는 뜻이고, 04 는 45.1 로 가장 낮아 옅은 카드 위 범프의 인접 색 차이가 완만하다는 뜻입니다. 움직인 프레임은 07 이 23/23 으로 유일하게 매 프레임 값을 흘리는 구조라는 뜻이고, 06·08 은 8/23 으로 가장 낮아 각각 420ms·320ms 안에 계산이 끝난 뒤 나머지 캡처 구간은 정지 상태이기 때문입니다.
목표를 향해 물리적으로 수렴하는 값 — 01·04·09
이 셋은 목표 값에 한 번에 닿지 않고 매 프레임 다시 계산해 물리적으로 수렴합니다. 01 은 springProgress()가 감쇠비(ZETA)·고유진동수(OMEGA)로 감쇠 조화진동자 방정식을 풀어 패널 위치 값이 목표를 넘었다가(오버슛) 되돌아오게 하고, 04 는 elasticPulse()가 감쇠 사인파로 수량 숫자의 크기 값을 계산해 커졌다가 원래 크기로 잦아들게 합니다. 09 는 lerp()가 표시 숫자(displayed)와 목표 숫자(target)의 차이를 매 프레임 16%씩 줄이는 관성 값을 만들어, 담긴 개수 값이 한 번에 뛰지 않고 서서히 따라붙게 합니다.
경계에 닿는 순간 상태가 바뀌는 값 — 05·06·08
이 셋은 값이 특정 경계에 닿는 순간 상태 자체가 바뀝니다. 05 는 rubberband()가 드래그 거리 값이 THRESHOLD(90px)를 넘으면 저항을 걸다가, 그 값이 THRESHOLD의 1.3배를 넘은 상태가 되면 dismiss()가 카드를 한 번에 밀어냅니다. 06 은 crossfadeApply()가 항목 수 값이 0인지 아닌지(hasItems)만으로 안내문과 목록의 짙기 값을 상보적으로 계산하고, 08 은 collapseRow()가 삭제된 줄의 실측 높이 값을 0까지 줄인 뒤 그 값이 0에 닿은 프레임에서 row.remove()로 줄 자체를 없앱니다.
하나의 진행 값에서 여러 속성이 함께 파생되는 값 — 02·03·07
이 셋은 값 하나를 계산해 두 가지 이상의 화면 속성에 동시에 반영합니다. 02 는 apply(progress)가 이징한 값 하나로 뒤 화면의 scale과 filter(밝기)를 함께 계산하고, 03 은 computeDelay(indexInOrder, total)가 총 등장 시간(TOTAL_SPREAD_MS)을 항목 수로 나눠 각 줄의 지연 값을 순서마다 다시 구합니다. 07 은 tick()이 스크롤 진행률을 lerp()로 보간한 값 하나로 그림자 짙기(--shadow-op)와 바의 세로 위치(translateY)를 함께 계산합니다.
패턴별 코드
01슬라이드 오버슛 — 열림 위치 값이 목표를 넘었다가 되돌아오는 장바구니 패널
패널이 열린 상태가 되면 위치 값이 목표 지점을 살짝 지나쳤다가 되돌아오는 스프링 곡선을 따라 자리를 잡고, 닫힌 상태가 되면 같은 곡선의 반대 방향으로 위치 값이 줄어듭니다. 온라인 쇼핑몰 장바구니 패널, 사이드 카트에 붙이기 좋은 형태입니다.
감쇠비와 고유진동수로 오버슛 위치 값을 계산하는 함수
// 계산 겹: 감쇠 조화진동자(스프링) 방정식을 매 프레임 직접 풀어 위치 값을 만든다 — easing 곡선을
// 빌려 쓰는 게 아니라 감쇠비(ZETA)·고유진동수(OMEGA) 로 목표를 넘었다가(오버슛) 되돌아오는 값을 계산한다.
var ZETA = 0.42;
var OMEGA = 9.5;
var OMEGA_D = OMEGA * Math.sqrt(1 - ZETA * ZETA);
function springProgress(tSec) {
if (tSec <= 0) { return 0; }
var env = Math.exp(-ZETA * OMEGA * tSec);
return 1 - env * (Math.cos(OMEGA_D * tSec) + (ZETA * OMEGA / OMEGA_D) * Math.sin(OMEGA_D * tSec));
}

<main class="stage">
<div class="cdso is-demo" id="cdso">
<div class="cdso__page">
<div class="cdso__card"></div>
<div class="cdso__card"></div>
<div class="cdso__card"></div>
<button class="cdso__toggle" id="cdsoToggle" type="button">장바구니 열기</button>
</div>
<div class="cdso__backdrop" id="cdsoBackdrop"></div>
<aside class="cdso__panel" id="cdsoPanel">
<header class="cdso__head">
<span class="cdso__title">장바구니</span>
<button class="cdso__close" id="cdsoClose" type="button" aria-label="닫기">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></g></svg>
</button>
</header>
<ul class="cdso__list">
<li class="cdso__row"><span>코튼 티셔츠</span><b>$24</b></li>
<li class="cdso__row"><span>캔버스 백</span><b>$18</b></li>
<li class="cdso__row"><span>세라믹 머그</span><b>$12</b></li>
</ul>
<footer class="cdso__foot"><span>합계</span><b>$54</b></footer>
</aside>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: JS 가 감쇠 조화진동자 방정식(springProgress())으로 위치 값을 매 프레임 직접 계산해
// translateX 에 대입한다 — CSS 는 결과를 받기만 하고 @keyframes 로 흉내내지 않는다.
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.stage { position: relative; }
.cdso__page {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
width: min(200px, 62vw);
}
.cdso__card {
width: 100%;
height: 40px;
border-radius: $r-control;
background: rgba(255, 247, 230, .12);
}
.cdso__toggle {
margin-top: $sp-2;
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-5;
background: $color;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
cursor: pointer;
}
.cdso__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .55);
opacity: 0;
pointer-events: none;
}
.cdso__panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(72%, 240px);
box-sizing: border-box;
display: flex;
flex-direction: column;
background: $subject-cream;
padding: $sp-4;
gap: $sp-3;
transform: translateX(100%);
box-shadow: $shadow-raised;
}
.cdso__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.cdso__title {
color: $subject-ink;
font-weight: 700;
font-size: 14px;
}
.cdso__close {
width: 28px;
height: 28px;
border: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
color: $subject-ink;
display: grid;
place-items: center;
cursor: pointer;
}
.cdso__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.cdso__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba(23, 20, 26, .06);
color: $subject-ink;
font-size: 12px;
font-weight: 600;
}
.cdso__foot {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
padding-top: $sp-3;
border-top: 1px solid rgba(23, 20, 26, .12);
color: $subject-ink;
font-weight: 700;
font-size: 14px;
}
@media (max-width: 340px) {
.cdso__page { width: min(170px, 60vw); gap: $sp-2; }
.cdso__card { height: 32px; }
.cdso__panel { width: min(78%, 200px); padding: $sp-3; gap: $sp-2; }
.cdso__row { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.cdso__panel, .cdso__backdrop { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.cdso__page {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(200px, 62vw);
}
.cdso__card {
width: 100%;
height: 40px;
border-radius: 12px;
background: rgba(255, 247, 230, 0.12);
}
.cdso__toggle {
margin-top: 8px;
border: 0;
border-radius: 999px;
padding: 8px 20px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
cursor: pointer;
}
.cdso__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
opacity: 0;
pointer-events: none;
}
.cdso__panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(72%, 240px);
box-sizing: border-box;
display: flex;
flex-direction: column;
background: #fff7e6;
padding: 16px;
gap: 12px;
transform: translateX(100%);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cdso__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.cdso__title {
color: #17141a;
font-weight: 700;
font-size: 14px;
}
.cdso__close {
width: 28px;
height: 28px;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
display: grid;
place-items: center;
cursor: pointer;
}
.cdso__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.cdso__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.06);
color: #17141a;
font-size: 12px;
font-weight: 600;
}
.cdso__foot {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 12px;
border-top: 1px solid rgba(23, 20, 26, 0.12);
color: #17141a;
font-weight: 700;
font-size: 14px;
}
@media (max-width: 340px) {
.cdso__page {
width: min(170px, 60vw);
gap: 8px;
}
.cdso__card {
height: 32px;
}
.cdso__panel {
width: min(78%, 200px);
padding: 12px;
gap: 8px;
}
.cdso__row {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.cdso__panel, .cdso__backdrop {
transition: none !important;
}
}(function () {
var root = document.getElementById('cdso');
var panel = document.getElementById('cdsoPanel');
var backdrop = document.getElementById('cdsoBackdrop');
var toggle = document.getElementById('cdsoToggle');
var closeBtn = document.getElementById('cdsoClose');
// 계산 겹: 감쇠 조화진동자(스프링) 방정식을 매 프레임 직접 풀어 위치 값을 만든다 — easing 곡선을
// 빌려 쓰는 게 아니라 감쇠비(ZETA)·고유진동수(OMEGA) 로 목표를 넘었다가(오버슛) 되돌아오는 값을 계산한다.
var ZETA = 0.42;
var OMEGA = 9.5;
var OMEGA_D = OMEGA * Math.sqrt(1 - ZETA * ZETA);
function springProgress(tSec) {
if (tSec <= 0) { return 0; }
var env = Math.exp(-ZETA * OMEGA * tSec);
return 1 - env * (Math.cos(OMEGA_D * tSec) + (ZETA * OMEGA / OMEGA_D) * Math.sin(OMEGA_D * tSec));
}
var DURATION_S = 0.85;
var isOpen = false;
var raf = null;
function apply(v) {
var pct = (1 - v) * 100;
panel.style.transform = 'translateX(' + pct.toFixed(2) + '%)';
backdrop.style.opacity = Math.min(1, Math.max(0, v)).toFixed(3);
}
function run(closing) {
if (raf) { cancelAnimationFrame(raf); }
var start = performance.now();
function frame(now) {
var t = (now - start) / 1000;
var raw = springProgress(Math.min(t, DURATION_S));
var v = closing ? (1 - raw) : raw;
apply(v);
if (t < DURATION_S) {
raf = requestAnimationFrame(frame);
} else {
apply(closing ? 0 : 1);
raf = null;
isOpen = !closing;
}
}
raf = requestAnimationFrame(frame);
}
function stopAutoplay() {
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
}
toggle.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
run(isOpen);
});
closeBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
run(true);
});
backdrop.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
run(true);
});
var autoplayTimer = null;
function autoplayTick() {
run(isOpen);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
apply(1);
} else {
run(false);
autoplayTimer = setInterval(autoplayTick, 1050);
}
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./CartDrawerSlideSpringOvershoot.module.scss";
export type CartDrawerSlideSpringOvershootProps = {
/** 스프링이 정착하는 데 걸리는 대략 시간 (기본 850ms) */
duration?: number;
color?: string;
};
// 계산 겹: 감쇠 조화진동자(스프링) 방정식을 매 프레임 직접 풀어 위치 값을 만든다 — easing 곡선을
// 빌려 쓰는 게 아니라 감쇠비(ZETA)·고유진동수(OMEGA) 로 목표를 넘었다가(오버슛) 되돌아오는 값을 계산한다.
const ZETA = 0.42;
const OMEGA = 9.5;
const OMEGA_D = OMEGA * Math.sqrt(1 - ZETA * ZETA);
function springProgress(tSec: number): number {
if (tSec <= 0) return 0;
const env = Math.exp(-ZETA * OMEGA * tSec);
return 1 - env * (Math.cos(OMEGA_D * tSec) + ((ZETA * OMEGA) / OMEGA_D) * Math.sin(OMEGA_D * tSec));
}
export default function CartDrawerSlideSpringOvershoot({
duration = 850,
color = "#fff7e6",
}: CartDrawerSlideSpringOvershootProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLElement | null>(null);
const backdropRef = useRef<HTMLDivElement | null>(null);
const isOpenRef = useRef(false);
const rafRef = useRef<number | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
const durationS = duration / 1000;
function apply(v: number) {
const panel = panelRef.current;
const backdrop = backdropRef.current;
if (!panel || !backdrop) return;
const pct = (1 - v) * 100;
panel.style.transform = `translateX(${pct.toFixed(2)}%)`;
backdrop.style.opacity = Math.min(1, Math.max(0, v)).toFixed(3);
}
function run(closing: boolean) {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
const start = performance.now();
function frame(now: number) {
const t = (now - start) / 1000;
const raw = springProgress(Math.min(t, durationS));
const v = closing ? 1 - raw : raw;
apply(v);
if (t < durationS) {
rafRef.current = requestAnimationFrame(frame);
} else {
apply(closing ? 0 : 1);
rafRef.current = null;
isOpenRef.current = !closing;
}
}
rafRef.current = requestAnimationFrame(frame);
}
function stopAutoplay() {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
function onRealInput(closing: boolean) {
rootRef.current?.classList.remove(styles.isDemo);
stopAutoplay();
run(closing);
}
const toggle = () => onRealInput(!!isOpenRef.current);
const close = () => onRealInput(true);
const toggleEl = rootRef.current?.querySelector<HTMLButtonElement>(`.${styles.toggle}`);
const closeEl = rootRef.current?.querySelector<HTMLButtonElement>(`.${styles.close}`);
toggleEl?.addEventListener("click", toggle);
closeEl?.addEventListener("click", close);
backdropRef.current?.addEventListener("click", close);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
apply(1);
} else {
run(false);
timerRef.current = setInterval(() => run(!!isOpenRef.current), 1050);
}
return () => {
stopAutoplay();
if (rafRef.current) cancelAnimationFrame(rafRef.current);
toggleEl?.removeEventListener("click", toggle);
closeEl?.removeEventListener("click", close);
backdropRef.current?.removeEventListener("click", close);
};
}, [duration]);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`} style={{ "--color": color } as CSSProperties}>
<div className={styles.page}>
<div className={styles.card} />
<div className={styles.card} />
<div className={styles.card} />
<button className={styles.toggle} type="button">장바구니 열기</button>
</div>
<div ref={backdropRef} className={styles.backdrop} />
<aside ref={panelRef} className={styles.panel}>
<header className={styles.head}>
<span className={styles.title}>장바구니</span>
<button className={styles.close} type="button" aria-label="닫기">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></g></svg>
</button>
</header>
<ul className={styles.list}>
<li className={styles.row}><span>코튼 티셔츠</span><b>$24</b></li>
<li className={styles.row}><span>캔버스 백</span><b>$18</b></li>
<li className={styles.row}><span>세라믹 머그</span><b>$12</b></li>
</ul>
<footer className={styles.foot}><span>합계</span><b>$54</b></footer>
</aside>
</div>
);
}01 · 슬라이드 오버슛 — 열림 위치 값이 목표를 넘었다가 되돌아오는 장바구니 패널 — 측정 누적면적 100.0% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02배경 축소 — 패널이 열린 만큼 뒤 화면 크기 값이 줄어드는 깊이 표현
패널의 열림 진행 값이 커질수록 뒤에 있는 화면의 크기 값과 어둡기 값이 함께 줄어들어 패널이 화면 앞으로 떠오른 것처럼 보이고, 닫히는 진행 값에서는 두 값이 같은 비율로 되돌아갑니다. 모바일 쇼핑 앱 장바구니, 커머스 사이트에 맞는 형태입니다.
하나의 진행 값에서 scale 과 filter 를 함께 계산하는 apply()
// 계산 겹: 패널의 열림 진행 값(progress, 0~1)을 이징 함수로 보간해 뒤 화면의 크기 값과
// 어둡기 값을 같은 비율로 계산한다 — scale·filter 를 각자 애니메이션하지 않고 한 값에서 둘을 도출한다.
function easeInOutCubic(p) {
return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
}
var DURATION_S = 0.5;
var MIN_SCALE = 0.86;
var MAX_DIM = 0.5;
var isOpen = false;
var raf = null;
function apply(progress) {
var eased = easeInOutCubic(Math.min(1, Math.max(0, progress)));
var scale = 1 - eased * (1 - MIN_SCALE);
var dim = eased * MAX_DIM;
page.style.transform = 'scale(' + scale.toFixed(4) + ')';
page.style.filter = 'brightness(' + (1 - dim).toFixed(3) + ')';
panel.style.transform = 'translateX(' + ((1 - eased) * 100).toFixed(2) + '%)';
}

<main class="stage">
<div class="cdbd is-demo" id="cdbd">
<div class="cdbd__page" id="cdbdPage">
<div class="cdbd__bar"></div>
<div class="cdbd__card"></div>
<div class="cdbd__card"></div>
<button class="cdbd__toggle" id="cdbdToggle" type="button">장바구니</button>
</div>
<aside class="cdbd__panel" id="cdbdPanel">
<header class="cdbd__head"><span>장바구니</span><b>3</b></header>
<ul class="cdbd__list">
<li>운동화</li>
<li>양말 세트</li>
<li>물병</li>
</ul>
</aside>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 열림 진행 값(0~1)을 easeInOutCubic() 으로 보간해 뒤 화면의 scale·brightness 값을
// 같은 비율로 도출한다(apply()) — 두 속성을 따로 애니메이션하지 않고 한 계산에서 나온다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.stage { position: relative; overflow: hidden; }
.cdbd__page {
display: flex;
flex-direction: column;
align-items: stretch;
gap: $sp-2;
width: min(220px, 66vw);
transform-origin: center;
will-change: transform, filter;
}
.cdbd__bar {
height: 18px;
border-radius: $r-sm;
background: rgba(23, 20, 26, .8);
width: 60%;
}
.cdbd__card {
height: 46px;
border-radius: $r-control;
background: rgba(23, 20, 26, .14);
}
.cdbd__toggle {
margin-top: $sp-2;
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-5;
background: $color;
color: $stage-yellow;
font-family: inherit;
font-weight: 700;
font-size: 12px;
cursor: pointer;
align-self: flex-start;
}
.cdbd__panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(70%, 230px);
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-3;
padding: $sp-4;
background: $color;
color: $subject-cream;
transform: translateX(100%);
box-shadow: $shadow-raised;
}
.cdbd__head {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 700;
font-size: 14px;
}
.cdbd__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.cdbd__list li {
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba(255, 247, 230, .1);
font-size: 12px;
font-weight: 600;
}
@media (max-width: 340px) {
.cdbd__page { width: min(180px, 62vw); }
.cdbd__card { height: 36px; }
.cdbd__panel { width: min(76%, 190px); padding: $sp-3; gap: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.cdbd__page, .cdbd__panel { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
overflow: hidden;
}
.cdbd__page {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
width: min(220px, 66vw);
transform-origin: center;
will-change: transform, filter;
}
.cdbd__bar {
height: 18px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.8);
width: 60%;
}
.cdbd__card {
height: 46px;
border-radius: 12px;
background: rgba(23, 20, 26, 0.14);
}
.cdbd__toggle {
margin-top: 8px;
border: 0;
border-radius: 999px;
padding: 8px 20px;
background: #17141a;
color: #ffd23f;
font-family: inherit;
font-weight: 700;
font-size: 12px;
cursor: pointer;
align-self: flex-start;
}
.cdbd__panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(70%, 230px);
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: #17141a;
color: #fff7e6;
transform: translateX(100%);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cdbd__head {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 700;
font-size: 14px;
}
.cdbd__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.cdbd__list li {
padding: 8px 12px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.1);
font-size: 12px;
font-weight: 600;
}
@media (max-width: 340px) {
.cdbd__page {
width: min(180px, 62vw);
}
.cdbd__card {
height: 36px;
}
.cdbd__panel {
width: min(76%, 190px);
padding: 12px;
gap: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cdbd__page, .cdbd__panel {
transition: none !important;
}
}(function () {
var root = document.getElementById('cdbd');
var page = document.getElementById('cdbdPage');
var panel = document.getElementById('cdbdPanel');
var toggle = document.getElementById('cdbdToggle');
// 계산 겹: 패널의 열림 진행 값(progress, 0~1)을 이징 함수로 보간해 뒤 화면의 크기 값과
// 어둡기 값을 같은 비율로 계산한다 — scale·filter 를 각자 애니메이션하지 않고 한 값에서 둘을 도출한다.
function easeInOutCubic(p) {
return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
}
var DURATION_S = 0.5;
var MIN_SCALE = 0.86;
var MAX_DIM = 0.5;
var isOpen = false;
var raf = null;
function apply(progress) {
var eased = easeInOutCubic(Math.min(1, Math.max(0, progress)));
var scale = 1 - eased * (1 - MIN_SCALE);
var dim = eased * MAX_DIM;
page.style.transform = 'scale(' + scale.toFixed(4) + ')';
page.style.filter = 'brightness(' + (1 - dim).toFixed(3) + ')';
panel.style.transform = 'translateX(' + ((1 - eased) * 100).toFixed(2) + '%)';
}
function run(closing) {
if (raf) { cancelAnimationFrame(raf); }
var start = performance.now();
var from = closing ? 1 : 0;
var to = closing ? 0 : 1;
function frame(now) {
var t = Math.min(1, (now - start) / (DURATION_S * 1000));
apply(from + (to - from) * t);
if (t < 1) {
raf = requestAnimationFrame(frame);
} else {
raf = null;
isOpen = !closing;
}
}
raf = requestAnimationFrame(frame);
}
function stopAutoplay() {
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
}
toggle.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
run(isOpen);
});
var autoplayTimer = null;
function autoplayTick() { run(isOpen); }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
apply(1);
} else {
run(false);
autoplayTimer = setInterval(autoplayTick, 900);
}
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./CartDrawerBackdropDepthScale.module.scss";
export type CartDrawerBackdropDepthScaleProps = {
duration?: number;
color?: string;
};
// 계산 겹: 열림 진행 값(0~1)을 easeInOutCubic() 으로 보간해 뒤 화면의 크기 값과
// 어둡기 값을 같은 비율로 계산한다 — scale·filter 를 각자 애니메이션하지 않고 한 값에서 둘을 도출한다.
function easeInOutCubic(p: number): number {
return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
}
const MIN_SCALE = 0.86;
const MAX_DIM = 0.5;
export default function CartDrawerBackdropDepthScale({
duration = 500,
color = "#17141a",
}: CartDrawerBackdropDepthScaleProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const pageRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLElement | null>(null);
const isOpenRef = useRef(false);
const rafRef = useRef<number | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
function apply(progress: number) {
const page = pageRef.current;
const panel = panelRef.current;
if (!page || !panel) return;
const eased = easeInOutCubic(Math.min(1, Math.max(0, progress)));
const scale = 1 - eased * (1 - MIN_SCALE);
const dim = eased * MAX_DIM;
page.style.transform = `scale(${scale.toFixed(4)})`;
page.style.filter = `brightness(${(1 - dim).toFixed(3)})`;
panel.style.transform = `translateX(${((1 - eased) * 100).toFixed(2)}%)`;
}
function run(closing: boolean) {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
const start = performance.now();
const from = closing ? 1 : 0;
const to = closing ? 0 : 1;
function frame(now: number) {
const t = Math.min(1, (now - start) / duration);
apply(from + (to - from) * t);
if (t < 1) {
rafRef.current = requestAnimationFrame(frame);
} else {
rafRef.current = null;
isOpenRef.current = !closing;
}
}
rafRef.current = requestAnimationFrame(frame);
}
function stopAutoplay() {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
const toggle = () => {
rootRef.current?.classList.remove(styles.isDemo);
stopAutoplay();
run(!!isOpenRef.current);
};
const toggleEl = rootRef.current?.querySelector<HTMLButtonElement>(`.${styles.toggle}`);
toggleEl?.addEventListener("click", toggle);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
apply(1);
} else {
run(false);
timerRef.current = setInterval(() => run(!!isOpenRef.current), 900);
}
return () => {
stopAutoplay();
if (rafRef.current) cancelAnimationFrame(rafRef.current);
toggleEl?.removeEventListener("click", toggle);
};
}, [duration]);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`} style={{ "--color": color } as CSSProperties}>
<div ref={pageRef} className={styles.page}>
<div className={styles.bar} />
<div className={styles.card} />
<div className={styles.card} />
<button className={styles.toggle} type="button">장바구니</button>
</div>
<aside ref={panelRef} className={styles.panel}>
<header className={styles.head}><span>장바구니</span><b>3</b></header>
<ul className={styles.list}>
<li>운동화</li>
<li>양말 세트</li>
<li>물병</li>
</ul>
</aside>
</div>
);
}02 · 배경 축소 — 패널이 열린 만큼 뒤 화면 크기 값이 줄어드는 깊이 표현 — 측정 누적면적 55.719% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03항목 순차 등장 — 목록 순서 값만큼 지연이 늘어나는 장바구니 줄
패널이 열린 상태가 되면 각 줄의 등장 시점이 목록 안 순서 값에 비례해 늦춰지는 지연 값을 받아 위에서부터 차례로 나타나고, 항목이 다시 정렬되면 그 지연 값도 새 순서에 맞게 다시 계산됩니다. 항목 여러 개가 담긴 장바구니, 위시리스트 패널에 쓰이는 형태입니다.
총 등장 시간을 항목 수로 나눠 지연 값을 구하는 computeDelay()
// 계산 겹: 총 등장 시간(TOTAL_SPREAD_MS)을 현재 항목 수로 나눠 항목마다 지연 값을 다시 구한다
// (computeDelay()) — 순서가 바뀌면 같은 규칙으로 각 자리의 지연 값이 새로 계산된다.
var BASE_MS = 40;
var TOTAL_SPREAD_MS = 480;
function computeDelay(indexInOrder, total) {
var steps = Math.max(1, total - 1);
var perStep = TOTAL_SPREAD_MS / steps;
return BASE_MS + indexInOrder * perStep;
}

<main class="stage">
<div class="cdis is-demo" id="cdis">
<header class="cdis__head"><span>장바구니</span>
<button class="cdis__shuffle" id="cdisShuffle" type="button">다시 담기</button>
</header>
<ul class="cdis__list" id="cdisList"></ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 총 등장 시간(TOTAL_SPREAD_MS)을 현재 항목 수로 나눠 항목마다 지연 값을 다시 구한다
// (computeDelay(), index.html) — 순서가 바뀌면 같은 규칙으로 각 자리의 지연 값이 새로 계산된다.
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-cream;
@include stage($stage-orange);
.stage { position: relative; }
.cdis {
width: min(230px, 68vw);
display: flex;
flex-direction: column;
gap: $sp-3;
}
.cdis__head {
display: flex;
align-items: center;
justify-content: space-between;
color: $subject-ink;
font-weight: 700;
font-size: 14px;
}
.cdis__shuffle {
border: 0;
border-radius: $r-pill;
padding: $sp-1 $sp-4;
background: $color;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 11px;
cursor: pointer;
}
.cdis__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.cdis__row {
padding: $sp-3;
border-radius: $r-control;
background: $color;
color: $subject-ink;
font-weight: 600;
font-size: 13px;
opacity: 0;
transform: translateY(14px);
animation-name: cdisRise;
animation-duration: $duration;
animation-timing-function: $easing;
animation-fill-mode: forwards;
animation-delay: var(--delay, 0ms);
}
@keyframes cdisRise {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 340px) {
.cdis { width: min(180px, 64vw); gap: $sp-1; }
.cdis__head { font-size: 12px; }
.cdis__list { gap: $sp-1; }
.cdis__row { font-size: 11px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.cdis__row { animation: none !important; opacity: 1; transform: 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);
}
.stage {
position: relative;
}
.cdis {
width: min(230px, 68vw);
display: flex;
flex-direction: column;
gap: 12px;
}
.cdis__head {
display: flex;
align-items: center;
justify-content: space-between;
color: #17141a;
font-weight: 700;
font-size: 14px;
}
.cdis__shuffle {
border: 0;
border-radius: 999px;
padding: 4px 16px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 11px;
cursor: pointer;
}
.cdis__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.cdis__row {
padding: 12px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-weight: 600;
font-size: 13px;
opacity: 0;
transform: translateY(14px);
animation-name: cdisRise;
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-fill-mode: forwards;
animation-delay: var(--delay, 0ms);
}
@keyframes cdisRise {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 340px) {
.cdis {
width: min(180px, 64vw);
gap: 4px;
}
.cdis__head {
font-size: 12px;
}
.cdis__list {
gap: 4px;
}
.cdis__row {
font-size: 11px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cdis__row {
animation: none !important;
opacity: 1;
transform: none;
}
}(function () {
var root = document.getElementById('cdis');
var list = document.getElementById('cdisList');
var shuffleBtn = document.getElementById('cdisShuffle');
var NAMES = ['패딩 조끼', '니트 장갑', '체크 목도리', '방한 부츠'];
// 계산 겹: 총 등장 시간(TOTAL_SPREAD_MS)을 현재 항목 수로 나눠 항목마다 지연 값을 다시 구한다
// (computeDelay()) — 순서가 바뀌면 같은 규칙으로 각 자리의 지연 값이 새로 계산된다.
var BASE_MS = 40;
var TOTAL_SPREAD_MS = 480;
function computeDelay(indexInOrder, total) {
var steps = Math.max(1, total - 1);
var perStep = TOTAL_SPREAD_MS / steps;
return BASE_MS + indexInOrder * perStep;
}
var order = NAMES.map(function (_, i) { return i; });
function render() {
list.textContent = '';
order.forEach(function (nameIndex, pos) {
var li = document.createElement('li');
li.className = 'cdis__row';
li.style.setProperty('--delay', computeDelay(pos, order.length) + 'ms');
li.textContent = NAMES[nameIndex];
list.appendChild(li);
});
}
function shuffle() {
for (var i = order.length - 1; i > 0; i -= 1) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = order[i]; order[i] = order[j]; order[j] = tmp;
}
render();
}
shuffleBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
shuffle();
});
var autoplayTimer = null;
function stopAutoplay() {
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
}
render();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
autoplayTimer = setInterval(shuffle, 1800);
}
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CartDrawerItemStaggerCascade.module.scss";
export type CartDrawerItemStaggerCascadeProps = {
duration?: number;
color?: string;
};
const NAMES = ["패딩 조끼", "니트 장갑", "체크 목도리", "방한 부츠"];
// 계산 겹: 총 등장 시간(TOTAL_SPREAD_MS)을 현재 항목 수로 나눠 항목마다 지연 값을 다시 구한다
// (computeDelay()) — 순서가 바뀌면 같은 규칙으로 각 자리의 지연 값이 새로 계산된다.
const BASE_MS = 40;
const TOTAL_SPREAD_MS = 480;
function computeDelay(indexInOrder: number, total: number): number {
const steps = Math.max(1, total - 1);
const perStep = TOTAL_SPREAD_MS / steps;
return BASE_MS + indexInOrder * perStep;
}
export default function CartDrawerItemStaggerCascade({
color = "#fff7e6",
}: CartDrawerItemStaggerCascadeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const [order, setOrder] = useState<number[]>(NAMES.map((_, i) => i));
const [tick, setTick] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
function shuffle() {
setOrder((prev) => {
const next = prev.slice();
for (let i = next.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = next[i];
next[i] = next[j];
next[j] = tmp;
}
return next;
});
setTick((t) => t + 1);
}
function stopAutoplay() {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
timerRef.current = setInterval(shuffle, 1800);
}
return stopAutoplay;
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`} style={{ "--color": color } as CSSProperties}>
<div className={styles.cdis}>
<header className={styles.head}>
<span>장바구니</span>
<button
className={styles.shuffle}
type="button"
onClick={() => {
rootRef.current?.classList.remove(styles.isDemo);
stopAutoplay();
shuffle();
}}
>
다시 담기
</button>
</header>
<ul className={styles.list} key={tick}>
{order.map((nameIndex, pos) => (
<li
key={nameIndex}
className={styles.row}
style={{ "--delay": `${computeDelay(pos, order.length)}ms` } as CSSProperties}
>
{NAMES[nameIndex]}
</li>
))}
</ul>
</div>
</div>
);
}03 · 항목 순차 등장 — 목록 순서 값만큼 지연이 늘어나는 장바구니 줄 — 측정 누적면적 29.915% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
04수량 탄성 범프 — 늘어난 값만큼 커졌다가 되돌아오는 수량 숫자
수량 값이 바뀐 상태가 되면 그 숫자의 크기 값이 변화 폭에 비례해 커졌다가 탄성 곡선을 따라 원래 크기로 되돌아오고, 값이 줄어든 상태에서는 반대 방향의 작은 크기 값을 거쳐 되돌아옵니다. 수량 증감 버튼이 있는 장바구니 줄에 맞는 형태입니다.
값이 바뀐 시각부터 지난 시간에 감쇠 사인파를 계산하는 elasticPulse()
// 계산 겹: 값이 바뀐 순간부터 지난 시간(t)에 감쇠 사인파(elasticPulse())를 매 프레임 계산해
// scale 에 대입한다 — CSS keyframes 의 고정 퍼센트 곡선이 아니라 값 변경 시각을 기준으로 다시 푼다.
var AMPLITUDE = 0.3;
var FREQ = 18;
var DECAY = 9;
function elasticPulse(tSec) {
if (tSec <= 0) { return 1; }
return 1 + AMPLITUDE * Math.sin(tSec * FREQ) * Math.exp(-DECAY * tSec);
}

<main class="stage">
<div class="cqbe is-demo" id="cqbe">
<div class="cqbe__row">
<span class="cqbe__name">캔버스 백</span>
<div class="cqbe__stepper">
<button class="cqbe__btn" id="cqbeMinus" type="button" aria-label="수량 줄이기">-</button>
<span class="cqbe__badge">
<span class="cqbe__ripple" id="cqbeRipple"></span>
<span class="cqbe__qty" id="cqbeQty">1</span>
</span>
<button class="cqbe__btn" id="cqbePlus" type="button" aria-label="수량 늘리기">+</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 값이 바뀐 시각부터 지난 시간에 감쇠 사인파(elasticPulse(), index.html)를 매 프레임
// 계산해 scale 에 대입한다 — 고정 keyframes 곡선이 아니라 변경 시각 기준으로 다시 푼다.
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue-deep;
@include stage($stage-paper);
.stage { position: relative; }
.cqbe {
width: min(220px, 68vw);
}
.cqbe__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-4;
border-radius: $r-card;
background: rgba(23, 20, 26, .06);
transform-origin: center;
will-change: transform;
}
.cqbe__name {
color: $subject-ink;
font-weight: 700;
font-size: 14px;
}
.cqbe__stepper {
display: flex;
align-items: center;
gap: $sp-3;
}
.cqbe__btn {
width: 28px;
height: 28px;
border: 0;
border-radius: $r-pill;
background: $color;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 16px;
line-height: 1;
display: grid;
place-items: center;
cursor: pointer;
}
.cqbe__badge {
position: relative;
width: 40px;
height: 40px;
display: grid;
place-items: center;
}
.cqbe__ripple {
position: absolute;
left: 50%;
top: 50%;
width: 40px;
height: 40px;
border-radius: $r-pill;
background: $color;
opacity: 0;
transform: translate(-50%, -50%) scale(.4);
transform-origin: center;
pointer-events: none;
}
.cqbe__qty {
position: relative;
min-width: 20px;
text-align: center;
color: $subject-ink;
font-weight: 700;
font-size: 18px;
display: inline-block;
transform-origin: center;
}
@media (max-width: 340px) {
.cqbe__row { padding: $sp-3; }
.cqbe__btn { width: 24px; height: 24px; font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.cqbe__qty { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.cqbe {
width: min(220px, 68vw);
}
.cqbe__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-radius: 16px;
background: rgba(23, 20, 26, 0.06);
transform-origin: center;
will-change: transform;
}
.cqbe__name {
color: #17141a;
font-weight: 700;
font-size: 14px;
}
.cqbe__stepper {
display: flex;
align-items: center;
gap: 12px;
}
.cqbe__btn {
width: 28px;
height: 28px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 16px;
line-height: 1;
display: grid;
place-items: center;
cursor: pointer;
}
.cqbe__badge {
position: relative;
width: 40px;
height: 40px;
display: grid;
place-items: center;
}
.cqbe__ripple {
position: absolute;
left: 50%;
top: 50%;
width: 40px;
height: 40px;
border-radius: 999px;
background: #0a4fe8;
opacity: 0;
transform: translate(-50%, -50%) scale(0.4);
transform-origin: center;
pointer-events: none;
}
.cqbe__qty {
position: relative;
min-width: 20px;
text-align: center;
color: #17141a;
font-weight: 700;
font-size: 18px;
display: inline-block;
transform-origin: center;
}
@media (max-width: 340px) {
.cqbe__row {
padding: 12px;
}
.cqbe__btn {
width: 24px;
height: 24px;
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.cqbe__qty {
transition: none !important;
}
}(function () {
var root = document.getElementById('cqbe');
var qtyEl = document.getElementById('cqbeQty');
var minusBtn = document.getElementById('cqbeMinus');
var plusBtn = document.getElementById('cqbePlus');
// 계산 겹: 값이 바뀐 순간부터 지난 시간(t)에 감쇠 사인파(elasticPulse())를 매 프레임 계산해
// scale 에 대입한다 — CSS keyframes 의 고정 퍼센트 곡선이 아니라 값 변경 시각을 기준으로 다시 푼다.
var AMPLITUDE = 0.3;
var FREQ = 18;
var DECAY = 9;
function elasticPulse(tSec) {
if (tSec <= 0) { return 1; }
return 1 + AMPLITUDE * Math.sin(tSec * FREQ) * Math.exp(-DECAY * tSec);
}
// 계산 겹 2: 범프와 별도로, 배지 둘레의 번짐(ripple)은 ease-out 곡선으로 반지름을 키우고
// 동시에 옅어지게 계산한다 — 하나의 진행값(p)에서 scale·opacity 둘을 같이 뽑는다(depth-scale 항목과 같은 패턴, 다른 대상).
function easeOutCubic(p) { return 1 - Math.pow(1 - p, 3); }
var rippleEl = document.getElementById('cqbeRipple');
function applyRipple(tSec) {
var p = Math.min(1, Math.max(0, tSec / 0.5));
var eased = easeOutCubic(p);
var s = 0.4 + eased * 2.1;
var op = (1 - eased) * 0.4;
rippleEl.style.transform = 'translate(-50%, -50%) scale(' + s.toFixed(3) + ')';
rippleEl.style.opacity = op.toFixed(3);
}
var qty = 1;
var pulseStart = null;
var raf = null;
var rowEl = root.querySelector('.cqbe__row');
function frame(now) {
var t = (now - pulseStart) / 1000;
var scale = elasticPulse(t);
rowEl.style.transform = 'scale(' + scale.toFixed(4) + ')';
qtyEl.style.transform = 'scale(' + scale.toFixed(4) + ')';
applyRipple(t);
if (t < 0.5) {
raf = requestAnimationFrame(frame);
} else {
rowEl.style.transform = 'scale(1)';
qtyEl.style.transform = 'scale(1)';
rippleEl.style.opacity = '0';
raf = null;
}
}
function bump() {
if (raf) { cancelAnimationFrame(raf); }
pulseStart = performance.now();
raf = requestAnimationFrame(frame);
}
function setQty(n) {
qty = Math.max(1, Math.min(9, n));
qtyEl.textContent = String(qty);
bump();
}
minusBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
setQty(qty - 1);
});
plusBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
setQty(qty + 1);
});
var autoplayTimer = null;
var dir = 1;
function stopAutoplay() {
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
}
function autoplayTick() {
if (qty >= 5) { dir = -1; }
if (qty <= 1) { dir = 1; }
setQty(qty + dir);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
autoplayTimer = setInterval(autoplayTick, 700);
}
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CartDrawerQtyBumpElastic.module.scss";
export type CartDrawerQtyBumpElasticProps = {
duration?: number;
color?: string;
};
// 계산 겹: 값이 바뀐 순간부터 지난 시간(t)에 감쇠 사인파(elasticPulse())를 매 프레임 계산해
// scale 에 대입한다 — CSS keyframes 의 고정 퍼센트 곡선이 아니라 값 변경 시각을 기준으로 다시 푼다.
const AMPLITUDE = 0.3;
const FREQ = 18;
const DECAY = 9;
function elasticPulse(tSec: number): number {
if (tSec <= 0) return 1;
return 1 + AMPLITUDE * Math.sin(tSec * FREQ) * Math.exp(-DECAY * tSec);
}
// 계산 겹 2: 범프와 별도로, 배지 둘레의 번짐(ripple)은 ease-out 곡선으로 반지름을 키우고
// 동시에 옅어지게 계산한다 — 하나의 진행값(p)에서 scale·opacity 둘을 같이 뽑는다.
function easeOutCubic(p: number): number {
return 1 - Math.pow(1 - p, 3);
}
export default function CartDrawerQtyBumpElastic({
color = "#0a4fe8",
}: CartDrawerQtyBumpElasticProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const rowRef = useRef<HTMLDivElement | null>(null);
const qtyRef = useRef<HTMLSpanElement | null>(null);
const rippleRef = useRef<HTMLSpanElement | null>(null);
const [qty, setQtyState] = useState(1);
const qtyValRef = useRef(1);
const rafRef = useRef<number | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
function applyRipple(tSec: number) {
const el = rippleRef.current;
if (!el) return;
const p = Math.min(1, Math.max(0, tSec / 0.5));
const eased = easeOutCubic(p);
const s = 0.4 + eased * 2.1;
const op = (1 - eased) * 0.4;
el.style.transform = `translate(-50%, -50%) scale(${s.toFixed(3)})`;
el.style.opacity = op.toFixed(3);
}
function bump() {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
const start = performance.now();
function frame(now: number) {
const t = (now - start) / 1000;
const scale = elasticPulse(t);
if (rowRef.current) rowRef.current.style.transform = `scale(${scale.toFixed(4)})`;
if (qtyRef.current) qtyRef.current.style.transform = `scale(${scale.toFixed(4)})`;
applyRipple(t);
if (t < 0.5) {
rafRef.current = requestAnimationFrame(frame);
} else {
if (rowRef.current) rowRef.current.style.transform = "scale(1)";
if (qtyRef.current) qtyRef.current.style.transform = "scale(1)";
if (rippleRef.current) rippleRef.current.style.opacity = "0";
rafRef.current = null;
}
}
rafRef.current = requestAnimationFrame(frame);
}
function setQty(n: number) {
qtyValRef.current = Math.max(1, Math.min(9, n));
setQtyState(qtyValRef.current);
bump();
}
function stopAutoplay() {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
const onMinus = () => {
rootRef.current?.classList.remove(styles.isDemo);
stopAutoplay();
setQty((qtyValRef.current ?? 1) - 1);
};
const onPlus = () => {
rootRef.current?.classList.remove(styles.isDemo);
stopAutoplay();
setQty((qtyValRef.current ?? 1) + 1);
};
const minusEl = rootRef.current?.querySelector<HTMLButtonElement>(`.${styles.minus}`);
const plusEl = rootRef.current?.querySelector<HTMLButtonElement>(`.${styles.plus}`);
minusEl?.addEventListener("click", onMinus);
plusEl?.addEventListener("click", onPlus);
let dir = 1;
function autoplayTick() {
const cur = qtyValRef.current ?? 1;
if (cur >= 5) dir = -1;
if (cur <= 1) dir = 1;
setQty(cur + dir);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
timerRef.current = setInterval(autoplayTick, 700);
}
return () => {
stopAutoplay();
if (rafRef.current) cancelAnimationFrame(rafRef.current);
minusEl?.removeEventListener("click", onMinus);
plusEl?.removeEventListener("click", onPlus);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`} style={{ "--color": color } as CSSProperties}>
<div ref={rowRef} className={styles.row}>
<span className={styles.name}>캔버스 백</span>
<div className={styles.stepper}>
<button className={styles.minus} type="button" aria-label="수량 줄이기">-</button>
<span className={styles.badge}>
<span ref={rippleRef} className={styles.ripple} />
<span ref={qtyRef} className={styles.qty}>{qty}</span>
</span>
<button className={styles.plus} type="button" aria-label="수량 늘리기">+</button>
</div>
</div>
</div>
);
}04 · 수량 탄성 범프 — 늘어난 값만큼 커졌다가 되돌아오는 수량 숫자 — 측정 누적면적 8.746% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05드래그 러버밴드 — 끄는 거리 값이 한계를 넘으면 저항이 커지는 닫기 동작
패널을 끄는 거리 값이 늘어날수록 따라오는 이동 값의 비율이 점점 줄어드는 저항 곡선을 따르다가, 그 거리 값이 기준을 넘은 상태가 되면 패널이 닫히는 위치까지 나머지 거리 값이 한 번에 줄어듭니다. 터치 스크린 장바구니 패널, 모바일 시트에 맞는 형태입니다.
임계값을 넘은 거리 값을 지수 감쇠로 압축하는 rubberband()
// 계산 겹: 드래그 거리가 임계값을 넘으면 저항 함수(rubberband())로 이동량을 압축한다 —
// 당길수록 화면에 반영되는 비율이 줄어드는 진짜 고무줄 공식(iOS 오버스크롤과 같은 계열),
// 단순 clamp 가 아니다.
var THRESHOLD = 90;
var MAX_PULL = 140;
function rubberband(dx) {
var sign = dx < 0 ? -1 : 1;
var abs = Math.abs(dx);
if (abs <= THRESHOLD) { return dx; }
var over = abs - THRESHOLD;
var compressed = MAX_PULL * (1 - Math.exp(-over / MAX_PULL));
return sign * (THRESHOLD + compressed);
}

<main class="stage">
<div class="cswr is-demo" id="cswr">
<ul class="cswr__list">
<li class="cswr__row" id="cswrRow">
<span class="cswr__trash" aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16ZM96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0Zm48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0Z"/></g></svg>
</span>
<span class="cswr__card" id="cswrCard">
<span class="cswr__name">런닝화</span>
<span class="cswr__price">$41</span>
</span>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 드래그 거리가 임계값(THRESHOLD)을 넘으면 저항 함수 rubberband() 가 지수 감쇠로
// 이동량을 압축한다(index.html) — 당길수록 화면 반영 비율이 줄어드는 고무줄 공식.
$duration: $dur-quick;
$easing: $ease-out;
$color: $error;
@include stage($stage-ink);
.stage { position: relative; overflow: hidden; }
.cswr {
width: min(330px, 84vw);
}
.cswr__list {
list-style: none;
margin: 0;
padding: 0;
}
.cswr__row {
position: relative;
border-radius: $r-control;
overflow: hidden;
--trash-opacity: 0;
}
.cswr__trash {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: $color;
color: $subject-cream;
opacity: var(--trash-opacity);
}
.cswr__card {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-5;
border-radius: $r-control;
background: $subject-cream;
touch-action: none;
cursor: grab;
will-change: transform;
}
.cswr__name {
color: $subject-ink;
font-weight: 700;
font-size: 14px;
}
.cswr__price {
color: $subject-ink;
font-weight: 600;
font-size: 13px;
}
@media (max-width: 340px) {
.cswr { width: min(220px, 70vw); }
.cswr__card { padding: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.cswr__card { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
overflow: hidden;
}
.cswr {
width: min(330px, 84vw);
}
.cswr__list {
list-style: none;
margin: 0;
padding: 0;
}
.cswr__row {
position: relative;
border-radius: 12px;
overflow: hidden;
--trash-opacity: 0;
}
.cswr__trash {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: #c2361a;
color: #fff7e6;
opacity: var(--trash-opacity);
}
.cswr__card {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
border-radius: 12px;
background: #fff7e6;
touch-action: none;
cursor: grab;
will-change: transform;
}
.cswr__name {
color: #17141a;
font-weight: 700;
font-size: 14px;
}
.cswr__price {
color: #17141a;
font-weight: 600;
font-size: 13px;
}
@media (max-width: 340px) {
.cswr {
width: min(220px, 70vw);
}
.cswr__card {
padding: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cswr__card {
transition: none !important;
}
}(function () {
var root = document.getElementById('cswr');
var card = document.getElementById('cswrCard');
var row = document.getElementById('cswrRow');
// 계산 겹: 드래그 거리가 임계값을 넘으면 저항 함수(rubberband())로 이동량을 압축한다 —
// 당길수록 화면에 반영되는 비율이 줄어드는 진짜 고무줄 공식(iOS 오버스크롤과 같은 계열),
// 단순 clamp 가 아니다.
var THRESHOLD = 90;
var MAX_PULL = 140;
function rubberband(dx) {
var sign = dx < 0 ? -1 : 1;
var abs = Math.abs(dx);
if (abs <= THRESHOLD) { return dx; }
var over = abs - THRESHOLD;
var compressed = MAX_PULL * (1 - Math.exp(-over / MAX_PULL));
return sign * (THRESHOLD + compressed);
}
var dragging = false;
var startX = 0;
var current = 0;
function apply(dx) {
var x = rubberband(dx);
card.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
var trashOpacity = Math.min(1, Math.abs(x) / THRESHOLD);
row.style.setProperty('--trash-opacity', trashOpacity.toFixed(3));
}
function onDown(clientX) {
dragging = true;
startX = clientX - current;
root.classList.remove('is-demo');
stopAutoplay();
}
function onMove(clientX) {
if (!dragging) { return; }
current = clientX - startX;
apply(current);
}
function onUp() {
if (!dragging) { return; }
dragging = false;
if (Math.abs(current) > THRESHOLD * 1.3) {
dismiss(current < 0 ? -1 : 1);
} else {
current = 0;
card.style.transition = 'transform ' + DUR + 'ms';
apply(0);
setTimeout(function () { card.style.transition = ''; }, DUR);
}
}
var DUR = 260;
function dismiss(dir) {
card.style.transition = 'transform 320ms ease-in, opacity 320ms ease-in';
card.style.transform = 'translateX(' + (dir * 420) + 'px)';
card.style.opacity = '0';
setTimeout(function () {
card.style.transition = '';
card.style.transform = 'translateX(0)';
card.style.opacity = '1';
current = 0;
row.style.setProperty('--trash-opacity', '0');
}, 420);
}
card.addEventListener('pointerdown', function (e) { onDown(e.clientX); });
window.addEventListener('pointermove', function (e) { onMove(e.clientX); });
window.addEventListener('pointerup', onUp);
var autoplayTimer = null;
function stopAutoplay() {
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
}
function autoStep() {
var t0 = performance.now();
var DUR_MS = 900;
function anim(now) {
var p = Math.min(1, (now - t0) / DUR_MS);
var dx = p < 0.7 ? (p / 0.7) * 270 : 270 * (1 - (p - 0.7) / 0.3);
apply(dx);
if (p < 1) { requestAnimationFrame(anim); } else { apply(0); }
}
requestAnimationFrame(anim);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
autoStep();
autoplayTimer = setInterval(autoStep, 1500);
}
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./CartDrawerSwipeDismissRubberband.module.scss";
export type CartDrawerSwipeDismissRubberbandProps = {
duration?: number;
color?: string;
};
// 계산 겹: 드래그 거리가 임계값을 넘으면 저항 함수(rubberband())로 이동량을 압축한다 —
// 당길수록 화면에 반영되는 비율이 줄어드는 진짜 고무줄 공식(iOS 오버스크롤과 같은 계열),
// 단순 clamp 가 아니다.
const THRESHOLD = 90;
const MAX_PULL = 140;
function rubberband(dx: number): number {
const sign = dx < 0 ? -1 : 1;
const abs = Math.abs(dx);
if (abs <= THRESHOLD) return dx;
const over = abs - THRESHOLD;
const compressed = MAX_PULL * (1 - Math.exp(-over / MAX_PULL));
return sign * (THRESHOLD + compressed);
}
const DUR = 260;
export default function CartDrawerSwipeDismissRubberband({
color = "#c2361a",
}: CartDrawerSwipeDismissRubberbandProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cardRef = useRef<HTMLSpanElement | null>(null);
const rowRef = useRef<HTMLLIElement | null>(null);
const draggingRef = useRef(false);
const startXRef = useRef(0);
const currentRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
function apply(dx: number) {
const card = cardRef.current;
const row = rowRef.current;
if (!card || !row) return;
const x = rubberband(dx);
card.style.transform = `translateX(${x.toFixed(2)}px)`;
const trashOpacity = Math.min(1, Math.abs(x) / THRESHOLD);
row.style.setProperty("--trash-opacity", trashOpacity.toFixed(3));
}
function stopAutoplay() {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
function dismiss(dir: number) {
const card = cardRef.current;
const row = rowRef.current;
if (!card || !row) return;
card.style.transition = "transform 320ms ease-in, opacity 320ms ease-in";
card.style.transform = `translateX(${dir * 420}px)`;
card.style.opacity = "0";
setTimeout(() => {
card.style.transition = "";
card.style.transform = "translateX(0)";
card.style.opacity = "1";
currentRef.current = 0;
row.style.setProperty("--trash-opacity", "0");
}, 420);
}
function onDown(clientX: number) {
draggingRef.current = true;
startXRef.current = clientX - (currentRef.current ?? 0);
rootRef.current?.classList.remove(styles.isDemo);
stopAutoplay();
}
function onMove(clientX: number) {
if (!draggingRef.current) return;
currentRef.current = clientX - (startXRef.current ?? 0);
apply(currentRef.current ?? 0);
}
function onUp() {
if (!draggingRef.current) return;
draggingRef.current = false;
const card = cardRef.current;
const cur = currentRef.current ?? 0;
if (Math.abs(cur) > THRESHOLD * 1.3) {
dismiss(cur < 0 ? -1 : 1);
} else {
currentRef.current = 0;
if (card) {
card.style.transition = `transform ${DUR}ms`;
apply(0);
setTimeout(() => {
card.style.transition = "";
}, DUR);
}
}
}
const onPointerDown = (e: PointerEvent) => onDown(e.clientX);
const onPointerMove = (e: PointerEvent) => onMove(e.clientX);
cardRef.current?.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onUp);
function autoStep() {
const t0 = performance.now();
const DUR_MS = 900;
function anim(now: number) {
const p = Math.min(1, (now - t0) / DUR_MS);
const dx = p < 0.7 ? (p / 0.7) * 270 : 270 * (1 - (p - 0.7) / 0.3);
apply(dx);
if (p < 1) {
requestAnimationFrame(anim);
} else {
apply(0);
}
}
requestAnimationFrame(anim);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
autoStep();
timerRef.current = setInterval(autoStep, 1500);
}
return () => {
stopAutoplay();
cardRef.current?.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onUp);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`} style={{ "--color": color } as CSSProperties}>
<ul className={styles.list}>
<li ref={rowRef} className={styles.row}>
<span className={styles.trash} aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16ZM96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0Zm48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0Z"/></g></svg>
</span>
<span ref={cardRef} className={styles.card}>
<span className={styles.name}>런닝화</span>
<span className={styles.price}>$41</span>
</span>
</li>
</ul>
</div>
);
}05 · 드래그 러버밴드 — 끄는 거리 값이 한계를 넘으면 저항이 커지는 닫기 동작 — 측정 누적면적 8.522% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06빈 상태 교차 — 항목 수 값이 0에서 바뀌는 순간 겹쳐 사라지는 안내문
항목 수 값이 0인 상태에서는 안내문의 짙기 값이 그대로 유지되다가, 그 값이 1 이상으로 바뀐 상태가 되면 안내문의 짙기 값은 줄고 목록의 짙기 값은 늘어나는 교차 값으로 서로 자리를 바꿉니다. 담긴 상품이 없는 장바구니 초기 화면에 쓰는 형태입니다.
하나의 진행 값으로 두 상태의 opacity 를 상보적으로 계산하는 crossfadeApply()
var DUR_MS = 420;
var hasItems = false;
var raf = null;
function crossfadeApply(p, toItems) {
var eased = easeInOutCubic(p);
var itemsOpacity = toItems ? eased : 1 - eased;
var emptyOpacity = 1 - itemsOpacity;
listEl.style.opacity = itemsOpacity.toFixed(3);
listEl.style.transform = 'scale(' + (0.96 + itemsOpacity * 0.04).toFixed(3) + ')';
emptyEl.style.opacity = emptyOpacity.toFixed(3);
emptyEl.style.transform = 'scale(' + (0.96 + emptyOpacity * 0.04).toFixed(3) + ')';
}

<main class="stage">
<div class="cesc is-demo" id="cesc">
<div class="cesc__panel">
<header class="cesc__head">장바구니</header>
<div class="cesc__body">
<div class="cesc__empty" id="cescEmpty">
<span class="cesc__icon" aria-hidden="true">
<svg viewBox="0 0 256 256" width="34" height="34"><g fill="currentColor" opacity=".6"><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16ZM96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Z"/></g></svg>
</span>
<p class="cesc__msg">장바구니가 비어 있어요</p>
</div>
<ul class="cesc__list" id="cescList">
<li class="cesc__row"><span>가죽 지갑</span><b>$36</b></li>
<li class="cesc__row"><span>선글라스</span><b>$29</b></li>
</ul>
</div>
<button class="cesc__toggle" id="cescToggle" type="button">담기 / 비우기</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 하나의 진행값(p)을 이징해 두 상태의 opacity·scale 을 상보적으로 계산한다
// (crossfadeApply(), index.html) — 빈 상태와 목록을 각각 애니메이션하지 않고 한 값에서 도출.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.stage { position: relative; }
.cesc__panel {
width: min(240px, 70vw);
display: flex;
flex-direction: column;
gap: $sp-3;
}
.cesc__head {
color: $color;
font-weight: 700;
font-size: 14px;
}
.cesc__body {
position: relative;
height: 130px;
}
.cesc__empty,
.cesc__list {
position: absolute;
inset: 0;
transform-origin: center;
}
.cesc__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-2;
color: $color;
text-align: center;
}
.cesc__msg {
margin: 0;
font-size: 12px;
font-weight: 600;
opacity: .72;
}
.cesc__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.cesc__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-3;
border-radius: $r-control;
background: $subject-cream;
color: $color;
font-weight: 600;
font-size: 13px;
}
.cesc__toggle {
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-5;
background: $color;
color: $stage-yellow;
font-family: inherit;
font-weight: 700;
font-size: 12px;
cursor: pointer;
align-self: flex-start;
}
@media (max-width: 340px) {
.cesc__panel { width: min(190px, 66vw); gap: $sp-2; }
.cesc__head { font-size: 12px; }
.cesc__body { height: 84px; }
.cesc__row { padding: $sp-2; font-size: 12px; }
.cesc__toggle { padding: $sp-1 $sp-4; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.cesc__empty, .cesc__list { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.cesc__panel {
width: min(240px, 70vw);
display: flex;
flex-direction: column;
gap: 12px;
}
.cesc__head {
color: #17141a;
font-weight: 700;
font-size: 14px;
}
.cesc__body {
position: relative;
height: 130px;
}
.cesc__empty,
.cesc__list {
position: absolute;
inset: 0;
transform-origin: center;
}
.cesc__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #17141a;
text-align: center;
}
.cesc__msg {
margin: 0;
font-size: 12px;
font-weight: 600;
opacity: 0.72;
}
.cesc__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.cesc__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-weight: 600;
font-size: 13px;
}
.cesc__toggle {
border: 0;
border-radius: 999px;
padding: 8px 20px;
background: #17141a;
color: #ffd23f;
font-family: inherit;
font-weight: 700;
font-size: 12px;
cursor: pointer;
align-self: flex-start;
}
@media (max-width: 340px) {
.cesc__panel {
width: min(190px, 66vw);
gap: 8px;
}
.cesc__head {
font-size: 12px;
}
.cesc__body {
height: 84px;
}
.cesc__row {
padding: 8px;
font-size: 12px;
}
.cesc__toggle {
padding: 4px 16px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.cesc__empty, .cesc__list {
transition: none !important;
}
}(function () {
var root = document.getElementById('cesc');
var emptyEl = document.getElementById('cescEmpty');
var listEl = document.getElementById('cescList');
var toggleBtn = document.getElementById('cescToggle');
// 계산 겹: 하나의 진행값(p, 0~1)을 이징해 두 상태의 opacity 를 서로 반대 방향으로 동시에
// 계산한다(crossfadeApply()) — 두 애니메이션을 따로 걸지 않고 한 값에서 상보적으로 도출한다.
function easeInOutCubic(p) {
return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
}
var DUR_MS = 420;
var hasItems = false;
var raf = null;
function crossfadeApply(p, toItems) {
var eased = easeInOutCubic(p);
var itemsOpacity = toItems ? eased : 1 - eased;
var emptyOpacity = 1 - itemsOpacity;
listEl.style.opacity = itemsOpacity.toFixed(3);
listEl.style.transform = 'scale(' + (0.96 + itemsOpacity * 0.04).toFixed(3) + ')';
emptyEl.style.opacity = emptyOpacity.toFixed(3);
emptyEl.style.transform = 'scale(' + (0.96 + emptyOpacity * 0.04).toFixed(3) + ')';
}
function run(toItems) {
if (raf) { cancelAnimationFrame(raf); }
var start = performance.now();
function frame(now) {
var p = Math.min(1, (now - start) / DUR_MS);
crossfadeApply(p, toItems);
if (p < 1) {
raf = requestAnimationFrame(frame);
} else {
raf = null;
hasItems = toItems;
}
}
raf = requestAnimationFrame(frame);
}
toggleBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
run(!hasItems);
});
var autoplayTimer = null;
function stopAutoplay() {
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
}
crossfadeApply(0, false);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
autoplayTimer = setInterval(function () { run(!hasItems); }, 1300);
}
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./CartDrawerEmptyStateCrossfade.module.scss";
export type CartDrawerEmptyStateCrossfadeProps = {
duration?: number;
color?: string;
};
// 계산 겹: 하나의 진행값(p, 0~1)을 이징해 두 상태의 opacity 를 서로 반대 방향으로 동시에
// 계산한다(crossfadeApply()) — 두 애니메이션을 따로 걸지 않고 한 값에서 상보적으로 도출한다.
function easeInOutCubic(p: number): number {
return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
}
const DUR_MS = 420;
export default function CartDrawerEmptyStateCrossfade({
color = "#17141a",
}: CartDrawerEmptyStateCrossfadeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const emptyRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<HTMLUListElement | null>(null);
const hasItemsRef = useRef(false);
const rafRef = useRef<number | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
function crossfadeApply(p: number, toItems: boolean) {
const list = listRef.current;
const empty = emptyRef.current;
if (!list || !empty) return;
const eased = easeInOutCubic(p);
const itemsOpacity = toItems ? eased : 1 - eased;
const emptyOpacity = 1 - itemsOpacity;
list.style.opacity = itemsOpacity.toFixed(3);
list.style.transform = `scale(${(0.96 + itemsOpacity * 0.04).toFixed(3)})`;
empty.style.opacity = emptyOpacity.toFixed(3);
empty.style.transform = `scale(${(0.96 + emptyOpacity * 0.04).toFixed(3)})`;
}
function run(toItems: boolean) {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
const start = performance.now();
function frame(now: number) {
const p = Math.min(1, (now - start) / DUR_MS);
crossfadeApply(p, toItems);
if (p < 1) {
rafRef.current = requestAnimationFrame(frame);
} else {
rafRef.current = null;
hasItemsRef.current = toItems;
}
}
rafRef.current = requestAnimationFrame(frame);
}
function stopAutoplay() {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
const onToggle = () => {
rootRef.current?.classList.remove(styles.isDemo);
stopAutoplay();
run(!hasItemsRef.current);
};
const toggleEl = rootRef.current?.querySelector<HTMLButtonElement>(`.${styles.toggle}`);
toggleEl?.addEventListener("click", onToggle);
crossfadeApply(0, false);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
timerRef.current = setInterval(() => run(!hasItemsRef.current), 1300);
}
return () => {
stopAutoplay();
if (rafRef.current) cancelAnimationFrame(rafRef.current);
toggleEl?.removeEventListener("click", onToggle);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`} style={{ "--color": color } as CSSProperties}>
<div className={styles.panel}>
<header className={styles.head}>장바구니</header>
<div className={styles.body}>
<div ref={emptyRef} className={styles.empty}>
<span className={styles.icon} aria-hidden="true">
<svg viewBox="0 0 256 256" width="34" height="34"><g fill="currentColor" opacity=".6"><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16ZM96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Z"/></g></svg>
</span>
<p className={styles.msg}>장바구니가 비어 있어요</p>
</div>
<ul ref={listRef} className={styles.list}>
<li className={styles.row}><span>가죽 지갑</span><b>$36</b></li>
<li className={styles.row}><span>선글라스</span><b>$29</b></li>
</ul>
</div>
<button className={styles.toggle} type="button">담기 / 비우기</button>
</div>
</div>
);
}06 · 빈 상태 교차 — 항목 수 값이 0에서 바뀌는 순간 겹쳐 사라지는 안내문 — 측정 누적면적 14.408% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07결제 바 높이 반전 — 스크롤 값에 따라 그림자와 높이 값이 바뀌는 하단 바
목록의 스크롤 위치 값이 바닥에 가까워진 상태가 되면 하단 바의 그림자 짙기 값과 높이 값이 함께 늘어나 앞으로 떠오르고, 스크롤 위치 값이 다시 위로 옮겨가면 두 값이 원래 크기로 줄어듭니다. 결제 버튼이 고정된 장바구니 하단에 맞는 형태입니다.
스크롤 진행률을 lerp 로 그림자·높이 값에 보간하는 tick()
// 계산 겹: 스크롤 진행률(0~1)을 매 프레임 lerp() 로 그림자 강도·bar 높이 값에 보간한다 —
// 스크롤 위치를 그대로 쓰지 않고 목표 값 쪽으로 매 프레임 조금씩 다가가는 관성 보간이다.
function lerp(a, b, t) { return a + (b - a) * t; }
var maxScroll = 1;
function computeMax() {
maxScroll = Math.max(1, scrollEl.scrollHeight - scrollEl.clientHeight);
}
var shadow = 0;
var raf = null;
function tick() {
var target = Math.min(1, scrollEl.scrollTop / maxScroll);
shadow = lerp(shadow, target, 0.18);
bar.style.setProperty('--shadow-op', shadow.toFixed(3));
bar.style.transform = 'translateY(' + (-shadow * 2).toFixed(2) + 'px)';
if (Math.abs(target - shadow) > 0.002) {
raf = requestAnimationFrame(tick);
} else {
raf = null;
}
}

<main class="stage">
<div class="cscr is-demo" id="cscr">
<div class="cscr__panel">
<div class="cscr__scroll" id="cscrScroll">
<ul class="cscr__list">
<li class="cscr__row"><span>텀블러</span><b>$16</b></li>
<li class="cscr__row"><span>노트</span><b>$8</b></li>
<li class="cscr__row"><span>파우치</span><b>$14</b></li>
<li class="cscr__row"><span>키링</span><b>$6</b></li>
<li class="cscr__row"><span>양초</span><b>$18</b></li>
</ul>
</div>
<footer class="cscr__bar" id="cscrBar">
<span>합계</span>
<b id="cscrTotal">$62</b>
</footer>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 스크롤 진행률을 목표값으로 두고 매 프레임 lerp() 로 그림자 세기 쪽에 관성 있게
// 다가간다(tick(), index.html) — 스크롤 위치를 그대로 쓰지 않고 보간된 값만 opacity 로 반영한다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-orange);
.stage { position: relative; }
.cscr__panel {
width: min(240px, 70vw);
height: 190px;
display: flex;
flex-direction: column;
border-radius: $r-card;
overflow: hidden;
background: $subject-cream;
}
.cscr__scroll {
flex: 1 1 auto;
overflow-y: auto;
padding: $sp-3;
}
.cscr__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.cscr__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-3;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
color: $color;
font-weight: 600;
font-size: 12px;
}
.cscr__bar {
position: relative;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-3 $sp-4;
background: $subject-cream;
color: $color;
font-weight: 700;
font-size: 14px;
--shadow-op: 0;
will-change: transform;
}
.cscr__bar::before {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 100%;
height: 14px;
box-shadow: 0 -10px 18px rgba(23, 20, 26, .4);
opacity: var(--shadow-op);
pointer-events: none;
}
@media (max-width: 340px) {
.cscr__panel { width: min(190px, 66vw); height: 160px; }
}
@media (prefers-reduced-motion: reduce) {
.cscr__bar { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.cscr__panel {
width: min(240px, 70vw);
height: 190px;
display: flex;
flex-direction: column;
border-radius: 16px;
overflow: hidden;
background: #fff7e6;
}
.cscr__scroll {
flex: 1 1 auto;
overflow-y: auto;
padding: 12px;
}
.cscr__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.cscr__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
color: #17141a;
font-weight: 600;
font-size: 12px;
}
.cscr__bar {
position: relative;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 14px;
--shadow-op: 0;
will-change: transform;
}
.cscr__bar::before {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 100%;
height: 14px;
box-shadow: 0 -10px 18px rgba(23, 20, 26, 0.4);
opacity: var(--shadow-op);
pointer-events: none;
}
@media (max-width: 340px) {
.cscr__panel {
width: min(190px, 66vw);
height: 160px;
}
}
@media (prefers-reduced-motion: reduce) {
.cscr__bar {
transition: none !important;
}
}(function () {
var root = document.getElementById('cscr');
var scrollEl = document.getElementById('cscrScroll');
var bar = document.getElementById('cscrBar');
// 계산 겹: 스크롤 진행률(0~1)을 매 프레임 lerp() 로 그림자 강도·bar 높이 값에 보간한다 —
// 스크롤 위치를 그대로 쓰지 않고 목표 값 쪽으로 매 프레임 조금씩 다가가는 관성 보간이다.
function lerp(a, b, t) { return a + (b - a) * t; }
var maxScroll = 1;
function computeMax() {
maxScroll = Math.max(1, scrollEl.scrollHeight - scrollEl.clientHeight);
}
var shadow = 0;
var raf = null;
function tick() {
var target = Math.min(1, scrollEl.scrollTop / maxScroll);
shadow = lerp(shadow, target, 0.18);
bar.style.setProperty('--shadow-op', shadow.toFixed(3));
bar.style.transform = 'translateY(' + (-shadow * 2).toFixed(2) + 'px)';
if (Math.abs(target - shadow) > 0.002) {
raf = requestAnimationFrame(tick);
} else {
raf = null;
}
}
function kick() {
if (!raf) { raf = requestAnimationFrame(tick); }
}
function stopOnRealInput() {
root.classList.remove('is-demo');
stopAutoplay();
}
scrollEl.addEventListener('wheel', stopOnRealInput, { passive: true });
scrollEl.addEventListener('pointerdown', stopOnRealInput);
scrollEl.addEventListener('scroll', kick);
var autoplayRaf = null;
function stopAutoplay() {
if (autoplayRaf) { cancelAnimationFrame(autoplayRaf); autoplayRaf = null; }
}
computeMax();
window.addEventListener('resize', computeMax);
var CYCLE_MS = 1600;
function autoLoop(now) {
var p = (now % CYCLE_MS) / CYCLE_MS;
var tri = p < 0.5 ? p * 2 : 2 - p * 2;
scrollEl.scrollTop = tri * maxScroll;
var target = Math.min(1, scrollEl.scrollTop / maxScroll);
shadow = lerp(shadow, target, 0.18);
bar.style.setProperty('--shadow-op', shadow.toFixed(3));
bar.style.transform = 'translateY(' + (-shadow * 2).toFixed(2) + 'px)';
autoplayRaf = requestAnimationFrame(autoLoop);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
autoplayRaf = requestAnimationFrame(autoLoop);
}
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./CartDrawerStickyCheckoutReveal.module.scss";
export type CartDrawerStickyCheckoutRevealProps = {
duration?: number;
color?: string;
};
// 계산 겹: 스크롤 진행률(0~1)을 매 프레임 lerp() 로 그림자 강도·bar 높이 값에 보간한다 —
// 스크롤 위치를 그대로 쓰지 않고 목표 값 쪽으로 매 프레임 조금씩 다가가는 관성 보간이다.
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
export default function CartDrawerStickyCheckoutReveal({
color = "#17141a",
}: CartDrawerStickyCheckoutRevealProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const barRef = useRef<HTMLElement | null>(null);
const shadowRef = useRef(0);
const maxScrollRef = useRef(1);
const rafRef = useRef<number | null>(null);
const autoplayRafRef = useRef<number | null>(null);
useEffect(() => {
const scrollEl = scrollRef.current;
const bar = barRef.current;
if (!scrollEl || !bar) return;
function computeMax() {
maxScrollRef.current = Math.max(1, (scrollEl as HTMLDivElement).scrollHeight - (scrollEl as HTMLDivElement).clientHeight);
}
function tick() {
const target = Math.min(1, scrollEl!.scrollTop / (maxScrollRef.current ?? 1));
const shadow = lerp(shadowRef.current ?? 0, target, 0.18);
shadowRef.current = shadow;
bar!.style.setProperty("--shadow-op", shadow.toFixed(3));
bar!.style.transform = `translateY(${(-shadow * 2).toFixed(2)}px)`;
if (Math.abs(target - shadow) > 0.002) {
rafRef.current = requestAnimationFrame(tick);
} else {
rafRef.current = null;
}
}
function kick() {
if (!rafRef.current) rafRef.current = requestAnimationFrame(tick);
}
function stopAutoplay() {
if (autoplayRafRef.current) {
cancelAnimationFrame(autoplayRafRef.current);
autoplayRafRef.current = null;
}
}
function stopOnRealInput() {
rootRef.current?.classList.remove(styles.isDemo);
stopAutoplay();
}
scrollEl.addEventListener("wheel", stopOnRealInput, { passive: true });
scrollEl.addEventListener("pointerdown", stopOnRealInput);
scrollEl.addEventListener("scroll", kick);
window.addEventListener("resize", computeMax);
computeMax();
const CYCLE_MS = 1600;
function autoLoop(now: number) {
const p = (now % CYCLE_MS) / CYCLE_MS;
const tri = p < 0.5 ? p * 2 : 2 - p * 2;
const maxScroll = maxScrollRef.current ?? 1;
scrollEl!.scrollTop = tri * maxScroll;
const target = Math.min(1, scrollEl!.scrollTop / maxScroll);
const shadow = lerp(shadowRef.current ?? 0, target, 0.18);
shadowRef.current = shadow;
bar!.style.setProperty("--shadow-op", shadow.toFixed(3));
bar!.style.transform = `translateY(${(-shadow * 2).toFixed(2)}px)`;
autoplayRafRef.current = requestAnimationFrame(autoLoop);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
autoplayRafRef.current = requestAnimationFrame(autoLoop);
}
return () => {
stopAutoplay();
if (rafRef.current) cancelAnimationFrame(rafRef.current);
scrollEl.removeEventListener("wheel", stopOnRealInput);
scrollEl.removeEventListener("pointerdown", stopOnRealInput);
scrollEl.removeEventListener("scroll", kick);
window.removeEventListener("resize", computeMax);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`} style={{ "--color": color } as CSSProperties}>
<div className={styles.panel}>
<div ref={scrollRef} className={styles.scroll}>
<ul className={styles.list}>
<li className={styles.row}><span>텀블러</span><b>$16</b></li>
<li className={styles.row}><span>노트</span><b>$8</b></li>
<li className={styles.row}><span>파우치</span><b>$14</b></li>
<li className={styles.row}><span>키링</span><b>$6</b></li>
<li className={styles.row}><span>양초</span><b>$18</b></li>
</ul>
</div>
<footer ref={barRef} className={styles.bar}>
<span>합계</span>
<b>$62</b>
</footer>
</div>
</div>
);
}07 · 결제 바 높이 반전 — 스크롤 값에 따라 그림자와 높이 값이 바뀌는 하단 바 — 측정 누적면적 8.047% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08삭제 높이 접힘 — 사라지는 줄의 높이 값이 0으로 줄며 아래 줄이 채워지는 자리
줄이 삭제된 상태가 되면 그 줄의 높이 값이 0까지 줄어드는 동안 아래 줄들의 위치 값도 같은 속도로 따라 올라와 빈자리를 채우고, 값이 0에 닿은 상태에서 그 줄은 목록에서 없어집니다. 상품을 지울 수 있는 장바구니 목록에 쓰는 형태입니다.
실측 높이 값에서 시작해 0까지 보간하는 collapseRow()
var DUR_MS = 320;
function easeInCubic(p) { return p * p * p; }
function collapseRow(row) {
var h = row.getBoundingClientRect().height;
row.style.overflow = 'hidden';
row.style.height = h + 'px';
row.style.willChange = 'height, opacity, transform';
var start = performance.now();
function frame(now) {
var p = Math.min(1, (now - start) / DUR_MS);
var eased = easeInCubic(p);
row.style.height = (h * (1 - eased)) + 'px';
row.style.opacity = String(1 - eased);
row.style.transform = 'translateX(' + (-eased * 24) + 'px)';
if (p < 1) {
requestAnimationFrame(frame);
} else {
row.remove();
refillIfEmpty();
}
}
requestAnimationFrame(frame);
}

<main class="stage">
<div class="cric is-demo" id="cric">
<ul class="cric__list" id="cricList">
<li class="cric__row" data-name="스니커즈"><span>스니커즈</span><b>$52</b>
<button class="cric__x" type="button" aria-label="삭제">×</button>
</li>
<li class="cric__row" data-name="양말"><span>양말</span><b>$9</b>
<button class="cric__x" type="button" aria-label="삭제">×</button>
</li>
<li class="cric__row" data-name="깔창"><span>깔창</span><b>$7</b>
<button class="cric__x" type="button" aria-label="삭제">×</button>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 삭제된 행의 실측 높이(px)에서 시작해 매 프레임 3제곱 이징으로 0 에 다가가는 값을
// 계산한다(collapseRow(), index.html) — CSS max-height 전환이 아니라 실측치 기반 보간이다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.cric {
width: min(240px, 70vw);
}
.cric__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.cric__row {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
padding: $sp-3;
border-radius: $r-control;
background: rgba(23, 20, 26, .06);
color: $subject-ink;
font-weight: 600;
font-size: 13px;
transform-origin: left center;
}
.cric__x {
border: 0;
border-radius: $r-pill;
width: 22px;
height: 22px;
flex: 0 0 auto;
background: $color;
color: $subject-cream;
font-family: inherit;
font-size: 13px;
line-height: 1;
display: grid;
place-items: center;
cursor: pointer;
}
@media (max-width: 340px) {
.cric { width: min(190px, 66vw); }
.cric__row { font-size: 12px; padding: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.cric__row { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cric {
width: min(240px, 70vw);
}
.cric__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.cric__row {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px;
border-radius: 12px;
background: rgba(23, 20, 26, 0.06);
color: #17141a;
font-weight: 600;
font-size: 13px;
transform-origin: left center;
}
.cric__x {
border: 0;
border-radius: 999px;
width: 22px;
height: 22px;
flex: 0 0 auto;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-size: 13px;
line-height: 1;
display: grid;
place-items: center;
cursor: pointer;
}
@media (max-width: 340px) {
.cric {
width: min(190px, 66vw);
}
.cric__row {
font-size: 12px;
padding: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cric__row {
transition: none !important;
}
}(function () {
var root = document.getElementById('cric');
var list = document.getElementById('cricList');
// 계산 겹: 삭제된 행의 실제 높이(px)를 시작값으로 두고 매 프레임 지수 감쇠로 0에 다가가게
// 계산한다(collapseTo0()) — max-height 전환 곡선이 아니라 실측 높이에서 시작하는 보간이다.
var DUR_MS = 320;
function easeInCubic(p) { return p * p * p; }
function collapseRow(row) {
var h = row.getBoundingClientRect().height;
row.style.overflow = 'hidden';
row.style.height = h + 'px';
row.style.willChange = 'height, opacity, transform';
var start = performance.now();
function frame(now) {
var p = Math.min(1, (now - start) / DUR_MS);
var eased = easeInCubic(p);
row.style.height = (h * (1 - eased)) + 'px';
row.style.opacity = String(1 - eased);
row.style.transform = 'translateX(' + (-eased * 24) + 'px)';
if (p < 1) {
requestAnimationFrame(frame);
} else {
row.remove();
refillIfEmpty();
}
}
requestAnimationFrame(frame);
}
var STOCK = [
['에코백', '$11'], ['머그컵', '$13'], ['카드지갑', '$22'],
['우산', '$17'], ['볼캡', '$19']
];
var stockIndex = 0;
function addRow() {
var item = STOCK[stockIndex % STOCK.length];
stockIndex += 1;
var li = document.createElement('li');
li.className = 'cric__row';
li.innerHTML = '<span>' + item[0] + '</span><b>' + item[1] + '</b><button class="cric__x" type="button" aria-label="삭제">×</button>';
list.appendChild(li);
bindRow(li);
}
function refillIfEmpty() {
if (list.children.length < 2) {
addRow();
}
}
function bindRow(row) {
var btn = row.querySelector('.cric__x');
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
collapseRow(row);
});
}
Array.prototype.forEach.call(list.querySelectorAll('.cric__row'), bindRow);
var autoplayTimer = null;
function stopAutoplay() {
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
}
function autoTick() {
var rows = list.querySelectorAll('.cric__row');
if (rows.length) {
collapseRow(rows[0]);
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
autoplayTimer = setInterval(autoTick, 1100);
}
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CartDrawerRemoveItemCollapse.module.scss";
export type CartDrawerRemoveItemCollapseProps = {
duration?: number;
color?: string;
};
type Row = { id: number; name: string; price: string };
const STOCK: [string, string][] = [
["에코백", "$11"], ["머그컵", "$13"], ["카드지갑", "$22"],
["우산", "$17"], ["볼캡", "$19"],
];
const DUR_MS = 320;
// 계산 겹: 삭제된 행의 실제 높이(px)를 시작값으로 두고 매 프레임 3제곱 이징으로 0 에 다가가게
// 계산한다(collapseRow()) — max-height 전환 곡선이 아니라 실측 높이에서 시작하는 보간이다.
function easeInCubic(p: number): number {
return p * p * p;
}
export default function CartDrawerRemoveItemCollapse({
color = "#0a4fe8",
}: CartDrawerRemoveItemCollapseProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<HTMLUListElement | null>(null);
const [rows, setRows] = useState<Row[]>([
{ id: 0, name: "스니커즈", price: "$52" },
{ id: 1, name: "양말", price: "$9" },
{ id: 2, name: "깔창", price: "$7" },
]);
const stockIndexRef = useRef(0);
const nextIdRef = useRef(3);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
function refillIfEmpty(count: number) {
if (count < 2) {
const item = STOCK[(stockIndexRef.current ?? 0) % STOCK.length];
stockIndexRef.current = (stockIndexRef.current ?? 0) + 1;
const id = nextIdRef.current ?? 3;
nextIdRef.current = (nextIdRef.current ?? 3) + 1;
setRows((prev) => [...prev, { id, name: item[0], price: item[1] }]);
}
}
function collapseRow(id: number) {
const li = listRef.current?.querySelector<HTMLLIElement>(`[data-id="${id}"]`);
if (!li) return;
const h = li.getBoundingClientRect().height;
li.style.overflow = "hidden";
li.style.height = `${h}px`;
li.style.willChange = "height, opacity, transform";
const start = performance.now();
function frame(now: number) {
const p = Math.min(1, (now - start) / DUR_MS);
const eased = easeInCubic(p);
li!.style.height = `${h * (1 - eased)}px`;
li!.style.opacity = String(1 - eased);
li!.style.transform = `translateX(${-eased * 24}px)`;
if (p < 1) {
requestAnimationFrame(frame);
} else {
setRows((prev) => {
const next = prev.filter((r) => r.id !== id);
refillIfEmpty(next.length);
return next;
});
}
}
requestAnimationFrame(frame);
}
function stopAutoplay() {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
useEffect(() => {
function autoTick() {
const first = listRef.current?.querySelector<HTMLLIElement>(`.${styles.row}`);
const id = first?.getAttribute("data-id");
if (id != null) collapseRow(Number(id));
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
timerRef.current = setInterval(autoTick, 1100);
}
return stopAutoplay;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`} style={{ "--color": color } as CSSProperties}>
<div className={styles.cric}>
<ul ref={listRef} className={styles.list}>
{rows.map((row) => (
<li key={row.id} data-id={row.id} className={styles.row}>
<span>{row.name}</span>
<b>{row.price}</b>
<button
className={styles.x}
type="button"
aria-label="삭제"
onClick={() => {
rootRef.current?.classList.remove(styles.isDemo);
stopAutoplay();
collapseRow(row.id);
}}
>
×
</button>
</li>
))}
</ul>
</div>
</div>
);
}08 · 삭제 높이 접힘 — 사라지는 줄의 높이 값이 0으로 줄며 아래 줄이 채워지는 자리 — 측정 누적면적 26.947% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09배지 수 관성 — 바뀐 값을 향해 서서히 따라붙는 아이콘 위 숫자
담긴 개수 값이 바뀐 상태가 되면 아이콘 위 숫자는 그 값을 향해 한 번에 닿지 않고 매 프레임 남은 차이 값의 일부만 따라붙는 관성 값을 거쳐 도착하고, 그 사이 숫자의 크기 값도 잠깐 커졌다 줄어듭니다. 장바구니 아이콘의 담긴 개수 표시에 쓰는 형태입니다.
목표 숫자와 표시 숫자의 차이를 매 프레임 줄이는 lerp() 기반 tick()
// 계산 겹: 표시 숫자(displayed)가 목표 숫자(target)를 매 프레임 향해 관성 lerp() 로
// 따라잡는다 — 값이 바뀔 때마다 다시 세지 않고, 두 값의 차이를 매 프레임 조금씩 줄인다.
function lerp(a, b, t) { return a + (b - a) * t; }
var target = 0;
var displayed = 0;
var raf = null;
function tick() {
displayed = lerp(displayed, target, 0.16);
var shown = Math.round(displayed);
badge.textContent = String(shown);
var pulse = 1 + Math.min(0.3, Math.abs(target - displayed) / (target + 1) * 0.6);
badge.style.transform = 'scale(' + pulse.toFixed(3) + ')';
if (Math.abs(target - displayed) > 0.03) {
raf = requestAnimationFrame(tick);
} else {
displayed = target;
badge.textContent = String(target);
badge.style.transform = 'scale(1)';
raf = null;
}
}

<main class="stage">
<div class="cbci is-demo" id="cbci">
<button class="cbci__fab" id="cbciAdd" type="button" aria-label="담기">
<svg viewBox="0 0 256 256" width="26" height="26" aria-hidden="true"><g fill="currentColor"><path d="M222.14 58.87A8 8 0 0 0 216 56H62.68L56.16 22.35A8 8 0 0 0 48 16H16a8 8 0 0 0 0 16h25.6l32 165.4A24 24 0 1 0 105.14 224a23.94 23.94 0 0 0 15.5-8h34.72a23.94 23.94 0 1 0 22.32-32h-51a8 8 0 0 1-7.86-6.43L114.68 160H188a24 24 0 0 0 23.53-19.24l16.29-75.63A8 8 0 0 0 222.14 58.87Z"/></g></svg>
<span class="cbci__badge" id="cbciBadge">0</span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 표시 숫자가 목표 숫자를 매 프레임 lerp() 로 따라잡는다(tick(), index.html) —
// 값이 바뀔 때마다 즉시 세지 않고 차이를 매 프레임 조금씩 줄이는 관성 계산이다.
$duration: $dur-quick;
$easing: $ease-pop;
$color: $error;
@include stage($stage-ink);
.stage { position: relative; }
.cbci__fab {
position: relative;
width: 64px;
height: 64px;
border: 0;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
display: grid;
place-items: center;
cursor: pointer;
box-shadow: $shadow-raised;
}
.cbci__badge {
position: absolute;
top: -6px;
right: -6px;
min-width: 22px;
height: 22px;
padding: 0 $sp-1;
border-radius: $r-pill;
background: $color;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
display: grid;
place-items: center;
transform-origin: center;
}
@media (max-width: 340px) {
.cbci__fab { width: 52px; height: 52px; }
.cbci__badge { min-width: 18px; height: 18px; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.cbci__badge { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.cbci__fab {
position: relative;
width: 64px;
height: 64px;
border: 0;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
display: grid;
place-items: center;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cbci__badge {
position: absolute;
top: -6px;
right: -6px;
min-width: 22px;
height: 22px;
padding: 0 4px;
border-radius: 999px;
background: #c2361a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
display: grid;
place-items: center;
transform-origin: center;
}
@media (max-width: 340px) {
.cbci__fab {
width: 52px;
height: 52px;
}
.cbci__badge {
min-width: 18px;
height: 18px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.cbci__badge {
transition: none !important;
}
}(function () {
var root = document.getElementById('cbci');
var addBtn = document.getElementById('cbciAdd');
var badge = document.getElementById('cbciBadge');
// 계산 겹: 표시 숫자(displayed)가 목표 숫자(target)를 매 프레임 향해 관성 lerp() 로
// 따라잡는다 — 값이 바뀔 때마다 다시 세지 않고, 두 값의 차이를 매 프레임 조금씩 줄인다.
function lerp(a, b, t) { return a + (b - a) * t; }
var target = 0;
var displayed = 0;
var raf = null;
function tick() {
displayed = lerp(displayed, target, 0.16);
var shown = Math.round(displayed);
badge.textContent = String(shown);
var pulse = 1 + Math.min(0.3, Math.abs(target - displayed) / (target + 1) * 0.6);
badge.style.transform = 'scale(' + pulse.toFixed(3) + ')';
if (Math.abs(target - displayed) > 0.03) {
raf = requestAnimationFrame(tick);
} else {
displayed = target;
badge.textContent = String(target);
badge.style.transform = 'scale(1)';
raf = null;
}
}
function kick() {
if (!raf) { raf = requestAnimationFrame(tick); }
}
function addOne() {
target += 1;
if (target > 9) { target = 0; displayed = 0; badge.textContent = '0'; }
kick();
}
addBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
addOne();
});
var autoplayTimer = null;
function stopAutoplay() {
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
autoplayTimer = setInterval(addOne, 380);
}
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./CartDrawerBadgeCountInertia.module.scss";
export type CartDrawerBadgeCountInertiaProps = {
duration?: number;
color?: string;
};
// 계산 겹: 목표 수량으로 즉시 점프하지 않고 매 프레임 lerp() 로 표시 숫자를 목표 쪽으로
// 끌어당긴다(관성) — 거리(distance)가 클수록 배지 pulse 스케일도 함께 커진다.
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
const INERTIA = 0.16;
const MAX_COUNT = 9;
const TICK_MS = 380;
export default function CartDrawerBadgeCountInertia({
color = "#c2361a",
}: CartDrawerBadgeCountInertiaProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const badgeRef = useRef<HTMLSpanElement | null>(null);
const fabRef = useRef<HTMLButtonElement | null>(null);
const targetRef = useRef(0);
const displayedRef = useRef(0);
const rafRef = useRef<number | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
const badge = badgeRef.current;
const fab = fabRef.current;
if (!badge || !fab) return;
function ensureLoop() {
if (rafRef.current) return;
const step = () => {
const target = targetRef.current ?? 0;
const dist = target - (displayedRef.current ?? 0);
displayedRef.current = lerp(displayedRef.current ?? 0, target, INERTIA);
const shown = Math.round(displayedRef.current ?? 0);
badge!.textContent = String(shown);
const pulse = 1 + Math.min(0.4, Math.abs(dist) * 0.05);
badge!.style.transform = `scale(${pulse.toFixed(3)})`;
if (Math.abs(dist) > 0.02) {
rafRef.current = requestAnimationFrame(step);
} else {
displayedRef.current = target;
badge!.textContent = String(target);
badge!.style.transform = "scale(1)";
rafRef.current = null;
}
};
rafRef.current = requestAnimationFrame(step);
}
function addOne() {
const t = targetRef.current ?? 0;
targetRef.current = t >= MAX_COUNT ? 0 : t + 1;
ensureLoop();
}
function stopAutoplay() {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
const onClick = () => {
rootRef.current?.classList.remove(styles.isDemo);
stopAutoplay();
addOne();
};
fab.addEventListener("click", onClick);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
timerRef.current = setInterval(addOne, TICK_MS);
}
return () => {
stopAutoplay();
if (rafRef.current) cancelAnimationFrame(rafRef.current);
fab.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`} style={{ "--color": color } as CSSProperties}>
<button ref={fabRef} type="button" className={styles.fab} aria-label="담기">
<svg viewBox="0 0 256 256" width="26" height="26" aria-hidden="true">
<g fill="currentColor">
<path opacity=".5" d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16ZM96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Z" />
</g>
</svg>
<span ref={badgeRef} className={styles.badge}>0</span>
</button>
</div>
);
}09 · 배지 수 관성 — 바뀐 값을 향해 서서히 따라붙는 아이콘 위 숫자 — 측정 누적면적 0.232% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
값이 패턴을 바꾸는 곳 — 오버슛 계수·저항 곡선·지연 값
오버슛 계수 — 감쇠비 값이 튕기는 정도를 정하는 곳
01 의 ZETA는 감쇠비 값이라 이 값이 작을수록 진동이 덜 죽어 패널이 목표를 넘나드는 흔들림이 오래 남고, 값이 클수록 한 번 넘었다가 빠르게 가라앉습니다. 패널 하나의 통통 튀는 인상은 이 계수 하나로 정해집니다.
저항 곡선 — 임계값을 넘은 뒤 압축 비율을 정하는 곳
05 의 THRESHOLD는 저항이 시작되는 거리 값이라, 이 값이 작으면 손이 조금만 움직여도 곧바로 압축 구간에 들어가고 값이 크면 손끝이 자유롭게 움직이는 구간이 늘어납니다. MAX_PULL은 그 압축이 최종적으로 얼마나 멀리까지 늘어날 수 있는지를 정하는 값입니다.
지연 값 — 총 시간을 항목 수로 나누는 곳
03 의 TOTAL_SPREAD_MS는 첫 줄부터 마지막 줄까지 걸리는 총 시간 값이라, 항목 수가 늘어나면 computeDelay()가 한 줄 사이의 간격을 자동으로 좁혀 전체 등장 시간을 지킵니다. BASE_MS는 항목이 하나뿐이어도 최소한으로 남는 지연 값입니다.
어디서 깨지는가 — 빠른 드래그·수량 폭증·목록이 비었을 때
빠른 드래그 — 값이 채 자리 잡기 전에 다시 들어오는 지점
01 은 run(closing)이 호출될 때마다 springProgress(tSec)가 t를 0부터 다시 재는 함수라, 애니메이션이 끝나기 전에 토글을 빠르게 두 번 누르면 isOpen이 아직 갱신되지 않은 상태라 같은 방향으로 다시 시작되어 패널 위치 값이 순간적으로 닫힌 자리 쪽으로 튀었다가 다시 열리는 것처럼 보일 수 있습니다. 05 는 카드가 dismiss()로 밀려나는 320ms 전환 도중에 다시 잡으면 current가 그 전환이 끝나야 0으로 재설정되므로, 전환 중간에 새로 드래그를 시작하면 시작 지점 계산에 옛 current 값이 섞여 카드가 손 위치와 다른 곳에서 시작할 수 있습니다.
수량 폭증 — 값이 순서를 건너뛰는 지점
04 는 + 버튼을 연속으로 누르면 bump()가 매번 raf를 취소하고 pulseStart를 지금 시각으로 다시 설정하는데, 이전 펄스가 아직 원래 크기로 돌아오지 못한 상태에서 다시 시작되면 크기 값이 부드럽게 이어지지 않고 눈에 띄게 한 번 꺾일 수 있습니다. 09 는 target이 9를 넘는 상태가 되면 lerp()를 거치지 않고 target = 0; displayed = 0;으로 즉시 되돌아가므로, 다른 값들과 달리 이 지점에서만 관성 없이 숫자가 뚝 끊겨 보입니다.
목록이 비었을 때 — 계산의 재료 자체가 없는 지점
06 은 hasItems 값이 참·거짓 둘뿐이라 항목이 0개에서 1개로 늘 때와 1개에서 2개로 늘 때를 구분하지 못하고 같은 교차 계산을 반복합니다. 08 은 refillIfEmpty()가 list.children.length < 2가 되는 순간 STOCK 배열을 순환시켜 새 줄을 곧바로 채워 넣기 때문에 이 데모 자체는 실제로 목록이 완전히 비는 상태를 보여주지 않는데, gnavhgub 안 원본에서 실제 재고 데이터를 연결한다면 refillIfEmpty() 호출을 지우고 0개 상태를 위한 안내문을 따로 두어야 목록이 진짜로 빈 상태를 보여줄 수 있습니다.
변형 세 가지와 접근성
같은 화면이라도 상수 값 하나만 바꾸면 장바구니 디자인 인상이 완전히 달라지는데, 아래 세 개는 그 값과 느낌을 실측한 표입니다.
| 변형 | 바꾸는 값 | 바뀌는 느낌 |
|---|---|---|
| 스프링 감쇠비 | 01 ZETA 0.42 → 0.22 |
ZETA를 0.42 로 두면 패널이 목표를 살짝 지나쳤다가 한 번 크게 튕기고 가라앉는데, 0.22 로 낮추면 감쇠가 약해져 목표를 넘나드는 진동이 두세 번 더 눈에 띄게 이어져 스프링 특유의 통통 튀는 느낌이 강해집니다 |
| 러버밴드 저항 임계값 | 05 THRESHOLD 90px → 150px |
THRESHOLD를 90px 로 두면 손이 조금만 끌어도 패널이 바로 닫히는 위치까지 넘어가 버려 저항 구간을 느낄 새가 없는데, 150px 로 올리면 저항이 커지는 구간이 길어져 손끝에 걸리는 감각과 놓았을 때 되돌아오는 탄력이 뚜렷해집니다 |
| 삭제 접힘 시간 | 08 DUR_MS 320ms → 620ms |
DUR_MS를 320ms 로 두면 지운 줄이 빠르게 접혀 아래 줄이 순간적으로 튀어 올라오는 것처럼 보이는데, 620ms 로 늘리면 높이가 줄어드는 동안 아래 줄들이 같은 속도로 따라 올라오는 과정이 눈으로 좇을 수 있을 만큼 천천히 드러납니다 |
접근성
아홉 개 모두 prefers-reduced-motion: reduce가 참인 상태가 되면 자동 재생 루프(setInterval·requestAnimationFrame 반복)를 걸지 않고, 그중 01·02 는 한 걸음 더 나아가 애니메이션을 건너뛰고 열린 최종 상태(apply(1))로 곧바로 나타납니다. 이 값이 켜진 상태에서도 클릭·드래그·스크롤 같은 실제 입력 리스너는 그대로 등록돼 있어 조작 자체는 막히지 않습니다. 닫기 버튼(01)·삭제 버튼(08)·수량 증감 버튼(04)·담기 버튼(09)은 전부 aria-label을 갖고 있어 스크린 리더가 아이콘만으로도 무슨 동작인지 읽어 줍니다. 05 는 삭제가 버튼이 아니라 드래그 제스처라 aria-label 대상 자체가 없고, 카드 안 쓰레기통 아이콘도 aria-hidden으로 스크린 리더에서 숨겨져 있습니다. 표준 API 동작은 Pointer Events 문서와 reduced-motion 문서에 정리돼 있습니다.
FAQ
장바구니 UI 드로어와 가운데 뜨는 팝업 장바구니는 어떻게 다른가요?
드로어는 화면 옆에서 패널이 밀려 들어오고 나머지 화면은 그대로 보이는 형태이고, 01·02 처럼 위치 값이나 배경 크기 값을 계산해 옆에서 겹쳐지듯 자리를 잡습니다. 가운데 팝업은 화면 전체를 가리며 나타나는 형태라 계산하는 값 자체가 다르고, 이 편은 옆에서 밀려 들어오는 드로어 9종만 다룹니다.
장바구니 담기 UI 애니메이션 중 하나만 먼저 적용한다면 무엇부터 골라야 하나요?
담기 버튼을 눌렀을 때 반응이 가장 먼저 눈에 띄어야 한다면 09 배지 수 관성이나 04 수량 탄성 범프처럼 계산이 가벼운 것부터 붙이는 편이 안전합니다. 01 슬라이드 오버슛이나 05 드래그 러버밴드는 패널 전체 위치 값을 계산하므로 화면 구조가 먼저 잡힌 뒤에 얹는 편이 수정이 적습니다.
아홉 개 중 실제 결제나 재고 서버와 통신하는 게 있나요?
없습니다. 아홉 개 전부 클릭·드래그·스크롤에 반응해 로컬 상태 값만 바꾸는 시뮬레이션이고 fetch나 실제 결제 요청을 보내지 않습니다. 실제 쇼핑몰에 붙이려면 각 계산 함수가 만드는 값(위치·거리·개수)을 그대로 두고 그 값을 만드는 이벤트 자리에 실제 API 응답 처리 코드를 넣으면 됩니다.
더 읽을거리
이 아홉 개는 장바구니 패널이 열리고 닫히는 값 자체를 계산하는 데 집중한다는 점에서, 아래 이웃 편들과는 값이 붙는 자리가 다릅니다.
- CSS 사이드바 드로어 슬라이드 9종 — JS 최소로 — 049 는 드로어가 열리는 트윈 곡선 자체를 CSS 로 다루는 데 비해, 이 편은 그 패널 안에서 장바구니 상태 값(수량·삭제·빈 상태)이 바뀌는 순간의 계산을 다룹니다
- 장바구니 UI 9종 — 복붙하면 되는 수량·쿠폰·결제 코드 — 278 은 결제 화면 자체의 구성(쿠폰 입력·합계 갱신)을 다루는 데 비해, 이 편은 그 앞 단계인 드로어가 여닫히는 위치·거리 값의 계산을 다룹니다
- 필터 UI 9종 — 패널 슬라이드부터 칩 제거까지 — 346 은 필터 패널이 열리는 값과 칩이 제거되는 값을 다루는 데 비해, 이 편은 같은 패널 구조를 장바구니 상태 값에 맞춰 다시 계산한 편입니다









