01スライドオーバーシュート — 開いた位置の値が目標を少し超えてから戻るカートパネル
パネルが開いた状態になると、位置の値が目標地点を少し超えてから戻るばね曲線に沿って収まり、閉じた状態になると同じ曲線を逆方向にたどって値が減っていきます。
使いどころオンラインストアのカートパネル、サイドカート
ZETAOMEGAspringProgress

<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 に同梱 · カート ドロワー UIパターン9選 — スプリングからバッジ慣性まで
このデモが属する記事
カート ドロワー UIパターン9選 — スプリングからバッジ慣性まで9つのうち1つは開く位置の値が目標を少し超えてからばね曲線に沿って戻り、1つは引く距離の値が基準を超えると抵抗が強まり、もう1つは個数の値が変わるたびにアイコン上の数字が毎フレーム残りの差の一部だけ追いつきます。この9つがカート ドロワーの この記事のデモをすべて見る


