カート ドロワー UIパターン9選 — スプリングからバッジ慣性まで
9つのうち1つは開く位置の値が目標を少し超えてからばね曲線に沿って戻り、1つは引く距離の値が基準を超えると抵抗が強まり、もう1つは個数の値が変わるたびにアイコン上の数字が毎フレーム残りの差の一部だけ追いつきます。この9つがカート ドロワーの動きです。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 スライドオーバーシュート — 開いた位置の値が目標を少し超えてから戻るカートパネル
- 02 背景縮小 — パネルが開いた分だけ背後の画面の大きさの値が縮む奥行き表現
- 03 項目段階表示 — リスト内の順番の値だけ遅延が伸びるカートの行
- 04 数量の弾性バンプ — 変化量の値だけ大きくなってから戻る数量の数字
- 05 ラバーバンドドラッグ — 引く距離の値が限界を超えると抵抗が強まる閉じ方
- 06 空状態のクロスフェード — 項目数の値が0から変わる瞬間に入れ替わる案内文
- 07 決済バーの持ち上げ — スクロール位置の値に応じて影と高さの値が変わる下部バー
- 08 削除の高さ折りたたみ — 消える行の高さの値が0になり下の行が詰める余白
- 09 バッジ数の慣性 — 変わった値へ少しずつ追いつくアイコン上の数字
9つのデモはすべてこのページの中だけで完結します。決済ボタンを押してもfetchも外部の決済APIも呼ばれず、商品名と価格はすべて架空のもので実在するブランドとは一切関係がありません。それでも9つとも訪問者の実際の操作に反応します——01と02はカートを開閉するボタンのクリック(ポインター操作)に反応し、03は行を並び替える操作、04は数量を増減するボタンのクリック、05はパネルをつかんで動かすポインターのドラッグ、06はカートへの追加・削除で個数の値が0をまたいだ瞬間、07はカート内リストのスクロール、08は削除ボタンのクリック、09は数量が変わった瞬間の個数の値に反応します。
9つのパターンの解剖 — 位置・距離・個数の値がいつ変わるか
9つは、位置・距離・個数のどの値をどう計算し直すかで3つに分かれます。この違いを飛ばすと、カート デザインの実装は開閉するだけのパネルで終わってしまいます。
決まった長さのイージング区間 — 02・06・08
02・06・08は状態が切り替わった瞬間にだけ計算が始まり、決まった時間(DURATION_SやDUR_MS)の中でイージング関数の進行値ひとつから高さ・不透明度・拡大率などの値を導きます。値は0から1まで一度だけ進み、次の状態切り替えが起きるまで計算は動きません。
経過時間や距離から直接解く減衰・抵抗の式 — 01・04・05
01・04・05は進行値を使わず、経過時間や現在のドラッグ距離をそのまま式に入れて値を求めます。01は減衰調和振動子の方程式(springProgress)を毎フレーム解いて位置の値を作り、目標を一度超えてから戻ります。04は値が変わった瞬間からの経過時間を減衰サイン波(elasticPulse)に入れて計算し、05はドラッグ距離そのものを指数関数の抵抗式(rubberband)に通します。
動き続ける目標を追いかける値 — 03・07・09
03・07・09には完了という状態がなく、動き続ける目標値を毎フレーム追いかけ続けます。03は並び順が変わるたびに位置の値から遅延を求め直し(computeDelay)、07はスクロール位置を目標にlerpで影と高さの値を追いかけ、09は個数の目標値をlerpで追いかけながらバッジの数字を更新します。
パターン別コード
01スライドオーバーシュート — 開いた位置の値が目標を少し超えてから戻るカートパネル
パネルが開いた状態になると、位置の値が目標地点を少し超えてから戻るばね曲線に沿って収まり、閉じた状態になると同じ曲線を逆方向にたどって値が減っていきます。

<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>コットンTシャツ</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレーム中19フレームで動き、累積面積100.00%・平均強度165.3を記録しています。ZETAを0.42、OMEGAを9.5とした減衰調和振動子の式をspringProgressが毎フレーム解き、この位置の値を一度だけ大きく超えさせてから収めています。
// 計算層: 減衰調和振動子(ばね)の方程式を毎フレーム直接解いて位置の値を作る — イージング曲線を
// 借りるのではなく、減衰比(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);
}
02背景縮小 — パネルが開いた分だけ背後の画面の大きさの値が縮む奥行き表現
パネルの開いた進行度の値が大きくなるほど、背後の画面の大きさの値と暗さの値が同じ割合で縮んでパネルが手前に浮いたように見え、閉じる進行度ではその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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレーム中15フレームで動き、累積面積55.72%・平均強度133.1を記録しています。MIN_SCALE(0.86)とMAX_DIM(0.5)が縮小と暗さの上限を決め、easeInOutCubicで求めたひとつの進行値からその両方を導いています。
// 計算層: パネルの開き進行値(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) + '%)';
}
03項目段階表示 — リスト内の順番の値だけ遅延が伸びるカートの行
パネルが開いた状態になると、各行の登場時点がリスト内の順番の値に比例して遅れる遅延の値を受け取り上から順に現れ、項目が並び替えられるとその遅延の値も新しい順番に合わせて求め直されます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレーム中10フレームで動き、累積面積29.92%・平均強度96.6を記録しています。computeDelayがTOTAL_SPREAD_MS(480ms)を項目数で割ったperStepに順番の値を掛けて各行の遅延を求め直すため、並び替え後も同じ規則で段階表示が崩れません。
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);
});
}
04数量の弾性バンプ — 変化量の値だけ大きくなってから戻る数量の数字
数量の値が変わった状態になると、その数字の大きさの値が変化量に比例して大きくなってから弾性曲線に沿って元の大きさに戻り、値が減った状態では逆方向に少し小さい値を経て戻ります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレーム中16フレームで動き、累積面積8.75%・平均強度45.1を記録しています。elasticPulseが値変更からの経過時間をFREQ18・DECAY9の減衰サイン波に入れて数字の大きさを計算し、別関数のapplyRippleが同じ変更時刻を基準に周囲のにじみの半径と濃さをeaseOutCubicで導いています。
// 計算層: 値が変わった瞬間からの経過時間(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);
}
05ラバーバンドドラッグ — 引く距離の値が限界を超えると抵抗が強まる閉じ方
パネルを引く距離の値が増えるほど、追従する移動の値の比率が徐々に減る抵抗曲線に従い、その距離の値が基準を超えた状態になるとパネルが閉じる位置まで残りの距離の値が一度に縮みます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレーム中16フレームで動き、累積面積8.52%・平均強度161.6を記録しています。THRESHOLD(90px)を超えた分だけrubberbandが指数関数で圧縮し、指を離した時点の距離の値がTHRESHOLDの1.3倍を超えていればdismissがそのまま420pxの位置まで320msでパネルを閉じ切ります。
// 計算層: ドラッグ距離が閾値を超えると抵抗関数(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));
}
06空状態のクロスフェード — 項目数の値が0から変わる瞬間に入れ替わる案内文
項目数の値が0の状態では案内文の濃さの値がそのまま保たれ、その値が1以上に変わった状態になると案内文の濃さの値は下がり、リストの濃さの値は上がる交差する値で入れ替わります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレーム中8フレームで動き、累積面積14.41%・平均強度74.6を記録しています。crossfadeApplyがひとつの進行値pからitemsOpacityとその逆数のemptyOpacityを同時に導き、DUR_MS(420ms)の間だけeaseInOutCubicで計算し直しています。
// 計算層: 一つの進行値(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) + ')';
}
07決済バーの持ち上げ — スクロール位置の値に応じて影と高さの値が変わる下部バー
リストのスクロール位置の値が下端に近づいた状態になると、下部バーの影の濃さの値と高さの値がともに増えて手前に持ち上がり、スクロール位置の値が再び上に移ると両方の値が元の大きさに戻ります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレーム中23フレームで動き、累積面積8.05%・平均強度110.1を記録しています。tickがscrollTopをmaxScrollで割った目標値へlerpで0.18ずつ毎フレーム近づけるため、スクロールが止まった後も影と高さの値がわずかに遅れて追いつきます。
// 計算層: スクロール進行率(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;
}
}
08削除の高さ折りたたみ — 消える行の高さの値が0になり下の行が詰める余白
行が削除された状態になると、その行の高さの値が0になるまでの間、下の行の位置の値も同じ速さで詰めて空いた場所を埋め、値が0に達した状態でその行はリストから消えます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレーム中8フレームで動き、累積面積26.95%・平均強度47.6を記録しています。collapseRowがeaseInCubicの進行値から高さ・不透明度・横方向のずれの3つの値を同時に計算し、行が0になった直後にrefillIfEmptyがカートの行数を数えて2行未満なら補充します。
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);
}
09バッジ数の慣性 — 変わった値へ少しずつ追いつくアイコン上の数字
入っている個数の値が変わった状態になると、アイコン上の数字は一度に値へ届かず、毎フレーム残りの差の値の一部だけ追いつく慣性の値を経て到達し、その間数字の大きさの値も一瞬大きくなってから縮みます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレーム中22フレームで動き、累積面積0.23%・平均強度70.4を記録しています。tickがdisplayedをtargetへlerpで0.16ずつ毎フレーム近づけ、二つの値の差が0.03を切った時点でdisplayedをtargetに合わせて止まります。
// 計算層: 表示中の数字(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;
}
}
値がパターンを変える場所 — オーバーシュート係数・抵抗曲線・遅延の値
01のZETAが小さいほどオーバーシュートが目立つ理由
ZETAは減衰比の値で、1に近いほど振動を素早く抑え、0に近いほど目標を超えてから戻るまでの揺れが長く残ります。0.42という値は一度だけはっきり超えてから収まる程度に選ばれていて、揺れの回数そのものをspringProgressが式で決めています。
05のTHRESHOLDが抵抗の始まる位置の値を決める理由
THRESHOLD(90px)までは指の動きがそのままパネルの位置の値になり、そこを超えた分だけrubberbandの指数関数が効き始めて値の増え方が緩やかになります。THRESHOLDを大きくすると素直に動く区間が伸び、抵抗を感じ始める位置の値そのものが後ろにずれます。
03の遅延がリスト内の位置の値から直接求まる理由
computeDelayは絶対時間を決め打ちにせず、その行の順番の値(indexInOrder)と現在の項目数(total)からperStepを割り出して遅延を計算します。項目数が変われば同じ順番でもperStepの値が変わるため、行を増減しても段階表示全体の長さの値はTOTAL_SPREAD_MSのまま保たれます。
どこで壊れるか — 高速ドラッグ・数量急増・空のカート
この9つとcss ドロワー メニュー スライド9選 — JS最小・CSSだけ・決済画面 デザイン 9選 — カートから支払いまでコピペOK・絞り込み ui9選 — パネルスライドからチップ削除までとの違いは、どの値を計算し直しているかです。ドロワーメニューの回は開閉の進行値をイージング曲線だけで求め、スプリングの超過やドラッグの抵抗式は扱いません。決済画面の回は入力欄の検証状態の値を求め直すことが中心で、パネルの位置そのものは計算しません。絞り込みUIの回はチップの数の値からレイアウトの折り返しを求め直しますが、個数の値がバッジの数字を追いかける慣性計算は登場しません。この9つは位置・距離・個数という三つの値を、スプリングの方程式・ラバーバンドの抵抗式・lerpの慣性で求め直しています。
素早いドラッグと合わせて起きる05の閉じ判定
05のonUp()は指を離した時点の距離の値だけを見て閉じるかどうかを決めます。Math.abs(current) > THRESHOLD * 1.3が真になった時だけdismissが呼ばれて420pxの位置まで320msでパネルが閉じ切り、指を動かした速さの値そのものは判定に入っていません。ゆっくり長く引いても、勢いよく短く弾いても、離れた瞬間の距離の値が同じなら結果は同じです。gnavhgubを開くと、この9つの計算部分がすべてそのままコピーして使える形で入っています。
個数が9を超えた瞬間に起きる09のリセット
09の増加処理にはif (target > 9) { target = 0; displayed = 0; badge.textContent = '0'; }という分岐があり、個数の目標値が9を超えた状態になると増え続けるのではなく0へ戻ります。表示の数字も同じ行で0にそろえられるため、lerpによるなめらかな追いつきは働かず、バッジの数字は9の次に0へ飛びます。
08の詰め直しと06の空状態は同じ0にたどり着かない
08のrefillIfEmpty()は行の数が2未満になった状態を検知すると新しい行を追加し、リストの要素数が0になる手前で補充してしまいます。06のcrossfadeApplyが案内文へ切り替わるのは項目数の値が実際に0になった状態だけなので、08の画面だけを見ている限りその案内文が現れる瞬間には出会いません。空の状態を試すなら06のデモで項目を全部取り除く必要があります。
3つのバリエーションとアクセシビリティ
| 変更点 | 値 | 感触の違い |
|---|---|---|
| スプリング減衰比 | 01 ZETA 0.42 → 0.22 | ZETAが0.42だと目標を少し超えてから一度大きく跳ねて収まりますが、0.22まで下げると減衰が弱まり目標をまたぐ振動があと2〜3回はっきり続いてばね特有の弾む感触が強くなります |
| ラバーバンド抵抗の閾値 | 05 THRESHOLD 90px → 150px | THRESHOLDが90pxだと少し引いただけで閉じる位置まで値が飛んでしまい抵抗を感じる間がありませんが、150pxまで上げると抵抗が強まる区間が伸びて指先に返ってくる感触とリリース後に戻る弾力がはっきりします |
| 削除の折りたたみ時間 | 08 DUR_MS 320ms → 620ms | DUR_MSが320msだと消えた行の高さがすぐ縮んで下の行が一瞬で詰めるように見えますが、620msまで伸ばすと高さが縮む間に下の行が同じ速さで追いついてくる過程を目で追えるくらいゆっくり見えます |
このzipには0.42・90px・320msの値が入っています。
操作を減らす設定と読み上げ利用者にも同じ計算値が届く理由
9つすべてがprefers-reduced-motionをJavaScriptのmatchMediaとCSSのメディアクエリの両方で確認しており、この設定を有効にした利用者には同じ位置・距離・個数の値がアニメーションなしで最終状態のまま届きます。装飾のためだけのアイコン——01の閉じるアイコン、05のごみ箱アイコン、06の案内アイコン、09のバッジアイコン——にはaria-hiddenが付いており、読み上げソフトはそれらを読み上げの対象から外します。一方で実際に操作するボタン——01の閉じるボタン、04の数量増減ボタン、09の追加ボタン、08の削除ボタン——にはaria-labelが付き、ラベルのないアイコンだけのボタンにはなっていません。
FAQ
カート ドロワー と はどんな計算を指しますか
位置・距離・個数という3つの値を、スプリングの方程式・ラバーバンドの抵抗式・lerpの慣性といった計算式でその都度求め直す9つのパターンのことです。ボタンを押した瞬間に固定のCSSアニメーションを1回再生するだけの実装とは異なり、値は毎フレーム式に入れ直されます。
この9つのうちどれかは本物の決済や在庫のAPIと通信していますか
どれも通信していません。決済ボタンはfetchも外部の決済APIも呼ばず、商品名・価格・在庫の数字はすべて架空のもので、実在するブランドとは関係がありません。
これはカート ui デザインの例に過ぎず、そのまま製品には使えませんか
例として眺めるだけでなく、そのまま使うことを前提に作られています。上に載せた9つの計算部分はどれも25行以内で外部ライブラリを使わず、差し替えが必要なのは商品データを取ってくる層とチェックアウトの送信先だけです。
Sources: MDN — easing-function · MDN — Pointer events · MDN — Window.requestAnimationFrame · MDN — ARIA dialog role · MDN — prefers-reduced-motion









