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

카드 스와이프 ui 9가지 — 드래그를 물리 계산으로 다듬기

카드 스와이프 ui 아홉 개를 모아 놓고 봤다. 엄지 끝 속도가 02번 기준을 넘지 못한 순간, 카드는 손을 놓아도 날아가지 않고 03번처럼 스프링으로 튕겨 제자리에 앉는다. 마지막 장까지 넘긴 자리엔 08번 빈 카드만 남는다.

아홉 개 모두 손을 놓는 순간 값 하나를 judge() 함수에 넘겨 날아갈지 되돌아갈지를 가른다. 대부분은 드래그 속도(px/ms)를 그 값으로 쓰지만 07은 방향키를 누르고 있던 시간만큼 쌓은 충전량(0~1)을 똑같은 judge() 에 그대로 넣는다. 입력이 포인터 이벤트든 키보드든 매 프레임 requestAnimationFrame 루프 안에서 규칙은 하나뿐이라는 뜻이다.

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

아홉 장 모두 손을 놓는 순간 값 하나로 갈린다

아래 아홉 장은 같은 판정 구조 위에서 회전·속도·스프링·원근·불투명도·경로·키보드·리필·합성 값 하나씩만 다르게 쓴다. 카드마다 무엇이 바뀌는지, 그 값이 어디서 계산되는지를 소스로 확인한다.

01드래그 거리에 보간되는 카드 회전

카드를 옆으로 끌면 이동한 픽셀 비율(pctX)에 ROTATE_LERP_RATIO 를 곱한 값이 회전각이 되고, ROTATE_MAX_DEG 26도에서 더 이상 꺾이지 않는다. 손을 놓을 때는 그 순간 속도가 SWIPE_VELOCITY_THRESHOLD_PX_MS(0.3px/ms) 를 넘었는지로 날아갈지 되돌아올지가 갈린다.

드래그 거리에 보간되는 카드 회전
새 창에서 열기

01 · 드래그 거리에 보간되는 카드 회전 — 측정 누적면적 49.342% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SWIPE_VELOCITY_THRESHOLD_PX_MSROTATE_LERP_RATIOEXIT_PCT
  function judge(velocity, dir) {
    root.classList.remove("is-demo");
    var flung = Math.abs(velocity) >= SWIPE_VELOCITY_THRESHOLD_PX_MS;
    if (reduce) {
      if (flung) { status.textContent = dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨"; nextCard(); }
      else { pctX = 0; render(); status.textContent = "제자리로 돌아옴"; phase = "idle"; }
      return;
    }
    if (flung) {
      phase = "exit"; exitDir = dir; exitPct = pctX;
      status.textContent = dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨";
    } else {
      phase = "settle";
      status.textContent = "제자리로 돌아옴";
    }
  }

24장 캡처에서 화면 누적 49.3%, 강도 평균 120.0, 6/23 프레임이 움직였다.

02놓는 속도로 갈리는 스와이프 판정

카드를 놓는 순간의 속도가 FLING_VELOCITY_MIN(0.42px/ms) 을 못 넘으면 카드는 관성(INERTIA_DECAY)만 줄이며 제자리로 돌아온다. 회전은 이동 거리에 SWIPE_ROTATE_LERP_RATIO(0.05deg/px) 를 곱해 01보다 훨씬 완만하게 붙는다.

놓는 속도로 갈리는 스와이프 판정
새 창에서 열기

02 · 놓는 속도로 갈리는 스와이프 판정 — 측정 누적면적 8.493% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FLING_VELOCITY_MINSWIPE_ROTATE_LERP_RATIOINERTIA_DECAY
  function judge(velocity, dir) {
    root.classList.remove("is-demo");
    hint.textContent = "속도 " + Math.abs(velocity).toFixed(2) + " px/ms";
    var flung = Math.abs(velocity) >= FLING_VELOCITY_MIN;
    if (reduce) {
      if (flung) { status.textContent = dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨"; nextCard(); }
      else { x = 0; render(); status.textContent = "기준 속도 미달 — 되돌아옴"; phase = "idle"; }
      return;
    }
    if (flung) {
      phase = "exit"; exitDir = dir;
      status.textContent = dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨";
    } else {
      phase = "settle";
      status.textContent = "기준 속도 미달 — 되돌아옴";
    }
  }

화면 누적은 8.5%로 아홉 장 중 가장 작지만, 강도 평균은 76.5, 6/23 프레임이 움직였다.

03스프링 오버슛으로 되돌아오는 카드

기준 속도(SPRING_FLING_VELOCITY_MIN 0.5px/ms)에 못 미치면 카드는 사라지지 않고 강성(SPRING_STIFFNESS)과 감쇠(SPRING_DAMPING) 값을 매 프레임 적분해 튕기듯 제자리로 붙는다. 목표(0)를 넘겼다가 되돌아오는 오버슛이 그대로 화면에 보인다.

스프링 오버슛으로 되돌아오는 카드
새 창에서 열기

03 · 스프링 오버슛으로 되돌아오는 카드 — 측정 누적면적 15.172% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SPRING_STIFFNESSSPRING_DAMPINGSWIPE_STACK_DEPTH_SCALE
  function integrate(dt) {
    var accel = SPRING_STIFFNESS * (0 - pos) - SPRING_DAMPING * vel;
    vel += accel * dt;
    pos += vel * dt;
    if (Math.abs(pos) < 0.4 && Math.abs(vel) < 4) { pos = 0; vel = 0; phase = "idle"; }
  }

화면 누적 15.2%, 강도 평균 112.5, 8/23 프레임이 움직였다.

04원근 투영으로 쌓이는 카드 스택

뒤에 쌓인 카드 최대 STACK_SIZE(3)장은 순서(i)마다 STACK_PERSPECTIVE_SCALE 와 초점거리 FOCAL 로 원근 배율을 계산해, 한 장씩 작아지고 위로 밀려 계단처럼 겹친다. 놓는 속도가 RELEASE_VELOCITY_MIN 을 넘으면 맨 앞 카드가 빠지고 다음 카드가 이 계산을 그대로 이어받는다.

원근 투영으로 쌓이는 카드 스택
새 창에서 열기

04 · 원근 투영으로 쌓이는 카드 스택 — 측정 누적면적 48.063% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STACK_PERSPECTIVE_SCALEFOCALRELEASE_VELOCITY_MIN
  function layoutStack() {
    for (var i = 1; i <= STACK_SIZE; i++) {
      var depth = i * STACK_PERSPECTIVE_SCALE * FOCAL;
      var scale = FOCAL / (FOCAL + depth);
      var ty = -(1 - scale) * 60;
      var el = peeks[i - 1];
      el.style.transform = "translateY(" + ty.toFixed(2) + "%) scale(" + scale.toFixed(3) + ")";
      el.style.opacity = (1 - i * 0.24).toFixed(2);
      el.style.zIndex = String(STACK_SIZE - i);
    }
  }

화면 누적 48.1%, 강도 평균 113.9, 6/23 프레임이 움직였다.

05드래그 거리에 번지는 좋아요·싫어요 도장

끄는 거리(px)를 STAMP_RANGE_PX(150) 로 나눈 비율을 STAMP_OPACITY_CURVE_POWER(2.4) 제곱해, 처음엔 거의 안 보이다가 임계 부근에서 확 진해지는 비선형 곡선으로 도장 불투명도를 정한다. 방향에 따라 좋아요·싫어요 중 한쪽 도장에만 이 값이 적용된다.

드래그 거리에 번지는 좋아요·싫어요 도장
새 창에서 열기

05 · 드래그 거리에 번지는 좋아요·싫어요 도장 — 측정 누적면적 24.239% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STAMP_OPACITY_CURVE_POWERSTAMP_RANGE_PXRELEASE_VELOCITY_MIN
  function stampOpacity(px) {
    var ratio = clamp(Math.abs(px) / STAMP_RANGE_PX, 0, 1);
    return Math.pow(ratio, STAMP_OPACITY_CURVE_POWER);
  }

  function render() {
    var rot = clamp(x * 0.07, -18, 18);
    card.style.transform = "translateX(" + x.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg)";
    var op = stampOpacity(x);
    like.style.opacity = x > 0 ? op.toFixed(3) : "0";
    nope.style.opacity = x < 0 ? op.toFixed(3) : "0";
  }

화면 누적 24.2%, 강도 평균 53.0, 6/23 프레임이 움직였다.

06역방향 보간으로 되돌리는 마지막 카드

카드가 나가는 동안 실제 위치를 PATH_SAMPLE_MS(40ms) 간격으로 기록해 두었다가, 되돌리기를 누르면 시간축을 끝에서부터 거꾸로 훑어 그 지점을 감싸는 두 샘플 사이를 선형보간해 UNDO_DURATION_S(0.6초) 동안 재생한다. 즉석에서 만든 애니메이션이 아니라 실제로 지나간 경로를 그대로 되감는다.

역방향 보간으로 되돌리는 마지막 카드
새 창에서 열기

06 · 역방향 보간으로 되돌리는 마지막 카드 — 측정 누적면적 48.014% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PATH_SAMPLE_MSUNDO_DURATION_SRELEASE_VELOCITY_MIN
  function sampleReverse(u) {
    var p = savedPath;
    var t0 = p[0].t, t1 = p[p.length - 1].t;
    var span = Math.max(t1 - t0, 1);
    var frac = clamp(u / UNDO_DURATION_S, 0, 1);
    var targetT = t1 - frac * span; // 시간을 거꾸로 훑는다
    for (var i = 0; i < p.length - 1; i++) {
      if (targetT >= p[i].t && targetT <= p[i + 1].t) {
        var seg = Math.max(p[i + 1].t - p[i].t, 0.001);
        var localFrac = (targetT - p[i].t) / seg;
        return p[i].x + (p[i + 1].x - p[i].x) * localFrac;
      }
    }
    return p[0].x;
  }

화면 누적 48.0%, 강도 평균 128.9, 6/23 프레임이 움직였다.

07방향키·버튼으로도 같은 판정을 내는 대체 경로

드래그 없이 방향키를 누르고 있으면 CHARGE_RATE(초당 1.6)로 힘이 쌓이고 떼면 CHARGE_DECAY(초당 3.2)로 줄어드는데, 이 충전량(0~1)이 CHARGE_RELEASE_MIN(0.55) 을 넘었는지를 드래그 속도와 똑같은 judge() 함수 하나로 판정한다. 포인터 좌표든 키보드 충전량이든 들어오는 값의 성격만 다를 뿐 규칙은 같다.

방향키·버튼으로도 같은 판정을 내는 대체 경로
새 창에서 열기

07 · 방향키·버튼으로도 같은 판정을 내는 대체 경로 — 측정 누적면적 18.896% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CHARGE_RATECHARGE_DECAYCHARGE_RELEASE_MIN
  function judge(strength, dir, releaseMin) {
    root.classList.remove("is-demo");
    var flung = Math.abs(strength) >= releaseMin;
    if (reduce) {
      if (flung) { status.textContent = dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨"; nextCard(); }
      else { x = 0; charge = 0; render(); status.textContent = "힘이 부족해 되돌아옴"; phase = "idle"; }
      return;
    }
    if (flung) { phase = "exit"; exitDir = dir; status.textContent = dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨"; }
    else { phase = "settle"; status.textContent = "힘이 부족해 되돌아옴"; }
  }

화면 누적 18.9%, 강도 평균 71.7, 9/23 프레임이 움직였다.

08스프링으로 튀어 오르는 빈 스택 카드

REFILL_COUNT(3)장을 다 넘기면 빈 상태 안내가 뜨고, 다시 채우기를 누르면 새 카드는 위치가 아니라 scale 값 자체에 스프링(REFILL_SPRING_STIFFNESS·REFILL_SPRING_DAMPING)을 적분해 0.2배에서 1배를 넘겼다 되돌아오며 커진다. 그래서 "튀어 오른다"는 건 위아래로 튀는 게 아니라 크기가 오버슛하며 팝업되는 스프링 효과다.

스프링으로 튀어 오르는 빈 스택 카드
새 창에서 열기

08 · 스프링으로 튀어 오르는 빈 스택 카드 — 측정 누적면적 42.749% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

REFILL_COUNTREFILL_SPRING_STIFFNESSREFILL_SPRING_DAMPING
  function integrateScale(dt) {
    var accel = REFILL_SPRING_STIFFNESS * (1 - scale) - REFILL_SPRING_DAMPING * scaleVel;
    scaleVel += accel * dt;
    scale += scaleVel * dt;
    if (Math.abs(1 - scale) < 0.004 && Math.abs(scaleVel) < 0.02) { scale = 1; scaleVel = 0; phase = "idle"; }
  }

화면 누적 42.7%, 강도 평균 198.2, 6/23 프레임이 움직였다 — 아홉 장 중 강도 평균이 가장 높다.

09회전·스프링·원근 스택을 한 덱에 합친 히어로 장면

01의 회전 목표를 HERO_ROTATE_LERP_RATE 로 매 프레임 지연 보간하고, 약하게 놓으면 03과 같은 스프링 적분으로 되돌아오며, 뒤 카드들은 HERO_STACK_PERSPECTIVE_SCALE 원근으로 04처럼 겹쳐 쌓인다. 세 계산이 한 카드 위에서 동시에 굴러가는 완성형 장면이다.

회전·스프링·원근 스택을 한 덱에 합친 히어로 장면
새 창에서 열기

09 · 회전·스프링·원근 스택을 한 덱에 합친 히어로 장면 — 측정 누적면적 22.597% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

HERO_ROTATE_LERP_RATEHERO_STACK_PERSPECTIVE_SCALESPRING_STIFFNESS
  function render() {
    var targetRot = clamp(pos * ROTATE_TARGET_RATIO, -ROTATE_MAX_DEG, ROTATE_MAX_DEG);
    rot += (targetRot - rot) * HERO_ROTATE_LERP_RATE;
    card.style.transform = "translateX(" + pos.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg)";
  }

  function nextCard() {
    idx += 1; setCard();
    pos = 0; vel = 0; rot = 0; phase = "idle";
    render();
    status.textContent = "대기 중";
  }

화면 누적 22.6%, 강도 평균 100.6, 10/23 프레임이 움직였다 — 아홉 장 중 가장 많이 움직였다.

스와이프 판정이 실무에서 쓰이는 자리

01의 회전 보간은 추천 카드 넘기기 화면에, 02의 속도 판정은 매칭 앱의 스와이프 카드 넘기기에 그대로 쓰인다. 04의 원근 스택은 상품 추천 카드 스택에서 뒤 카드가 몇 장 남았는지 보여주는 용도로, 07의 키보드 대체 경로는 마우스 없이 카드만 넘기는 접근성 시나리오에서 빛을 발한다. 이 판정 구조는 모바일 터치 제스처 나 CSS 슬라이더 캐러셀 에서 쓰는 손가락 입력 처리와도 맞닿아 있고, 요금제 디자인 인터랙션 의 버튼 판정과도 같은 감각을 공유한다.

값이 스와이프 판정을 바꾸는 곳 — 속도 임계값·회전 보간 비율·스택 원근 깊이

아홉 장을 24장씩 캡처해 실제로 움직인 화면 비율과 강도를 쟀다.

항목 누적면적 % 강도 평균 움직인 프레임
01 드래그 거리에 보간되는 카드 회전 49.3% 120.0 6/23
02 놓는 속도로 갈리는 스와이프 판정 8.5% 76.5 6/23
03 스프링 오버슛으로 되돌아오는 카드 15.2% 112.5 8/23
04 원근 투영으로 쌓이는 카드 스택 48.1% 113.9 6/23
05 드래그 거리에 번지는 좋아요·싫어요 도장 24.2% 53.0 6/23
06 역방향 보간으로 되돌리는 마지막 카드 48.0% 128.9 6/23
07 방향키·버튼으로도 같은 판정을 내는 대체 경로 18.9% 71.7 9/23
08 스프링으로 튀어 오르는 빈 스택 카드 42.7% 198.2 6/23
09 회전·스프링·원근 스택을 한 덱에 합친 히어로 장면 22.6% 100.6 10/23

판정을 가르는 값은 크게 셋이다 — 놓는 순간의 속도가 SWIPE_VELOCITY_THRESHOLD_PX_MS·FLING_VELOCITY_MIN·RELEASE_VELOCITY_MIN 같은 임계값을 넘는지, 이동 거리가 회전각으로 보간되는 비율(ROTATE_LERP_RATIO·SWIPE_ROTATE_LERP_RATIO), 그리고 뒤 카드가 겹치는 원근 깊이(STACK_PERSPECTIVE_SCALE·HERO_STACK_PERSPECTIVE_SCALE)다. 항목마다 이 셋 중 하나만 강조해 보여주는 구조다.

손 떼는 순간이 다르면 어디서 깨지는가

동작 줄임 설정 을 켠 환경에서는 아홉 항목 모두 스프링 적분이나 보간 루프를 건너뛰고 판정 결과로 바로 넘어가도록 짜여 있다 — 애니메이션이 없어져도 좋아요·싫어요 판정 자체는 그대로 작동한다. 카드 순서는 seedRand 로 고정한 값이라 매번 같은 카드부터 시작하는 것이지 진짜 무작위가 아니다. .is-demo 클래스가 붙어 있는 동안은 실제 입력 없이 synth() 함수가 같은 render()·judge() 를 반복 호출해 자동으로 시연하며, 실제 pointerdown·keydown·클릭이 한 번이라도 오면 이 클래스가 즉시 빠진다. 연속으로 빠르게 스와이프하면 속도 값은 startDrag() 가 매번 0 으로 되돌려 섞이지 않지만, 이전 카드의 퇴장 애니메이션이 끝나기 전에 같은 카드 요소를 새 드래그가 붙잡아 위치가 두 경로에서 동시에 갱신되는 것이 가장 자주 깨지는 지점이다. 06의 되돌리기는 저장된 경로가 두 샘플 미만이면 버튼이 비활성 상태를 유지해 빈 배열을 되감는 사고를 막는다. 여기 쓰인 비밀번호는 ga6p3g89, 실제 파일은 바로 아래 zip 에 있다.

세 값을 바꿔 감각을 조율하고 접근성도 놓치지 않는다

값 바뀌면 이런 느낌
01 SWIPE_VELOCITY_THRESHOLD_PX_MS 0.3 → 0.8 문턱을 낮추면 살짝 미는 손짓에도 카드가 날아가고, 문턱을 올리면 확실히 던져야만 반응해 오작동이 줄어든다
02 SWIPE_ROTATE_LERP_RATIO 0.05 → 0.25 비율을 줄이면 카드가 거의 안 기울고 옆으로만 밀려나며, 비율을 올리면 조금만 끌어도 확 꺾여 반응이 예민해진다
03 SWIPE_STACK_DEPTH_SCALE 0.04 → 0.14 값을 줄이면 뒤 카드들이 비슷한 크기로 얇게 겹쳐 보이고, 값을 올리면 뒤로 갈수록 눈에 띄게 작아져 깊이감이 강해진다

세 값 모두 prefers-reduced-motion: reduce 를 켠 화면에서는 보간·적분 과정 없이 판정 결과로 즉시 넘어가 값 차이 자체가 드러나지 않는다. 07 은 방향키(←→)를 누르고 있는 시간으로, 나머지 항목도 화면 하단 좋아요·싫어요 버튼 클릭으로 드래그 없이 같은 결과에 닿을 수 있다. 접근성 측면에서 상태 문구는 aria-live="polite" 영역에 올라가 스크린리더 사용자도 판정 결과를 놓치지 않는다.

카드 스와이프 ui와 스와이프 제스처는 뭐가 다른가?

카드 스와이프 ui는 카드 한 장을 밀어 좋아요·싫어요 같은 이분법 판정을 내는 컴포넌트를 가리키고, 스와이프 제스처는 그 판정을 만들어내는 손가락 동작 자체(방향·속도·거리)를 가리키는 더 넓은 말이다. 이 글의 아홉 항목은 같은 제스처 입력을 회전·스프링·원근·불투명도·경로·키보드 등 서로 다른 계산으로 받아 화면에 옮긴 예시다.

적용 전 자주 묻는 질문 셋

카드 순서가 매번 똑같이 보이는데 버그인가?

아니다. seedRand 로 고정한 시드값이 매번 같은 시작 카드를 고르도록 만든 결과라 재현 가능한 것이지 무작위가 아니다.

라이브러리 없이 이 정도 계산이 정말 되는가?

그렇다. 아홉 항목 모두 <script> 안의 순수 자바스크립트만으로 판정·보간·적분을 직접 계산하며, 외부 라이브러리나 CDN 을 하나도 불러오지 않는다.

자동으로 움직이는 시연은 실제 동작과 다른가?

아니다. .is-demo 클래스가 붙어 있는 동안 synth() 가 실제 입력과 같은 render()·judge() 경로를 그대로 호출해 자동 재생할 뿐이고, 포인터나 키보드 입력이 한 번 들어오면 그 클래스가 즉시 빠지고 실제 조작으로 넘어간다.

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

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