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

스크래치 카드 리빌 9가지 — 긁은 면적을 픽셀로 계산하기

관찰 기록 09:00 — 손끝이 닿자마자 03번 은박 조각이 중력을 받아 튀어 나간다. 09:04 — 06번 진행 링이 62%에서 멈춰 채워지지 않는다. 09:07 — 09번 화면 속 카드 전부가 한 번에 열린다. 스크래치 카드 리빌 아홉 가지를 지운 픽셀 수 하나로 만든 결과다.

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

스크래치 카드 9가지 — 긁은 면적 값 하나가 리빌을 어떻게 바꾸는가

아홉 항목 모두 은박 캔버스에서 지워진 픽셀 비율 하나를 계산해 쓰지만, 그 값을 어디에 연결하느냐에 따라 화면에서 다르게 보인다. 자동으로 벗겨지는 항목이 있고, 진행 링을 채우는 항목이 있고, 조각이나 컨페티 같은 별도의 물리 연출로 이어지는 항목도 있다. 모두 seedRand로 고정한 난수만 쓰고 외부 이미지나 라이브러리 없이 캔버스 계산만으로 은박과 조각을 그린다. 같은 픽셀 계산 축은 html5 canvas 애니메이션 9종 — 물방울·블랙홀 특수효과의 getImageData 활용에서도 확인할 수 있다.

패턴별 코드

아래 아홉 조각은 각 항목의 판정을 가르는 계산부만 옮긴 발췌이고, 실제 스크래치 카드 제작에 쓸 전체 코드와 은박 이미지는 압축 파일에 있다.

01긁은 면적을 픽셀로 재 자동으로 벗겨지는 카드

긁은 면적을 픽셀로 재 자동으로 벗겨지는 카드다. clearedPixelRatio가 getImageData로 은박 알파값을 한 픽셀씩 세어 지워진 비율을 구하고, 그 값이 SCRATCH_REVEAL_THRESHOLD_PCT를 넘으면 autoPeelRemaining이 나머지 은박을 한 번에 벗긴다. 쿠폰을 즉시 확인하는 카드에 쓴다.

긁은 면적을 픽셀로 재 자동으로 벗겨지는 카드
새 창에서 열기

01 · 긁은 면적을 픽셀로 재 자동으로 벗겨지는 카드 — 측정 누적면적 12.103% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

clearedPixelRatioSCRATCH_REVEAL_THRESHOLD_PCTautoPeelRemaining
  function clearedPixelRatio() {
    var data = ctx.getImageData(0, 0, foil.width, foil.height).data;
    var clear = 0, total = data.length / 4;
    for (var i = 3; i < data.length; i += 4) { if (data[i] < 40) clear += 1; }
    return clear / total;
  }

  function updateMeter(ratio) {
    meterFill.style.width = Math.round(ratio * 100) + '%';
  }

  function openCard() {
    if (opened) return;
    opened = true;
    ctx.globalCompositeOperation = 'destination-out';
    ctx.fillRect(0, 0, foil.width, foil.height);
    updateMeter(1);
    root.classList.add('is-open');
    live.textContent = '20% 할인 쿠폰이 열렸어요';
  }

  function autoPeelRemaining(ratio) {
    if (ratio * 100 >= SCRATCH_REVEAL_THRESHOLD_PCT) openCard();
  }

02매번 다른 무늬로 그려지는 은박 질감

매번 다른 무늬로 그려지는 은박 질감이다. buildNoiseFoil이 픽셀마다 valueNoise2D로 밝기값을 계산해 은박 표면에 채우는데, 격자점 값은 noiseAt이 시드별로 미리 만든 노이즈 테이블에서 가져온다. 프로모션 이벤트 카드를 대량으로 만들 때 카드마다 다른 무늬를 주는 데 쓴다.

매번 다른 무늬로 그려지는 은박 질감
새 창에서 열기

02 · 매번 다른 무늬로 그려지는 은박 질감 — 측정 누적면적 28.594% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

valueNoise2DbuildNoiseFoilnoiseAt
    for (var y = 0; y < FH; y += 1) {
      for (var x = 0; x < FW; x += 1) {
        var n = valueNoise2D(table, x * scaleX * 2.4, y * scaleY * 2.4);
        var lightness = 176 + n * 62;
        var idx = (y * FW + x) * 4;
        d[idx] = lightness * 0.98;
        d[idx + 1] = lightness;
        d[idx + 2] = lightness * 1.03;
        d[idx + 3] = 255;
      }
    }

03중력을 받아 튕겨 나가는 은박 조각

중력을 받아 튕겨 나가는 은박 조각이다. 긁을 때마다 spawnFlake가 조각을 만들고, stepFlakes가 매 프레임 중력과 회전을 더해 위치를 갱신하다가 FLAKE_LIFE_MS를 넘기거나 카드 밖으로 나가면 배열에서 지운다. 생일 쿠폰 긁기 이벤트에 쓴다.

중력을 받아 튕겨 나가는 은박 조각
새 창에서 열기

03 · 중력을 받아 튕겨 나가는 은박 조각 — 측정 누적면적 12.323% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

spawnFlakestepFlakesFLAKE_LIFE_MS
  function stepFlakes(dtMs, nowMs) {
    var dtS = dtMs / 1000;
    var alive = [];
    for (var i = 0; i < flakes.length; i += 1) {
      var f = flakes[i];
      f.vy += FLAKE_GRAVITY_PX_S2 * dtS;
      f.x += f.vx * dtS;
      f.y += f.vy * dtS;
      f.rot += f.angVel * dtS;
      f.born += dtMs;
      if (f.born < FLAKE_LIFE_MS && f.x > -8 && f.x < FW + 8 && f.y < FH + 8) alive.push(f);
    }
    flakes = alive;
  }

04중심에서 원형으로 번지는 리빌 반경

중심에서 원형으로 번지는 리빌 반경이다. 누른 지점을 중심으로 반경이 WIPE_LERP_RATE 비율만큼씩 WIPE_TARGET_R에 가까워지도록 wipe가 매 프레임 원을 그려 은박을 지우고, 시연은 이 번짐을 DEMO_STARTS 세 지점에서 세 번 반복해 보여준다. 터치 한 번으로 여는 모바일 쿠폰에 쓴다.

중심에서 원형으로 번지는 리빌 반경
새 창에서 열기

04 · 중심에서 원형으로 번지는 리빌 반경 — 측정 누적면적 23.424% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

wipeWIPE_LERP_RATEWIPE_TARGET_R
  function runDemo(tMod, dtS) {
    var next = demoCycle + 1;
    if (next < DEMO_STARTS.length && tMod >= DEMO_STARTS[next]) {
      demoCycle = next;
      var p = DEMO_POINTS[demoCycle];
      demoPress = { x: p.x, y: p.y, r: 4 };
      opened = false;
      root.classList.remove('is-open');
      paintFoil(); // 이전 사이클에서 열렸던 은박을 다시 덮는다
    }
    if (demoPress && !opened) {
      demoPress.r += (WIPE_TARGET_R - demoPress.r) * Math.min(1, WIPE_LERP_RATE * dtS);
      press = demoPress;
      wipe();
      if (clearedRatio() > .82) openCard();
    }
  }

05두 손가락이 각자 지우는 다중 터치 긁기

두 손가락이 각자 지우는 다중 터치 긁기다. pointers가 pointerId별로 마지막 좌표를 따로 저장해 실제 터치 두 개가 동시에 닿아도 각자의 궤적을 scratchLine으로 잇고, 시연 모드에서는 advance가 두 시연 경로를 따로 재생해 같은 동작을 보여준다. 친구와 함께 긁는 이벤트 카드에 쓴다.

두 손가락이 각자 지우는 다중 터치 긁기
새 창에서 열기

05 · 두 손가락이 각자 지우는 다중 터치 긁기 — 측정 누적면적 17.315% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointersscratchLineadvance
  zone.addEventListener('pointerdown', function (e) {
    if (opened) return;
    root.classList.remove('is-demo');
    var p = pos(e);
    pointers.set(e.pointerId, p);
    scratchAt(p.x, p.y);
    if (zone.setPointerCapture) zone.setPointerCapture(e.pointerId);
  });
  zone.addEventListener('pointermove', function (e) {
    if (opened || !pointers.has(e.pointerId)) return;
    var prev = pointers.get(e.pointerId);
    var p = pos(e);
    scratchLine(prev.x, prev.y, p.x, p.y);
    pointers.set(e.pointerId, p);
    if (clearedRatio() > .82) openCard();
  });
  function release(e) { pointers.delete(e.pointerId); }
  window.addEventListener('pointerup', release);
  window.addEventListener('pointercancel', release);

06긁은 비율에 맞춰 채워지는 진행 링

긁은 비율에 맞춰 채워지는 진행 링이다. getImageData로 비율을 다시 재는 연산이 매 프레임 돌면 렌더가 밀리는 게 실측돼, 실제로 긁은 틱이거나 3틱에 한 번만 clearedPixelRatio를 불러 drawRing으로 링을 채우고, RING_SYNC_THRESHOLD_PCT(92%) 자동 오픈은 시연 중엔 미뤄 뒀다가 진짜 긁을 때만 켠다. 긁기 진행률을 보여주는 게임형 쿠폰에 쓴다.

긁은 비율에 맞춰 채워지는 진행 링
새 창에서 열기

06 · 긁은 비율에 맞춰 채워지는 진행 링 — 측정 누적면적 24.568% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RING_SYNC_THRESHOLD_PCTdrawRingclearedPixelRatio
  function tick(ts) {
    if (lastTs === null) lastTs = ts;
    var dt = Math.min(ts - lastTs, 48);
    lastTs = ts;
    var scratched = false;
    if (!reduce && root.classList.contains('is-demo')) {
      elapsed += dt;
      var tMod = elapsed % DEMO_LOOP_MS;
      if (tMod < dt) resetDemo();
      scratched = runDemo(tMod);
    }
    // getImageData 전체 스캔은 무겁다 — 매 rAF 틱(약 60fps)마다 부르면 헤드리스 렌더 파이프라인이
    // 밀려 일정 시점 뒤 rAF 자체가 멎는 현상이 실측됐다(23 프레임 중 17 부터 완전 정지).
    // 실제로 긁은 틱과 3틱에 한 번(약 50ms, 출력 12fps 보다 촘촘)만 다시 재서 부하를 줄인다.
    ringTick += 1;
    if (scratched || ringTick % 3 === 0) {
      var ratio = clearedPixelRatio();
      drawRing(ratio);
      // 시연 중엔 링·카드가 캡처 구간 내내 계속 움직여야 하므로 자동 오픈으로 멈추지 않는다 —
      // 실제 사용자가 긁어서(is-demo 해제) 임계를 넘겼을 때만 카드가 열린다.
      if (!root.classList.contains('is-demo') && ratio * 100 >= RING_SYNC_THRESHOLD_PCT) openCard();
    }
    requestAnimationFrame(tick);
  }

07완전히 벗겨지면 터지는 컨페티

완전히 벗겨지면 터지는 컨페티다. 면적이 100%에 가까워지면 burstConfetti가 조각을 흩뿌리고, stepConfetti가 매 프레임 중력과 CONFETTI_BOUNCE 반발 속도로 튀어 오르는 궤적을 그린다. 선물이 확정되는 순간의 축하 연출에 쓴다.

완전히 벗겨지면 터지는 컨페티
새 창에서 열기

07 · 완전히 벗겨지면 터지는 컨페티 — 측정 누적면적 14.252% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

burstConfettistepConfettiCONFETTI_BOUNCE
  function stepConfetti(dtMs) {
    var dtS = dtMs / 1000;
    for (var i = 0; i < confetti.length; i += 1) {
      var c = confetti[i];
      c.vy += CONFETTI_GRAVITY * dtS;
      c.x += c.vx * dtS;
      c.y += c.vy * dtS;
      c.rot += c.angVel * dtS;
      if (c.y > FH - 6 && c.vy > 0 && c.bounces < 3) {
        c.y = FH - 6;
        c.vy *= -CONFETTI_BOUNCE;
        c.vx *= 0.7;
        c.bounces += 1;
      }
    }
  }

08방향키로도 같은 결과를 여는 대체 경로

방향키로도 같은 결과를 여는 대체 경로다. 화살표 키가 KEY_STEP만큼 cursor 좌표를 옮기고, Enter나 Space를 누르면 같은 scratchAt 픽셀 계산 경로를 그대로 밟아 실제로 긁었을 때와 똑같이 비율을 재고 카드를 연다. 키보드 전용 접근성 경로로 쓴다.

방향키로도 같은 결과를 여는 대체 경로
새 창에서 열기

08 · 방향키로도 같은 결과를 여는 대체 경로 — 측정 누적면적 4.137% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

placeCursorKEY_STEPcursor
  zone.addEventListener('keydown', function (e) {
    if (opened) return;
    var moved = false;
    if (e.key === 'ArrowUp') { cursor.y = Math.max(6, cursor.y - KEY_STEP); moved = true; }
    else if (e.key === 'ArrowDown') { cursor.y = Math.min(FH - 6, cursor.y + KEY_STEP); moved = true; }
    else if (e.key === 'ArrowLeft') { cursor.x = Math.max(6, cursor.x - KEY_STEP); moved = true; }
    else if (e.key === 'ArrowRight') { cursor.x = Math.min(FW - 6, cursor.x + KEY_STEP); moved = true; }
    else if (e.key === 'Enter' || e.key === ' ') {
      root.classList.remove('is-demo');
      scratchAt(cursor.x, cursor.y);
      var ratio = clearedRatio();
      live.textContent = Math.round(ratio * 100) + '% 긁었어요';
      if (ratio > .82) openCard();
      e.preventDefault();
      return;
    } else { return; }
    root.classList.remove('is-demo');
    placeCursor();
    e.preventDefault();
  });

09여러 장을 한 화면에 배열한 히어로 장면

여러 장을 한 화면에 배열한 히어로 장면이다. makeCard가 카드마다 독립된 캔버스와 상태를 만들어 01의 픽셀 계산과 03의 조각 물리, 07의 컨페티를 한 장면 안에서 동시에 돌리고, stepParticles가 카드별 조각을 매 프레임 갱신하며 HERO_REVEAL_THRESHOLD_PCT를 넘긴 카드부터 차례로 연다. 랜딩 페이지 상단 프로모션 배너에 쓴다.

여러 장을 한 화면에 배열한 히어로 장면
새 창에서 열기

09 · 여러 장을 한 화면에 배열한 히어로 장면 — 측정 누적면적 15.311% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

makeCardHERO_REVEAL_THRESHOLD_PCTstepParticles
    function scratchAt(x, y) {
      ctx.globalCompositeOperation = 'destination-out';
      ctx.beginPath();
      ctx.arc(x, y, SCRATCH_BRUSH_R, 0, Math.PI * 2);
      ctx.fill();
      spawnFlake(x, y);
    }

    function openCard() {
      if (opened) return;
      opened = true;
      ctx.globalCompositeOperation = 'destination-out';
      ctx.fillRect(0, 0, FW, FH);
      zone.classList.add('is-open');
      if (!burst) { burst = true; burstConfetti(); }
      live.textContent = PRIZE_TEXT[idx];
    }
    function checkThreshold() {
      var ratio = clearedRatio();
      if (ratio * 100 >= HERO_REVEAL_THRESHOLD_PCT) openCard();
    }

스크래치 카드 아홉 항목을 24장 캡처로 재 본 값

각 항목을 23개 프레임 델타로 견줘 잰 값은 아래와 같다.

항목 누적면적 평균 강도 움직인 프레임
01 긁은 면적을 픽셀로 재 자동으로 벗겨지는 카드 12.1% 40.4 15/23
02 매번 다른 무늬로 그려지는 은박 질감 28.6% 37.2 17/23
03 중력을 받아 튕겨 나가는 은박 조각 12.3% 82.1 21/23
04 중심에서 원형으로 번지는 리빌 반경 23.4% 69.0 23/23
05 두 손가락이 각자 지우는 다중 터치 긁기 17.3% 121.7 10/23
06 긁은 비율에 맞춰 채워지는 진행 링 24.6% 133.3 19/23
07 완전히 벗겨지면 터지는 컨페티 14.3% 136.4 16/23
08 방향키로도 같은 결과를 여는 대체 경로 4.1% 84.9 15/23
09 여러 장을 한 화면에 배열한 히어로 장면 15.3% 107.0 17/23

값이 리빌 판정을 바꾸는 곳 — 벗겨짐 임계값·조각 중력 가속도·컨페티 개수

세 상수가 리빌 방식을 가른다. SCRATCH_REVEAL_THRESHOLD_PCT(기본 50)는 01 항목에서 지운 면적이 몇 %를 넘으면 나머지 은박을 자동으로 벗길지를, FLAKE_GRAVITY_PX_S2(기본 400)는 03 항목에서 은박 조각이 얼마나 빨리 떨어져 나가는지를 정하고(09 항목은 이름 충돌을 피해 따로 둔 HERO_FLAKE_GRAVITY_PX_S2(기본 500)를 쓴다), CONFETTI_COUNT(기본 24)는 07 항목에서 카드가 열릴 때 한 번에 몇 조각이 튀어 오르는지를 정한다. 06 항목의 RING_SYNC_THRESHOLD_PCT(92)는 이 셋과 달리 진행 링이 자동으로 카드를 여는 시점만 따로 정해, 값 하나를 올려도 링이 채워지는 속도 자체는 바뀌지 않는다.

어디서 깨지는가 — 동작 줄임 설정·빠른 연속 긁기·저사양 기기의 프레임 저하

아홉 항목 모두 matchMedia('(prefers-reduced-motion: reduce)')로 동작 줄임 설정을 한 번씩 읽어 시연 반복을 끈다. 켜져 있으면 자동으로 도는 is-demo 루프를 지우고 정지 장면으로 멈추지만, 실제 포인터 이벤트 리스너는 이 값과 무관하게 그대로 등록돼 있어 사용자가 직접 긁는 건 언제나 된다. 03·09 항목은 여기서 한 걸음 더 나가 조각·컨페티 물리 계산 자체를 건너뛰어, 동작 줄임이 켜진 화면에서는 튕겨 나가는 조각도 함께 멈춘다.

빠른 연속 긁기에서는 06 항목이 눈에 띄게 다르다. getImageData로 비율을 다시 재는 연산을 매 프레임 돌렸더니 헤드리스 렌더가 밀려 23 프레임 캡처 중 17번째부터 멈추는 현상이 실측돼, 지금은 3틱(약 50ms)에 한 번만 다시 재도록 묶어 뒀다. 그 대가로 손을 빠르게 움직이며 연속으로 긁으면 진행 링이 실제로 지워진 픽셀보다 한두 프레임 늦게 채워지는 지연이 생긴다.

저사양 기기에서는 카드 수와 조각 수가 그대로 프레임 저하로 이어진다. 03 항목은 긁을 때마다 spawnFlake가 새 조각을 만들고 오래된 조각만 FLAKE_LIFE_MS가 지나야 지워지므로 짧게 여러 번 긁으면 순간적으로 화면에 남은 조각 수가 늘어나고, 09 항목은 카드마다 독립된 stepParticles 루프를 돌리므로 화면에 놓인 카드 수만큼 그 부담이 곱해진다. 이 스로틀 값과 아홉 항목의 판정 로직 전체는 압축 파일 하나에 들어 있고, 27qe6aea 로 열어 확인할 수 있다.

변형 세 가지와 접근성

이름 값 느낌
벗겨짐 임계값 조정 01 SCRATCH_REVEAL_THRESHOLD_PCT 50 → 80 값을 낮추면 조금만 긁어도 나머지가 금방 자동으로 벗겨지고, 값을 높이면 훨씬 많이 긁어야만 자동 벗김이 일어난다.
조각 중력 가속도 조정 03 FLAKE_GRAVITY_PX_S2 400 → 1200 값을 줄이면 조각이 천천히 흩날리며 오래 떠 있고, 값을 늘리면 조각이 빠르게 뚝 떨어져 순식간에 사라진다.
컨페티 개수 조정 07 CONFETTI_COUNT 24 → 60 값을 줄이면 컨페티가 몇 조각만 성기게 튀고, 값을 늘리면 화면을 가득 채울 만큼 촘촘히 터진다.

접근성 경로는 08 항목에만 있다. 아홉 파일 모두 prefers-reduced-motion을 한 번씩 읽어 시연 반복을 끄지만, 방향키와 Enter·Space로 커서를 옮기고 긁는 키보드 경로는 08 항목 하나뿐이고 나머지 여덟 항목은 포인터로만 긁을 수 있다. 08 항목의 키보드 경로는 scratchAt을 그대로 불러 실제로 긁었을 때와 같은 픽셀 계산을 거치므로, 마우스 없이도 스크래치 카드 이벤트를 끝까지 진행할 수 있다. prefers-reduced-motion 값이 켜져 있어도 08 항목의 키 입력은 그 값과 무관하게 그대로 동작한다.

스크래치 카드와 쿠폰 디자인은 뭐가 다른가?

다르지 않다 — 스크래치 카드는 쿠폰 디자인 9종 — 도장부터 돌림판까지 복붙 코드에 속한 인터랙션 하나이고, 은박을 지우는 CanvasRenderingContext2D 계산과 destination-out 합성 모드만 다를 뿐 카드를 열었을 때 보상을 보여주는 흐름은 같다. 다만 스크래치 카드 디자인은 긁는 동작 자체가 콘텐츠라, 값이 바뀔 때마다 화면을 다시 그리는 requestAnimationFrame 루프가 카드가 열릴 때까지 계속 돈다는 점에서 정적인 도장·돌림판과 다르다. 열림 뒤의 반응 연출은 마이크로 인터랙션 사이트 UI 패턴 9종 — 토스트부터 스크롤 스파이까지의 토스트·배지 패턴과 같은 축으로 이어진다.

스크래치 카드 리빌 아홉 가지에 대해 자주 나오는 질문 셋

아홉 항목 모두 마우스로도 스크래치 카드 긁기를 테스트할 수 있나요?

된다. 아홉 항목 모두 포인터 이벤트 하나로 마우스·터치·펜 입력을 함께 받고, 05 항목의 pointers 맵도 마우스처럼 입력이 하나뿐이면 항목 하나만 채운 채 똑같이 동작한다.

은박에 실제 이미지나 로고를 쓰지 않고도 질감이 나나요?

된다. 02 항목처럼 valueNoise2D 값-노이즈로 픽셀 밝기를 계산해 은박 표면을 그리기 때문에, 외부 이미지 파일 없이도 시드값만 바꾸면 매번 다른 무늬가 나온다.

카드를 한 번 다 긁고 나면 다시 긁을 수 있나요?

없다. 아홉 항목 모두 openCard가 opened 플래그를 세운 뒤엔 if (opened) return으로 이후 입력을 무시해, 한 번 연 카드는 새로고침 전까지 다시 닫히지 않는다.

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

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