GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

장바구니 담기 UI 9종 — 스프링부터 배지 관성까지

장바구니 담기 UI는 상품을 화면 옆 패널로 열어 바로 보여주는 장치인데, 아홉 개 중 하나는 여는 위치 값이 목표를 살짝 지나쳤다가 스프링 곡선을 따라 되돌아오고, 하나는 끄는 거리 값이 기준을 넘은 상태에서 저항이 커지며, 또 하나는 담긴 개수 값이 바뀔 때마다 아이콘 위 숫자가

아홉 화면 모두 실제 결제·재고 서버와 통신하지 않는 상태 전환 시뮬레이션이고, 상품·가격은 전부 가상입니다. 리스너는 01~04·06·08·09 가 클릭, 05 가 pointerdown·pointermove·pointerup, 07 이 wheel·pointerdown·scroll·resize 인데, 자동으로만 재생되고 마는 항목은 아홉 중 하나도 없이 전부 이 실제 입력이 들어오는 순간 같은 함수가 다시 값을 계산합니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

쇼핑몰 장바구니 UI 를 처음 만들 때는 담기 버튼 하나만 붙이고 끝내기 쉬운데, 아래 표는 아홉 패턴이 화면을 실제로 얼마나 흔들었는지 잰 값이고, 목표를 향해 물리적으로 수렴하는 값(01·04·09) → 경계에 닿는 순간 상태가 바뀌는 값(05·06·08) → 하나의 진행 값에서 여러 속성이 함께 파생되는 값(02·03·07) 순으로 묶었습니다.

아홉 개의 해부 — 위치·거리·개수 값이 언제 바뀌는가

실측 한눈에

항목 초점 누적면적 % 강도 평균 움직인 프레임
01 슬라이드 오버슛 열림 위치 값 → 스프링 재계산 100.00 165.3 19/23
02 배경 축소 진행 값 → 크기·어둡기 재계산 55.72 133.1 15/23
03 항목 순차 등장 순서 값 → 지연 값 재계산 29.92 96.6 10/23
04 수량 탄성 범프 변화 폭 값 → 크기 값 재계산 8.75 45.1 16/23
05 드래그 러버밴드 거리 값 → 저항 곡선 재계산 8.52 161.6 16/23
06 빈 상태 교차 개수 0 경계 → 짙기 값 재계산 14.41 74.6 8/23
07 결제 바 반응 스크롤 위치 값 → 그림자·높이 재계산 8.05 110.1 23/23
08 삭제 높이 접힘 실측 높이 값 → 0까지 보간 26.95 47.6 8/23
09 배지 수 관성 개수 값 → 관성 lerp 재계산 0.23 70.4 22/23

01 은 패널과 배경이 함께 움직여 누적면적이 100.00%로 9종 중 가장 크고, 09 는 아이콘 위 숫자 하나만 바뀌어 0.23%로 가장 작습니다 — 둘 다 계산 대상 자체의 크기 차이일 뿐, 09 가 덜 계산된 게 아닙니다. 강도 평균은 05 가 161.6 으로 가장 높아 빨간 쓰레기통 배경과 카드 사이의 픽셀 값 차이가 뚜렷하다는 뜻이고, 04 는 45.1 로 가장 낮아 옅은 카드 위 범프의 인접 색 차이가 완만하다는 뜻입니다. 움직인 프레임은 07 이 23/23 으로 유일하게 매 프레임 값을 흘리는 구조라는 뜻이고, 06·08 은 8/23 으로 가장 낮아 각각 420ms·320ms 안에 계산이 끝난 뒤 나머지 캡처 구간은 정지 상태이기 때문입니다.

목표를 향해 물리적으로 수렴하는 값 — 01·04·09

이 셋은 목표 값에 한 번에 닿지 않고 매 프레임 다시 계산해 물리적으로 수렴합니다. 01 은 springProgress()가 감쇠비(ZETA)·고유진동수(OMEGA)로 감쇠 조화진동자 방정식을 풀어 패널 위치 값이 목표를 넘었다가(오버슛) 되돌아오게 하고, 04 는 elasticPulse()가 감쇠 사인파로 수량 숫자의 크기 값을 계산해 커졌다가 원래 크기로 잦아들게 합니다. 09 는 lerp()가 표시 숫자(displayed)와 목표 숫자(target)의 차이를 매 프레임 16%씩 줄이는 관성 값을 만들어, 담긴 개수 값이 한 번에 뛰지 않고 서서히 따라붙게 합니다.

경계에 닿는 순간 상태가 바뀌는 값 — 05·06·08

이 셋은 값이 특정 경계에 닿는 순간 상태 자체가 바뀝니다. 05 는 rubberband()가 드래그 거리 값이 THRESHOLD(90px)를 넘으면 저항을 걸다가, 그 값이 THRESHOLD의 1.3배를 넘은 상태가 되면 dismiss()가 카드를 한 번에 밀어냅니다. 06 은 crossfadeApply()가 항목 수 값이 0인지 아닌지(hasItems)만으로 안내문과 목록의 짙기 값을 상보적으로 계산하고, 08 은 collapseRow()가 삭제된 줄의 실측 높이 값을 0까지 줄인 뒤 그 값이 0에 닿은 프레임에서 row.remove()로 줄 자체를 없앱니다.

하나의 진행 값에서 여러 속성이 함께 파생되는 값 — 02·03·07

이 셋은 값 하나를 계산해 두 가지 이상의 화면 속성에 동시에 반영합니다. 02 는 apply(progress)가 이징한 값 하나로 뒤 화면의 scale과 filter(밝기)를 함께 계산하고, 03 은 computeDelay(indexInOrder, total)가 총 등장 시간(TOTAL_SPREAD_MS)을 항목 수로 나눠 각 줄의 지연 값을 순서마다 다시 구합니다. 07 은 tick()이 스크롤 진행률을 lerp()로 보간한 값 하나로 그림자 짙기(--shadow-op)와 바의 세로 위치(translateY)를 함께 계산합니다.

패턴별 코드

01슬라이드 오버슛 — 열림 위치 값이 목표를 넘었다가 되돌아오는 장바구니 패널

패널이 열린 상태가 되면 위치 값이 목표 지점을 살짝 지나쳤다가 되돌아오는 스프링 곡선을 따라 자리를 잡고, 닫힌 상태가 되면 같은 곡선의 반대 방향으로 위치 값이 줄어듭니다. 온라인 쇼핑몰 장바구니 패널, 사이드 카트에 붙이기 좋은 형태입니다.

감쇠비와 고유진동수로 오버슛 위치 값을 계산하는 함수

  // 계산 겹: 감쇠 조화진동자(스프링) 방정식을 매 프레임 직접 풀어 위치 값을 만든다 — easing 곡선을
  // 빌려 쓰는 게 아니라 감쇠비(ZETA)·고유진동수(OMEGA) 로 목표를 넘었다가(오버슛) 되돌아오는 값을 계산한다.
  var ZETA = 0.42;
  var OMEGA = 9.5;
  var OMEGA_D = OMEGA * Math.sqrt(1 - ZETA * ZETA);
  function springProgress(tSec) {
    if (tSec <= 0) { return 0; }
    var env = Math.exp(-ZETA * OMEGA * tSec);
    return 1 - env * (Math.cos(OMEGA_D * tSec) + (ZETA * OMEGA / OMEGA_D) * Math.sin(OMEGA_D * tSec));
  }
슬라이드 오버슛 — 열림 위치 값이 목표를 넘었다가 되돌아오는 장바구니 패널
새 창에서 열기

01 · 슬라이드 오버슛 — 열림 위치 값이 목표를 넘었다가 되돌아오는 장바구니 패널 — 측정 누적면적 100.0% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ZETAOMEGAspringProgress

02배경 축소 — 패널이 열린 만큼 뒤 화면 크기 값이 줄어드는 깊이 표현

패널의 열림 진행 값이 커질수록 뒤에 있는 화면의 크기 값과 어둡기 값이 함께 줄어들어 패널이 화면 앞으로 떠오른 것처럼 보이고, 닫히는 진행 값에서는 두 값이 같은 비율로 되돌아갑니다. 모바일 쇼핑 앱 장바구니, 커머스 사이트에 맞는 형태입니다.

하나의 진행 값에서 scale 과 filter 를 함께 계산하는 apply()

  // 계산 겹: 패널의 열림 진행 값(progress, 0~1)을 이징 함수로 보간해 뒤 화면의 크기 값과
  // 어둡기 값을 같은 비율로 계산한다 — scale·filter 를 각자 애니메이션하지 않고 한 값에서 둘을 도출한다.
  function easeInOutCubic(p) {
    return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
  }

  var DURATION_S = 0.5;
  var MIN_SCALE = 0.86;
  var MAX_DIM = 0.5;
  var isOpen = false;
  var raf = null;

  function apply(progress) {
    var eased = easeInOutCubic(Math.min(1, Math.max(0, progress)));
    var scale = 1 - eased * (1 - MIN_SCALE);
    var dim = eased * MAX_DIM;
    page.style.transform = 'scale(' + scale.toFixed(4) + ')';
    page.style.filter = 'brightness(' + (1 - dim).toFixed(3) + ')';
    panel.style.transform = 'translateX(' + ((1 - eased) * 100).toFixed(2) + '%)';
  }
배경 축소 — 패널이 열린 만큼 뒤 화면 크기 값이 줄어드는 깊이 표현
새 창에서 열기

02 · 배경 축소 — 패널이 열린 만큼 뒤 화면 크기 값이 줄어드는 깊이 표현 — 측정 누적면적 55.719% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MIN_SCALEMAX_DIMeaseInOutCubic

03항목 순차 등장 — 목록 순서 값만큼 지연이 늘어나는 장바구니 줄

패널이 열린 상태가 되면 각 줄의 등장 시점이 목록 안 순서 값에 비례해 늦춰지는 지연 값을 받아 위에서부터 차례로 나타나고, 항목이 다시 정렬되면 그 지연 값도 새 순서에 맞게 다시 계산됩니다. 항목 여러 개가 담긴 장바구니, 위시리스트 패널에 쓰이는 형태입니다.

총 등장 시간을 항목 수로 나눠 지연 값을 구하는 computeDelay()

  // 계산 겹: 총 등장 시간(TOTAL_SPREAD_MS)을 현재 항목 수로 나눠 항목마다 지연 값을 다시 구한다
  // (computeDelay()) — 순서가 바뀌면 같은 규칙으로 각 자리의 지연 값이 새로 계산된다.
  var BASE_MS = 40;
  var TOTAL_SPREAD_MS = 480;

  function computeDelay(indexInOrder, total) {
    var steps = Math.max(1, total - 1);
    var perStep = TOTAL_SPREAD_MS / steps;
    return BASE_MS + indexInOrder * perStep;
  }
항목 순차 등장 — 목록 순서 값만큼 지연이 늘어나는 장바구니 줄
새 창에서 열기

03 · 항목 순차 등장 — 목록 순서 값만큼 지연이 늘어나는 장바구니 줄 — 측정 누적면적 29.915% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

computeDelayTOTAL_SPREAD_MSBASE_MS

04수량 탄성 범프 — 늘어난 값만큼 커졌다가 되돌아오는 수량 숫자

수량 값이 바뀐 상태가 되면 그 숫자의 크기 값이 변화 폭에 비례해 커졌다가 탄성 곡선을 따라 원래 크기로 되돌아오고, 값이 줄어든 상태에서는 반대 방향의 작은 크기 값을 거쳐 되돌아옵니다. 수량 증감 버튼이 있는 장바구니 줄에 맞는 형태입니다.

값이 바뀐 시각부터 지난 시간에 감쇠 사인파를 계산하는 elasticPulse()

  // 계산 겹: 값이 바뀐 순간부터 지난 시간(t)에 감쇠 사인파(elasticPulse())를 매 프레임 계산해
  // scale 에 대입한다 — CSS keyframes 의 고정 퍼센트 곡선이 아니라 값 변경 시각을 기준으로 다시 푼다.
  var AMPLITUDE = 0.3;
  var FREQ = 18;
  var DECAY = 9;
  function elasticPulse(tSec) {
    if (tSec <= 0) { return 1; }
    return 1 + AMPLITUDE * Math.sin(tSec * FREQ) * Math.exp(-DECAY * tSec);
  }
수량 탄성 범프 — 늘어난 값만큼 커졌다가 되돌아오는 수량 숫자
새 창에서 열기

04 · 수량 탄성 범프 — 늘어난 값만큼 커졌다가 되돌아오는 수량 숫자 — 측정 누적면적 8.746% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

elasticPulseAMPLITUDEapplyRipple

05드래그 러버밴드 — 끄는 거리 값이 한계를 넘으면 저항이 커지는 닫기 동작

패널을 끄는 거리 값이 늘어날수록 따라오는 이동 값의 비율이 점점 줄어드는 저항 곡선을 따르다가, 그 거리 값이 기준을 넘은 상태가 되면 패널이 닫히는 위치까지 나머지 거리 값이 한 번에 줄어듭니다. 터치 스크린 장바구니 패널, 모바일 시트에 맞는 형태입니다.

임계값을 넘은 거리 값을 지수 감쇠로 압축하는 rubberband()

  // 계산 겹: 드래그 거리가 임계값을 넘으면 저항 함수(rubberband())로 이동량을 압축한다 —
  // 당길수록 화면에 반영되는 비율이 줄어드는 진짜 고무줄 공식(iOS 오버스크롤과 같은 계열),
  // 단순 clamp 가 아니다.
  var THRESHOLD = 90;
  var MAX_PULL = 140;
  function rubberband(dx) {
    var sign = dx < 0 ? -1 : 1;
    var abs = Math.abs(dx);
    if (abs <= THRESHOLD) { return dx; }
    var over = abs - THRESHOLD;
    var compressed = MAX_PULL * (1 - Math.exp(-over / MAX_PULL));
    return sign * (THRESHOLD + compressed);
  }
드래그 러버밴드 — 끄는 거리 값이 한계를 넘으면 저항이 커지는 닫기 동작
새 창에서 열기

05 · 드래그 러버밴드 — 끄는 거리 값이 한계를 넘으면 저항이 커지는 닫기 동작 — 측정 누적면적 8.522% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

rubberbandTHRESHOLDMAX_PULL

06빈 상태 교차 — 항목 수 값이 0에서 바뀌는 순간 겹쳐 사라지는 안내문

항목 수 값이 0인 상태에서는 안내문의 짙기 값이 그대로 유지되다가, 그 값이 1 이상으로 바뀐 상태가 되면 안내문의 짙기 값은 줄고 목록의 짙기 값은 늘어나는 교차 값으로 서로 자리를 바꿉니다. 담긴 상품이 없는 장바구니 초기 화면에 쓰는 형태입니다.

하나의 진행 값으로 두 상태의 opacity 를 상보적으로 계산하는 crossfadeApply()

  var DUR_MS = 420;
  var hasItems = false;
  var raf = null;

  function crossfadeApply(p, toItems) {
    var eased = easeInOutCubic(p);
    var itemsOpacity = toItems ? eased : 1 - eased;
    var emptyOpacity = 1 - itemsOpacity;
    listEl.style.opacity = itemsOpacity.toFixed(3);
    listEl.style.transform = 'scale(' + (0.96 + itemsOpacity * 0.04).toFixed(3) + ')';
    emptyEl.style.opacity = emptyOpacity.toFixed(3);
    emptyEl.style.transform = 'scale(' + (0.96 + emptyOpacity * 0.04).toFixed(3) + ')';
  }
빈 상태 교차 — 항목 수 값이 0에서 바뀌는 순간 겹쳐 사라지는 안내문
새 창에서 열기

06 · 빈 상태 교차 — 항목 수 값이 0에서 바뀌는 순간 겹쳐 사라지는 안내문 — 측정 누적면적 14.408% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

crossfadeApplyDUR_MSframe

07결제 바 높이 반전 — 스크롤 값에 따라 그림자와 높이 값이 바뀌는 하단 바

목록의 스크롤 위치 값이 바닥에 가까워진 상태가 되면 하단 바의 그림자 짙기 값과 높이 값이 함께 늘어나 앞으로 떠오르고, 스크롤 위치 값이 다시 위로 옮겨가면 두 값이 원래 크기로 줄어듭니다. 결제 버튼이 고정된 장바구니 하단에 맞는 형태입니다.

스크롤 진행률을 lerp 로 그림자·높이 값에 보간하는 tick()

  // 계산 겹: 스크롤 진행률(0~1)을 매 프레임 lerp() 로 그림자 강도·bar 높이 값에 보간한다 —
  // 스크롤 위치를 그대로 쓰지 않고 목표 값 쪽으로 매 프레임 조금씩 다가가는 관성 보간이다.
  function lerp(a, b, t) { return a + (b - a) * t; }

  var maxScroll = 1;
  function computeMax() {
    maxScroll = Math.max(1, scrollEl.scrollHeight - scrollEl.clientHeight);
  }

  var shadow = 0;
  var raf = null;
  function tick() {
    var target = Math.min(1, scrollEl.scrollTop / maxScroll);
    shadow = lerp(shadow, target, 0.18);
    bar.style.setProperty('--shadow-op', shadow.toFixed(3));
    bar.style.transform = 'translateY(' + (-shadow * 2).toFixed(2) + 'px)';
    if (Math.abs(target - shadow) > 0.002) {
      raf = requestAnimationFrame(tick);
    } else {
      raf = null;
    }
  }
결제 바 높이 반전 — 스크롤 값에 따라 그림자와 높이 값이 바뀌는 하단 바
새 창에서 열기

07 · 결제 바 높이 반전 — 스크롤 값에 따라 그림자와 높이 값이 바뀌는 하단 바 — 측정 누적면적 8.047% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

lerpCYCLE_MScomputeMax

08삭제 높이 접힘 — 사라지는 줄의 높이 값이 0으로 줄며 아래 줄이 채워지는 자리

줄이 삭제된 상태가 되면 그 줄의 높이 값이 0까지 줄어드는 동안 아래 줄들의 위치 값도 같은 속도로 따라 올라와 빈자리를 채우고, 값이 0에 닿은 상태에서 그 줄은 목록에서 없어집니다. 상품을 지울 수 있는 장바구니 목록에 쓰는 형태입니다.

실측 높이 값에서 시작해 0까지 보간하는 collapseRow()

  var DUR_MS = 320;
  function easeInCubic(p) { return p * p * p; }

  function collapseRow(row) {
    var h = row.getBoundingClientRect().height;
    row.style.overflow = 'hidden';
    row.style.height = h + 'px';
    row.style.willChange = 'height, opacity, transform';
    var start = performance.now();
    function frame(now) {
      var p = Math.min(1, (now - start) / DUR_MS);
      var eased = easeInCubic(p);
      row.style.height = (h * (1 - eased)) + 'px';
      row.style.opacity = String(1 - eased);
      row.style.transform = 'translateX(' + (-eased * 24) + 'px)';
      if (p < 1) {
        requestAnimationFrame(frame);
      } else {
        row.remove();
        refillIfEmpty();
      }
    }
    requestAnimationFrame(frame);
  }
삭제 높이 접힘 — 사라지는 줄의 높이 값이 0으로 줄며 아래 줄이 채워지는 자리
새 창에서 열기

08 · 삭제 높이 접힘 — 사라지는 줄의 높이 값이 0으로 줄며 아래 줄이 채워지는 자리 — 측정 누적면적 26.947% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

collapseRoweaseInCubicSTOCK

09배지 수 관성 — 바뀐 값을 향해 서서히 따라붙는 아이콘 위 숫자

담긴 개수 값이 바뀐 상태가 되면 아이콘 위 숫자는 그 값을 향해 한 번에 닿지 않고 매 프레임 남은 차이 값의 일부만 따라붙는 관성 값을 거쳐 도착하고, 그 사이 숫자의 크기 값도 잠깐 커졌다 줄어듭니다. 장바구니 아이콘의 담긴 개수 표시에 쓰는 형태입니다.

목표 숫자와 표시 숫자의 차이를 매 프레임 줄이는 lerp() 기반 tick()

  // 계산 겹: 표시 숫자(displayed)가 목표 숫자(target)를 매 프레임 향해 관성 lerp() 로
  // 따라잡는다 — 값이 바뀔 때마다 다시 세지 않고, 두 값의 차이를 매 프레임 조금씩 줄인다.
  function lerp(a, b, t) { return a + (b - a) * t; }

  var target = 0;
  var displayed = 0;
  var raf = null;

  function tick() {
    displayed = lerp(displayed, target, 0.16);
    var shown = Math.round(displayed);
    badge.textContent = String(shown);
    var pulse = 1 + Math.min(0.3, Math.abs(target - displayed) / (target + 1) * 0.6);
    badge.style.transform = 'scale(' + pulse.toFixed(3) + ')';
    if (Math.abs(target - displayed) > 0.03) {
      raf = requestAnimationFrame(tick);
    } else {
      displayed = target;
      badge.textContent = String(target);
      badge.style.transform = 'scale(1)';
      raf = null;
    }
  }
배지 수 관성 — 바뀐 값을 향해 서서히 따라붙는 아이콘 위 숫자
새 창에서 열기

09 · 배지 수 관성 — 바뀐 값을 향해 서서히 따라붙는 아이콘 위 숫자 — 측정 누적면적 0.232% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

lerptargetdisplayed

값이 패턴을 바꾸는 곳 — 오버슛 계수·저항 곡선·지연 값

오버슛 계수 — 감쇠비 값이 튕기는 정도를 정하는 곳

01 의 ZETA는 감쇠비 값이라 이 값이 작을수록 진동이 덜 죽어 패널이 목표를 넘나드는 흔들림이 오래 남고, 값이 클수록 한 번 넘었다가 빠르게 가라앉습니다. 패널 하나의 통통 튀는 인상은 이 계수 하나로 정해집니다.

저항 곡선 — 임계값을 넘은 뒤 압축 비율을 정하는 곳

05 의 THRESHOLD는 저항이 시작되는 거리 값이라, 이 값이 작으면 손이 조금만 움직여도 곧바로 압축 구간에 들어가고 값이 크면 손끝이 자유롭게 움직이는 구간이 늘어납니다. MAX_PULL은 그 압축이 최종적으로 얼마나 멀리까지 늘어날 수 있는지를 정하는 값입니다.

지연 값 — 총 시간을 항목 수로 나누는 곳

03 의 TOTAL_SPREAD_MS는 첫 줄부터 마지막 줄까지 걸리는 총 시간 값이라, 항목 수가 늘어나면 computeDelay()가 한 줄 사이의 간격을 자동으로 좁혀 전체 등장 시간을 지킵니다. BASE_MS는 항목이 하나뿐이어도 최소한으로 남는 지연 값입니다.

어디서 깨지는가 — 빠른 드래그·수량 폭증·목록이 비었을 때

빠른 드래그 — 값이 채 자리 잡기 전에 다시 들어오는 지점

01 은 run(closing)이 호출될 때마다 springProgress(tSec)가 t를 0부터 다시 재는 함수라, 애니메이션이 끝나기 전에 토글을 빠르게 두 번 누르면 isOpen이 아직 갱신되지 않은 상태라 같은 방향으로 다시 시작되어 패널 위치 값이 순간적으로 닫힌 자리 쪽으로 튀었다가 다시 열리는 것처럼 보일 수 있습니다. 05 는 카드가 dismiss()로 밀려나는 320ms 전환 도중에 다시 잡으면 current가 그 전환이 끝나야 0으로 재설정되므로, 전환 중간에 새로 드래그를 시작하면 시작 지점 계산에 옛 current 값이 섞여 카드가 손 위치와 다른 곳에서 시작할 수 있습니다.

수량 폭증 — 값이 순서를 건너뛰는 지점

04 는 + 버튼을 연속으로 누르면 bump()가 매번 raf를 취소하고 pulseStart를 지금 시각으로 다시 설정하는데, 이전 펄스가 아직 원래 크기로 돌아오지 못한 상태에서 다시 시작되면 크기 값이 부드럽게 이어지지 않고 눈에 띄게 한 번 꺾일 수 있습니다. 09 는 target이 9를 넘는 상태가 되면 lerp()를 거치지 않고 target = 0; displayed = 0;으로 즉시 되돌아가므로, 다른 값들과 달리 이 지점에서만 관성 없이 숫자가 뚝 끊겨 보입니다.

목록이 비었을 때 — 계산의 재료 자체가 없는 지점

06 은 hasItems 값이 참·거짓 둘뿐이라 항목이 0개에서 1개로 늘 때와 1개에서 2개로 늘 때를 구분하지 못하고 같은 교차 계산을 반복합니다. 08 은 refillIfEmpty()가 list.children.length < 2가 되는 순간 STOCK 배열을 순환시켜 새 줄을 곧바로 채워 넣기 때문에 이 데모 자체는 실제로 목록이 완전히 비는 상태를 보여주지 않는데, gnavhgub 안 원본에서 실제 재고 데이터를 연결한다면 refillIfEmpty() 호출을 지우고 0개 상태를 위한 안내문을 따로 두어야 목록이 진짜로 빈 상태를 보여줄 수 있습니다.

변형 세 가지와 접근성

같은 화면이라도 상수 값 하나만 바꾸면 장바구니 디자인 인상이 완전히 달라지는데, 아래 세 개는 그 값과 느낌을 실측한 표입니다.

변형 바꾸는 값 바뀌는 느낌
스프링 감쇠비 01 ZETA 0.42 → 0.22 ZETA를 0.42 로 두면 패널이 목표를 살짝 지나쳤다가 한 번 크게 튕기고 가라앉는데, 0.22 로 낮추면 감쇠가 약해져 목표를 넘나드는 진동이 두세 번 더 눈에 띄게 이어져 스프링 특유의 통통 튀는 느낌이 강해집니다
러버밴드 저항 임계값 05 THRESHOLD 90px → 150px THRESHOLD를 90px 로 두면 손이 조금만 끌어도 패널이 바로 닫히는 위치까지 넘어가 버려 저항 구간을 느낄 새가 없는데, 150px 로 올리면 저항이 커지는 구간이 길어져 손끝에 걸리는 감각과 놓았을 때 되돌아오는 탄력이 뚜렷해집니다
삭제 접힘 시간 08 DUR_MS 320ms → 620ms DUR_MS를 320ms 로 두면 지운 줄이 빠르게 접혀 아래 줄이 순간적으로 튀어 올라오는 것처럼 보이는데, 620ms 로 늘리면 높이가 줄어드는 동안 아래 줄들이 같은 속도로 따라 올라오는 과정이 눈으로 좇을 수 있을 만큼 천천히 드러납니다

접근성

아홉 개 모두 prefers-reduced-motion: reduce가 참인 상태가 되면 자동 재생 루프(setInterval·requestAnimationFrame 반복)를 걸지 않고, 그중 01·02 는 한 걸음 더 나아가 애니메이션을 건너뛰고 열린 최종 상태(apply(1))로 곧바로 나타납니다. 이 값이 켜진 상태에서도 클릭·드래그·스크롤 같은 실제 입력 리스너는 그대로 등록돼 있어 조작 자체는 막히지 않습니다. 닫기 버튼(01)·삭제 버튼(08)·수량 증감 버튼(04)·담기 버튼(09)은 전부 aria-label을 갖고 있어 스크린 리더가 아이콘만으로도 무슨 동작인지 읽어 줍니다. 05 는 삭제가 버튼이 아니라 드래그 제스처라 aria-label 대상 자체가 없고, 카드 안 쓰레기통 아이콘도 aria-hidden으로 스크린 리더에서 숨겨져 있습니다. 표준 API 동작은 Pointer Events 문서와 reduced-motion 문서에 정리돼 있습니다.

FAQ

장바구니 UI 드로어와 가운데 뜨는 팝업 장바구니는 어떻게 다른가요?

드로어는 화면 옆에서 패널이 밀려 들어오고 나머지 화면은 그대로 보이는 형태이고, 01·02 처럼 위치 값이나 배경 크기 값을 계산해 옆에서 겹쳐지듯 자리를 잡습니다. 가운데 팝업은 화면 전체를 가리며 나타나는 형태라 계산하는 값 자체가 다르고, 이 편은 옆에서 밀려 들어오는 드로어 9종만 다룹니다.

장바구니 담기 UI 애니메이션 중 하나만 먼저 적용한다면 무엇부터 골라야 하나요?

담기 버튼을 눌렀을 때 반응이 가장 먼저 눈에 띄어야 한다면 09 배지 수 관성이나 04 수량 탄성 범프처럼 계산이 가벼운 것부터 붙이는 편이 안전합니다. 01 슬라이드 오버슛이나 05 드래그 러버밴드는 패널 전체 위치 값을 계산하므로 화면 구조가 먼저 잡힌 뒤에 얹는 편이 수정이 적습니다.

아홉 개 중 실제 결제나 재고 서버와 통신하는 게 있나요?

없습니다. 아홉 개 전부 클릭·드래그·스크롤에 반응해 로컬 상태 값만 바꾸는 시뮬레이션이고 fetch나 실제 결제 요청을 보내지 않습니다. 실제 쇼핑몰에 붙이려면 각 계산 함수가 만드는 값(위치·거리·개수)을 그대로 두고 그 값을 만드는 이벤트 자리에 실제 API 응답 처리 코드를 넣으면 됩니다.

더 읽을거리

이 아홉 개는 장바구니 패널이 열리고 닫히는 값 자체를 계산하는 데 집중한다는 점에서, 아래 이웃 편들과는 값이 붙는 자리가 다릅니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.