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

CTA 버튼 디자인 9종 — 그림자가 늘어나는 것부터 막대가 튀는 것까지

cta 버튼 디자인은 클릭을 부르는 버튼에 상태 변화를 계산해 얹는 작업인데, 아홉 개 중 하나는 그림자가 뻗어나가는 길이 값이 호버 상태에 따라 늘고 줄고, 하나는 둘레를 감싼 진행 값이 정해진 시간 동안 0까지 줄어들며, 또 하나는 끄는 위치 값이 기준을 넘어야 확정된 상태로

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

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

랜딩 페이지·결제·이벤트 버튼처럼 클릭 한 번의 무게가 큰 자리일수록 호버·클릭 뒤에 값이 어떻게 재계산되는지가 인상을 가릅니다 — 아래 표의 누적면적 값은 08 을 빼면 대체로 작은데, 09 처럼 작은 컴포넌트 편은 면적이 아니라 클릭마다 다른 난수로 튀었다가 감쇠하는 계산 한 겹의 정교함으로 재는 것이 맞습니다. 아래 표는 호버 상태에서 값이 되돌아오는 것(01·07) → 클릭이 일어날 때마다 값이 다시 계산되는 것(02·03·05·09) → 클릭 뒤 시간에 따라 값이 스스로 줄어드는 것(04·08) → 위치 값이 기준을 넘어야 확정되는 것(06) 순으로 묶었습니다.

아홉 개의 해부 — 어떤 값이 언제 바뀌는가

실측 한눈에

항목 초점 누적면적% 강도 평균 움직인 프레임
01 롱섀도 레트로 그림자 길이 값 → box-shadow 레이어 재계산 2.70 166.3 8/23
02 어긋남 분리 어긋남 폭 값 → translate 좌표 재계산 0.91 115.5 7/23
03 조각 발산 각도·속도 값 → 파티클 궤적 재계산 3.59 62.8 15/23
04 둘레 진행 진행 값 → stroke-dashoffset 재계산 1.60 150.9 20/23
05 입체 뒤집기 회전 값 → rotateY 오버슈트 재계산 5.27 110.7 16/23
06 밀어 확정 위치 값 → translateX 재계산 5.16 218.7 16/23
07 순차 드러남 순서 값 → transitionDelay 재계산 0.94 64.6 10/23
08 흐르는 대기 밝기 위치 값 → sweep translateX 재계산 6.11 38.7 20/23
09 막대 반응 높이 값 → 막대 height% 재계산 0.29 170.7 23/23

08 은 sweep 이 표면 전체를 가로질러 매번 다시 그려 누적면적이 6.11%로 아홉 중 가장 크고, 09 는 막대 다섯 개만 움직여 0.29%로 가장 작습니다 — 09 는 077 캔버스 장면 편과 달리 컴포넌트 편이라 면적이 아니라 클릭마다 다른 난수로 튀었다가 DECAY(0.14)로 가라앉는 계산 한 겹의 정교함으로 재는 것이 맞습니다. 강도 평균은 06 이 218.7로 가장 높아 트랙 채움 핸들과 바탕 사이 픽셀 값 차이가 뚜렷하다는 뜻이고, 08 은 38.7로 가장 낮아 옅은 스윕 밝기 차이가 완만하다는 뜻입니다. 움직인 프레임은 09 가 23/23으로 유일하게 매 프레임 감쇠 값을 흘리는 구조라는 뜻이고, 02 는 7/23으로 가장 낮아 어긋남이 320ms 안에 짧게 끝난다는 뜻입니다.

호버 상태가 되면 값이 늘었다 줄어드는 cta 버튼 종류 — 01·07

01 은 mouseenter·focus 상태가 되면 step()이 목표(target)를 MAXLEN(22)으로 올리고, 매 프레임 len 값을 목표와의 차이의 28%씩 선형 보간(lerp)해 buildShadow()가 그 길이만큼 45도 방향 레이어를 쌓아 box-shadow 문자열을 다시 만들며, mouseleave·blur 상태가 되면 목표가 다시 0 이 되어 같은 계산으로 그림자가 줄어듭니다. 07 은 호버 상태가 되면 reveal()이 두 번째 문구에 is-shown 클래스를 얹어 각 글자의 transitionDelay(순서의 0.82제곱 × BASE)만큼 늦게 나타나게 하고, 호버가 풀린 상태가 되면 hide()가 같은 클래스를 지워 처음 문구로 되돌립니다.

클릭이 일어날 때마다 값이 다시 계산되는 것 — 02·03·05·09

02 는 클릭·호버 상태가 되면 runGlitch()가 mulberry32(SEED) 시드 난수로 두 텍스트 층의 어긋남 폭을 TICK(40ms)마다 다시 뽑아 DURATION(320ms) 동안 흔들다가 반드시 0 으로 되돌립니다. 03 은 클릭이 일어나면 burst()가 조각 18개마다 서로 다른 각도(angle)·속도(speed) 값을 부여하고, tick()이 매 프레임 중력(GRAVITY 420)을 속도에 더하고 감쇠(DRAG 0.985)를 곱해 좌표를 다시 계산합니다. 05 는 클릭·Enter·Space 가 눌리면 flip()이 시작되어 ease()가 감쇠 진동식(지수 감쇠 × 사인파)으로 회전 값이 180도를 살짝 넘었다가 스프링처럼 제자리에 앉는 값을 매 프레임 계산합니다. 09 는 클릭이 일어날 때마다 hit()이 막대 다섯 개 각각의 목표 높이를 서로 다른 난수로 정하고, tick()이 현재 높이와 바닥(MIN_H)의 차이를 DECAY(0.14)만큼씩 줄여 튀어 오른 막대가 서서히 가라앉는 값을 계산합니다.

클릭 뒤 시간에 따라 값이 스스로 줄어드는 것 — 04·08

04 는 클릭이 일어나면 startCountdown()이 시작 시각(startT)을 기록하고, frame()이 남은 시간 비율(progress)을 DURATION(1500ms) 동안 0에서 1까지 매 프레임 계산해 둘레(CIRC)에 곱한 stroke-dashoffset 값으로 그려 1 이 되면 완료 상태로 넘어갑니다. 08 은 클릭이 일어나면 startLoading()이 라벨을 지우고 frame()이 sweep의 가로 위치 값을 easeInOutSine()으로 CYCLE(1100ms)마다 한쪽 끝(-BAND)에서 반대쪽 끝까지 반복해서 계산하다가, 정해진 시간이 지나면 그 흐름을 멈추고 실제 문구로 바뀝니다.

위치 값이 기준을 넘어야 확정되는 것 — 06

06 은 끄는 위치 값(pos)을 트랙 폭 대비 비율로 clamp 하다가, 손을 놓는 순간 release()가 그 값이 THRESH(0.7)를 넘었는지로 확정 여부를 가르고, 넘지 못한 채 놓인 상태가 되면 settle()이 목표(0)까지 매 프레임 차이의 30%씩 줄여 원래 자리로 되돌립니다.

패턴별 코드

01롱섀도 레트로 — 뻗어나가는 그림자의 길이 값이 호버 상태에 따라 늘고 줄어드는 버튼

그림자가 뻗어나가는 길이 값이 호버 상태가 되면 정해진 목표 값까지 늘어나고, 호버가 풀린 상태가 되면 다시 원래 길이 값으로 돌아옵니다. 랜딩 페이지 CTA, 프로모션 배너 버튼에 맞는 형태입니다.

MAXLEN·lerp 로 그림자 길이 값을 매 프레임 좇는 buildShadow()

  var MAXLEN = 22;
  var C_NEAR = [47, 109, 246];   // $subject-blue
  var C_FAR = [23, 20, 26];      // $subject-ink
  var len = 0, target = 0, raf = null;

  function lerp(a, b, t) { return a + (b - a) * t; }

  function buildShadow(l) {
    var n = Math.round(l);
    if (n <= 0) { return 'none'; }
    var parts = [];
    for (var i = 1; i <= n; i++) {
      var t = i / n;
      var r = Math.round(lerp(C_NEAR[0], C_FAR[0], t));
      var g = Math.round(lerp(C_NEAR[1], C_FAR[1], t));
      var b = Math.round(lerp(C_NEAR[2], C_FAR[2], t));
      parts.push(i + 'px ' + i + 'px 0 rgb(' + r + ',' + g + ',' + b + ')');
    }
    return parts.join(',');
  }
롱섀도 레트로 — 뻗어나가는 그림자의 길이 값이 호버 상태에 따라 늘고 줄어드는 버튼
새 창에서 열기

01 · 롱섀도 레트로 — 뻗어나가는 그림자의 길이 값이 호버 상태에 따라 늘고 줄어드는 버튼 — 측정 누적면적 2.695% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MAXLEN=22buildShadowlerp

02어긋남 분리 — 겹쳐진 텍스트 층의 위치 값이 짧은 시간 동안 어긋났다가 다시 겹쳐지는 버튼

호버 상태가 되는 순간 겹쳐진 두 텍스트 층의 위치 값이 정해진 폭만큼 순간적으로 어긋났다가, 정해진 시간이 지나면 다시 같은 위치 값으로 겹칩니다. 이벤트 강조 버튼, 한정판 상품 CTA에 맞는 형태입니다.

SEED·mulberry32 로 어긋남 폭을 매 틱 새로 뽑는 runGlitch()

  var SEED = 20260920;
  function mulberry32(a) {
    return function () {
      a |= 0; a = (a + 0x6D2B79F5) | 0;
      var t = Math.imul(a ^ (a >>> 15), 1 | a);
      t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    };
  }
  var rng = mulberry32(SEED);
  var DURATION = 320, TICK = 40;
  var glitchTimer = null;
어긋남 분리 — 겹쳐진 텍스트 층의 위치 값이 짧은 시간 동안 어긋났다가 다시 겹쳐지는 버튼
새 창에서 열기

02 · 어긋남 분리 — 겹쳐진 텍스트 층의 위치 값이 짧은 시간 동안 어긋났다가 다시 겹쳐지는 버튼 — 측정 누적면적 0.914% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SEED=20260920DURATION=320mulberry32

03조각 발산 — 클릭한 자리에서 여러 조각이 저마다 다른 각도 값으로 흩어졌다가 사라지는 버튼

클릭이 일어난 지점을 기준으로 여러 조각이 저마다 다른 각도 값과 속도 값으로 퍼져나가고, 투명도 값이 시간이 지날수록 낮아지다 사라진 상태가 됩니다. 구매 완료 버튼, 이벤트 참여 버튼에 맞는 형태입니다.

GRAVITY·COLORS 로 파티클 궤적을 매 프레임 계산하는 burst()

  var GRAVITY = 420, DRAG = 0.985, LIFE = 900;
  var particles = [];
  var raf = null, lastT = 0;

  function burst() {
    var cx = canvas.width / 2, cy = canvas.height * 0.35;
    particles = [];
    for (var i = 0; i < 18; i++) {
      var angle = (Math.PI * 2 * i) / 18 + (Math.random() - 0.5) * 0.5;
      var speed = 90 + Math.random() * 110;
      particles.push({
        x: cx, y: cy,
        vx: Math.cos(angle) * speed,
        vy: Math.sin(angle) * speed - 60,
        rot: Math.random() * Math.PI,
        vrot: (Math.random() - 0.5) * 10,
        color: COLORS[i % COLORS.length],
        age: 0
      });
    }
    if (!raf) { lastT = performance.now(); raf = requestAnimationFrame(tick); }
  }
조각 발산 — 클릭한 자리에서 여러 조각이 저마다 다른 각도 값으로 흩어졌다가 사라지는 버튼
새 창에서 열기

03 · 조각 발산 — 클릭한 자리에서 여러 조각이 저마다 다른 각도 값으로 흩어졌다가 사라지는 버튼 — 측정 누적면적 3.59% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GRAVITY=420COLORSburst

04둘레 진행 — 버튼을 감싼 둘레의 진행 값이 정해진 시간 동안 줄어드는 버튼

버튼을 감싼 둘레의 진행 값이 정해진 시간 동안 처음 값에서 0까지 줄어들고, 그 값이 0이 된 상태가 되면 정해진 동작으로 넘어갑니다. 한정 시간 할인 버튼, 자동 다음 단계 버튼에 맞는 형태입니다.

R·CIRC 로 둘레 진행 값을 stroke-dashoffset 에 계산하는 startCountdown()

  var R = 38;
  var CIRC = 2 * Math.PI * R;
  bar.style.strokeDasharray = CIRC.toFixed(2);
  var DURATION = 1500;
  var raf = null, startT = 0, running = false;

  function frame(now) {
    var t = now - startT;
    var progress = Math.min(1, t / DURATION);
    var offset = CIRC * (1 - progress);
    bar.style.strokeDashoffset = offset.toFixed(2);
    if (progress < 1) {
      raf = requestAnimationFrame(frame);
    } else {
      running = false; raf = null;
      btn.textContent = '완료됨';
      setTimeout(function () { btn.textContent = '다음 단계'; bar.style.strokeDashoffset = CIRC.toFixed(2); }, 500);
    }
  }
  function startCountdown() {
    if (running) { return; }
    running = true;
    startT = performance.now();
    raf = requestAnimationFrame(frame);
  }
둘레 진행 — 버튼을 감싼 둘레의 진행 값이 정해진 시간 동안 줄어드는 버튼
새 창에서 열기

04 · 둘레 진행 — 버튼을 감싼 둘레의 진행 값이 정해진 시간 동안 줄어드는 버튼 — 측정 누적면적 1.598% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

R=38CIRCstartCountdown

05입체 뒤집기 — 클릭하면 회전 값이 180도까지 돌며 반대 면이 드러나는 버튼

클릭이 일어나면 버튼의 회전 값이 180도가 될 때까지 돌아가며 반대 면이 드러나고, 회전 값이 목표를 살짝 넘었다가 제자리로 돌아오는 상태를 거칩니다. 찬반 선택 버튼, 비교 CTA에 맞는 형태입니다.

OVERSHOOT_DEG·DECAY 로 회전 값을 감쇠 진동시키는 ease()

  function ease(tSec) {
    if (tSec <= 0) { return 0; }
    var settle = 1 - Math.exp(-DECAY * tSec);
    var wobble = Math.sin(tSec * FREQ) * Math.exp(-DECAY * tSec) * (OVERSHOOT_DEG / 180);
    return settle + wobble;
  }
  function frame(now) {
    var t = (now - startT) / 1000;
    var p = Math.min(1, ease(t));
    current = base + p * 180;
    btn.style.transform = 'rotateY(' + current.toFixed(2) + 'deg)';
    if (t < DUR / 1000) {
      raf = requestAnimationFrame(frame);
    } else {
      base = base + 180;
      current = base;
      btn.style.transform = 'rotateY(' + current.toFixed(2) + 'deg)';
      raf = null;
    }
  }
  function flip() {
    if (raf) { return; }
    startT = performance.now();
    raf = requestAnimationFrame(frame);
  }
입체 뒤집기 — 클릭하면 회전 값이 180도까지 돌며 반대 면이 드러나는 버튼
새 창에서 열기

05 · 입체 뒤집기 — 클릭하면 회전 값이 180도까지 돌며 반대 면이 드러나는 버튼 — 측정 누적면적 5.267% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

OVERSHOOT_DEG=18flipease

06밀어 확정 — 끄는 위치 값이 기준을 넘으면 확정 상태가 되는 버튼

끄는 위치 값이 정해진 기준 값을 넘은 상태가 되면 확정된 상태로 바뀌고, 기준에 못 미친 채로 놓인 상태가 되면 위치 값이 처음 자리로 되돌아갑니다. 결제 확정 버튼, 위험한 작업 확인 버튼에 맞는 형태입니다.

THRESH·THUMB_W 로 위치 값의 확정 여부를 가르는 settle()

  var THRESH = 0.7;
  var pos = 0, target = 0, raf = null, confirmed = false;
  var THUMB_W = 40;

  function maxTravel() { return track.clientWidth - THUMB_W - 8; }

  function apply(p) {
    var mt = maxTravel();
    thumb.style.transform = 'translateX(' + (p * mt).toFixed(1) + 'px)';
  }

  function settle() {
    var diff = target - pos;
    pos += diff * 0.3;
    if (Math.abs(diff) < 0.002) { pos = target; }
    apply(pos);
    if (Math.abs(target - pos) > 0.002) {
      raf = requestAnimationFrame(settle);
    } else {
      raf = null;
    }
  }
밀어 확정 — 끄는 위치 값이 기준을 넘으면 확정 상태가 되는 버튼
새 창에서 열기

06 · 밀어 확정 — 끄는 위치 값이 기준을 넘으면 확정 상태가 되는 버튼 — 측정 누적면적 5.158% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

THRESH=0.7THUMB_W=40settle

07순차 드러남 — 호버 상태에서 두 번째 문구가 글자 순서 값을 따라 하나씩 나타나는 버튼

호버 상태가 되면 두 번째 문구의 각 글자가 자신의 순서 값만큼 지연된 뒤 차례로 나타난 상태가 되고, 호버가 풀린 상태가 되면 처음 문구로 되돌아갑니다. 설명 문구 전환 버튼, 상세 안내 CTA에 맞는 형태입니다.

BASE 로 글자별 등장 지연 값을 계산하는 reveal()

  var TEXT = '지금 30% 할인중';

  var BASE = 70;
  TEXT.split('').forEach(function (ch, i) {
    var span = document.createElement('span');
    span.textContent = ch === ' ' ? ' ' : ch;
    span.style.transitionDelay = (Math.pow(i, 0.82) * BASE).toFixed(0) + 'ms';
    second.appendChild(span);
  });
  var maxDelay = Math.pow(TEXT.length - 1, 0.82) * BASE + 260;

  function reveal() {
    second.classList.add('is-shown');
    first.classList.add('is-hidden');
  }
  function hide() {
    second.classList.remove('is-shown');
    first.classList.remove('is-hidden');
  }
순차 드러남 — 호버 상태에서 두 번째 문구가 글자 순서 값을 따라 하나씩 나타나는 버튼
새 창에서 열기

07 · 순차 드러남 — 호버 상태에서 두 번째 문구가 글자 순서 값을 따라 하나씩 나타나는 버튼 — 측정 누적면적 0.943% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BASE=70TEXTreveal

08흐르는 대기 — 콘텐츠가 준비되기 전까지 표면의 밝기 값이 한 방향으로 흐르는 버튼

콘텐츠가 준비되지 않은 상태인 동안 표면의 밝기 값이 한쪽 끝에서 반대쪽 끝까지 반복해서 흐르고, 콘텐츠가 준비된 상태가 되면 그 흐름이 멈추고 실제 내용으로 바뀝니다. 비동기 데이터 로딩 버튼, 폼 제출 대기 버튼에 맞는 형태입니다.

BAND·CYCLE 로 밝기 위치 값을 사인 곡선으로 계산하는 easeInOutSine()

  var BAND = 34, CYCLE = 1100;
  var raf = null, startT = 0, loading = false;

  function easeInOutSine(t) { return -(Math.cos(Math.PI * t) - 1) / 2; }

  function frame(now) {
    var t = ((now - startT) % CYCLE) / CYCLE;
    var e = easeInOutSine(t);
    var pos = -BAND + e * (100 + BAND * 2);
    sweep.style.transform = 'translateX(' + pos.toFixed(1) + '%)';
    if (loading) { raf = requestAnimationFrame(frame); } else { raf = null; }
  }
흐르는 대기 — 콘텐츠가 준비되기 전까지 표면의 밝기 값이 한 방향으로 흐르는 버튼
새 창에서 열기

08 · 흐르는 대기 — 콘텐츠가 준비되기 전까지 표면의 밝기 값이 한 방향으로 흐르는 버튼 — 측정 누적면적 6.108% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BAND=34CYCLE=1100easeInOutSine

09막대 반응 — 클릭할 때마다 여러 막대의 높이 값이 저마다 다르게 튀었다가 가라앉는 버튼

클릭이 일어날 때마다 나열된 여러 막대 각각의 높이 값이 서로 다른 크기로 순간 튀어오르고, 시간이 지나면서 각 막대의 높이 값이 줄어들어 처음 높이로 가라앉은 상태가 됩니다. 알림음 켜기 버튼, 재생/일시정지 버튼에 맞는 형태입니다.

DECAY·MIN_H 로 막대 높이 값을 매 프레임 감쇠시키는 hit()

  function tick() {
    var moving = false;
    for (var i = 0; i < N; i++) {
      var b = bars[i];
      var diff = MIN_H - b.h;
      if (b.h > MIN_H) {
        b.h += diff * DECAY;
        if (b.h - MIN_H > 0.004) { moving = true; } else { b.h = MIN_H; }
      }
    }
    render();
    if (moving) { raf = requestAnimationFrame(tick); } else { raf = null; }
  }
  function hit() {
    for (var i = 0; i < N; i++) {
      bars[i].h = Math.max(bars[i].h, MIN_H + Math.random() * 0.82);
    }
    render();
    if (!raf) { raf = requestAnimationFrame(tick); }
  }
막대 반응 — 클릭할 때마다 여러 막대의 높이 값이 저마다 다르게 튀었다가 가라앉는 버튼
새 창에서 열기

09 · 막대 반응 — 클릭할 때마다 여러 막대의 높이 값이 저마다 다르게 튀었다가 가라앉는 버튼 — 측정 누적면적 0.293% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DECAY=0.14MIN_H=0.14hit

값이 패턴을 바꾸는 곳 — 길이·진행·위치 값의 기준

실제 cta 버튼 예시에서 그림자 길이·진행·위치 값을 어디서 조정하면 되는지 보겠습니다 — 아홉 개 중 세 값만 골라도 인상이 크게 갈립니다.

그림자 길이 기준 — 01 MAXLEN

01 의 MAXLEN(22)은 buildShadow()가 그리는 레이어 개수의 상한이라, 10 대로 낮추면 목표까지 그려지는 레이어가 적어져 그림자가 짧고 옅은 인상이 되고, 40 대로 올리면 레이어가 더 많이 그려져 그림자가 훨씬 길고 무거운 3D 느낌이 되는 대신 매 프레임 채우는 레이어 수가 늘어 계산 비용도 함께 커집니다. len이 목표에 0.28씩만 다가가는 lerp 구조라, MAXLEN 이 클수록 절대 오차(0.05) 기준에 닿기까지 실제로 더 많은 프레임이 걸려 그림자가 완전히 자리 잡는 시간도 함께 늘어납니다.

진행 기준 — 04 R·DURATION

04 의 R(38)은 둘레(CIRC)를 정하는 반지름 값이라, 링 크기가 바뀔 뿐 offset = CIRC * (1 - progress) 산술이 늘 원 둘레 전체를 기준으로 다시 계산되므로 속도감 자체는 달라지지 않고, 정작 체감 속도를 정하는 값은 DURATION(1500ms)입니다 — 800ms 대로 낮추면 진행 값이 더 빨리 0 에 닿아 다음 단계로 넘어가는 압박이 커지고, 2500ms 대로 올리면 진행 값이 천천히 줄어 여유 있게 취소할 여지가 생깁니다.

위치 기준 — 06 THRESH

06 의 THRESH(0.7)는 끄는 위치 값이 얼마나 넘어야 확정으로 판정되는지의 기준이라, 0.9 대로 올리면 트랙 끝까지 거의 다 밀어야만 확정돼 실수로 넘어가는 일이 줄어드는 대신 손을 놓기 전에 확실히 밀었다는 확신이 필요해지고, 0.4 대로 낮추면 절반도 못 미는 시점에 이미 확정되어 살짝만 끌어도 바로 넘어가는 가벼운 조작감이 됩니다.

어디서 깨지는가 — 빠른 연타·짧은 호버·중간에 놓기

빠른 연타 — 02

02 는 runGlitch()가 시작할 때 if (glitchTimer) { return; }로 이미 실행 중인지부터 확인하므로, DURATION(320ms) 안에 마우스를 다시 올리거나 클릭해도 진행 중인 흔들림 위에 겹쳐 새로 계산되지 않고 그 클릭은 그냥 무시됩니다. 320ms 가 끝나 glitchTimer가 null로 돌아온 뒤에야 다음 연타가 새 어긋남 값을 뽑습니다.

짧은 호버 — 07

07 은 각 글자의 transitionDelay가 인라인 스타일로 한 번 정해진 뒤 지워지지 않으므로, 문구 전체가 다 나타나기 전에(maxDelay 전에) 마우스가 떠나 hide()가 호출되면 이미 나타난 앞 글자는 곧바로 사라지는데 아직 지연 차례가 오지 않은 뒷 글자는 is-shown이 풀렸어도 원래 지연 값만큼 기다렸다가 뒤늦게 opacity 0 으로 정리되어, 버튼은 이미 첫 문구로 돌아왔는데 뒷글자만 한 박자 늦게 깜빡이는 것처럼 보일 수 있습니다.

중간에 놓기 — 06

06 은 pos 값이 THRESH(0.7) 문턱에 살짝 못 미친 0.68 근처에서 손을 놓아도 release()가 이분법으로만 판정해 확정 상태가 되지 않고, goTo(0)이 목표를 곧장 0 으로 잡아 settle()이 차이의 30%씩 줄이는 lerp 로 트랙 시작점까지 그대로 되돌립니다 — 거의 다 민 상태에서 놓아도 중간 어딘가에 멈추는 게 아니라 처음 위치로 완전히 되돌아가는 것이 실제 동작입니다. t6t4u9yp 안 원본에서 이 완전 복귀가 과하다고 느껴지면 goTo(0) 대신 goTo(pos * 0.5)처럼 놓은 지점에 비례한 값으로 목표를 낮춰야, 다시 밀 때 절반만 더 밀면 되는 부분 복귀가 됩니다.

변형 세 가지와 접근성

직접 cta 버튼 만들기에 나설 때는 값 하나만 슬쩍 바꿔도 조작감의 인상이 크게 달라집니다.

이름 바뀐 값 느낌
밀어 확정 기준값 06 THRESH 0.7 → 0.4 THRESH 가 0.7 이면 트랙 폭의 70%를 넘길 때까지 끌어야 확정되어 손을 놓기 전에 확실히 밀었다는 확신이 필요한데, 0.4 로 낮추면 절반도 못 미는 시점에 이미 확정되어 살짝만 끌어도 바로 넘어가는 가벼운 조작감이 된다
막대 감쇠 속도 09 DECAY 0.14 → 0.45 DECAY 가 0.14 면 튀어 오른 막대가 여러 프레임에 걸쳐 천천히 가라앉아 잔향이 오래 남는데, 0.45 로 올리면 같은 높이까지 튀어도 두세 프레임 안에 빠르게 가라앉아 타건감이 훨씬 딱딱하고 즉각적으로 느껴진다
입체 뒤집기 오버슈트 각도 05 OVERSHOOT_DEG 18 → 4 OVERSHOOT_DEG 가 18 이면 180도를 넘어 198도까지 돌았다가 되돌아오는 폭이 커서 반대 면이 출렁이듯 자리를 잡는데, 4 로 줄이면 목표를 살짝만 지나쳤다 바로 안착해 스프링감이 옅어지고 딱딱 끊어지는 기계식 회전에 가까워진다

아홉 개 모두 prefers-reduced-motion: reduce가 참인 상태가 되면 자동 재생 루프는 걸지 않지만, 클릭·드래그·호버·키보드 같은 실제 입력 리스너는 그대로 등록돼 있어 조작 자체와 그 결과 값 계산은 막히지 않습니다. 아홉 개 전부 네이티브 <button> 요소라 Tab 으로 초점이 가고 Enter·Space 로도 클릭 이벤트가 발생해 02·03·04·07·08·09 는 마우스 없이도 값이 계산되고, 05 는 클릭 처리와 별도로 keydown에서 Enter·Space 를 한 번 더 확인합니다. 06 은 pointerdown·pointermove로만 위치 값을 읽어 키보드로 대체할 입력 경로가 아직 없고, 02·07 의 겹친 텍스트 층은 뒤쪽 레이어에 aria-hidden을 붙여 스크린 리더가 같은 문구를 두 번 읽지 않게 해 둔 상태입니다 — rotateY 함수 문서와 prefers-reduced-motion 문서에 각각의 기준을 확인할 수 있습니다.

FAQ

아홉 개 중에 전환율에 가장 도움이 되는 하나만 고른다면 뭘 골라야 하나요?

버튼 하나만 바꾼다고 전환율이 보장되지는 않지만, 아홉 가지 cta 버튼 ui 패턴 중 클릭 직후 상태가 바로 확정되는 04(둘레 진행)나 06(밀어 확정)처럼 다음 행동을 값으로 못 박는 유형이 사용자가 "눌렀는데 아무 일도 안 일어났나"라고 헷갈리는 상황을 줄여 줍니다. 01·07 처럼 호버로만 반응하는 유형은 모바일 터치 환경에서는 그 값 변화 자체가 아예 발생하지 않는다는 점도 함께 고려해야 합니다.

밀어서 확정하는 06 같은 패턴은 아무 버튼에나 써도 되나요?

06 은 THRESH를 넘겨야 확정되는 만큼 결제·삭제처럼 실수로 누르면 곤란한 동작에는 어울리지만, 뉴스레터 구독처럼 가벼운 동작에 쓰면 드래그라는 손동작 자체가 진입 장벽이 됩니다. 이 편의 값(THRESH 0.7)은 무거운 동작 기준이고, 가벼운 동작에 쓰려면 변형 표의 0.4 처럼 기준을 낮춰 손이 덜 가게 만드는 편이 맞습니다.

9개 다 자바스크립트가 있어야 동작하나요, CSS만으로도 되나요?

01·02·05·07·08·09 는 값이 난수·시간·감쇠처럼 매 프레임 다시 계산돼야 해서 자바스크립트 없이는 같은 동작이 나오지 않고, 03 은 캔버스 파티클이라 더더욱 그렇습니다. 04(둘레 진행)와 06(밀어 확정)도 시작 시각이나 드래그 위치 값을 실시간으로 읽어야 하므로, 아홉 개 모두 CSS 트랜지션만으로는 재현되지 않는 계산 겹을 갖고 있습니다.

더 읽을거리

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

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