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


