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

css 버튼 입체감 9가지 — 클릭 효과까지 커스텀 코드

css 버튼 입체감을 만드는 계산은 늘 같은 질문에서 출발합니다. 커서가 버튼 위 어디쯤 있는지를 각도 값 하나로 바꾸면 평면이 어떻게 입체로 보일까?

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

버튼 아홉 개, 값 하나가 자리를 바꿔가며 만드는 입체감

아홉 개 모두 출발점은 같습니다. getBoundingClientRect() 로 버튼 안에서 커서 좌표를 -1~1 범위로 정규화하고, 그 값을 perspective·transform-style: preserve-3d 가 걸린 장면 위에서 rotateX·rotateY 로 바꿔 씁니다. 다른 점은 같은 정규화 좌표 값을 어디에 한 번 더 흘려보내느냐입니다 — 01 은 광택 위치로, 03 은 그림자 층 수로, 08 은 테두리 번짐 반경으로 흘려보냅니다.

포인터가 없을 때는 아홉 개 전부 Math.random 없이 고정 시드 사인 곡선으로 좌표를 합성해 자동 시연을 돌립니다. 실 방문자의 포인터가 잡히는 순간 hasPointer 플래그가 켜지면서 합성 좌표는 실제 좌표로 조용히 넘어갑니다.

01커서 좌표 값이 기울기 각도로 바뀌는 버튼

정규화된 커서 좌표를 MAX_DEG 만큼 곱해 rotateX·rotateY 로 바로 씁니다. 같은 좌표 값이 광택 위치와 그림자 번짐 폭도 함께 정합니다.

커서 좌표 값이 회전시키는 버튼
새 창에서 열기

01 · 커서 좌표 값이 회전시키는 버튼 — 측정 누적면적 3.144% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MAX_DEGLERProtateX
  function render(nx, ny) {
    var rotY = nx * MAX_DEG;
    var rotX = -ny * MAX_DEG;
    btn.style.transform = 'rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
    var sx = 50 + nx * 34, sy = 50 + ny * 34;
    sheen.style.background = 'radial-gradient(circle at ' + sx.toFixed(1) + '% ' + sy.toFixed(1) + '%, rgba(255,247,230,.5), rgba(255,247,230,0) 58%)';
    var depth = Math.min(1, Math.sqrt(nx * nx + ny * ny));
    btn.style.boxShadow = '0 ' + (10 + depth * 16).toFixed(1) + 'px ' + (18 + depth * 22).toFixed(1) + 'px rgba(8,6,12,' + (0.30 + depth * 0.16).toFixed(2) + ')';
  }

  var t0 = null;
  function loop(now) {
    if (t0 === null) { t0 = now; }
    var t = (now - t0) / 1000;
    if (!hasPointer && !reduce) {
      var s = syntheticTarget(t);
      targetNX = s[0]; targetNY = s[1];
    }
    curNX += (targetNX - curNX) * LERP;
    curNY += (targetNY - curNY) * LERP;
    render(curNX, curNY);
    requestAnimationFrame(loop);
  }

css 버튼 마우스 오버 효과 중에서도 가장 단순한 이 축은, curNX·curNY 가 목표 좌표를 향해 LERP 비율만큼만 다가가는 관성 값이라 커서가 멈춘 지점과 화면 기울기 사이에 매 프레임 약간의 지연을 남깁니다.

02기울기 값이 광원 각도로 바뀌어 훑고 지나가는 버튼

정규화 좌표를 극좌표(angle·mag)로 바꿔 conic-gradient 광택 줄기의 시작 각도로 씁니다. 기울기 방향으로 바닥 그림자가 옮겨가고, 배경에는 반사광 레이어가 한 겹 더 깔립니다.

광원 각도 값이 훑고 지나가는 버튼 하이라이트
새 창에서 열기

02 · 광원 각도 값이 훑고 지나가는 버튼 하이라이트 — 측정 누적면적 12.364% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SWEEP_WIDTHLERPconic-gradient
  function render(angle, mag) {
    var rotY = Math.cos(angle * Math.PI / 180) * mag * 10;
    var rotX = Math.sin(angle * Math.PI / 180) * mag * -10;
    btn.style.transform = 'rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
    var a0 = (angle - SWEEP_WIDTH / 2).toFixed(1);
    var a1 = angle.toFixed(1);
    var a2 = (angle + SWEEP_WIDTH / 2).toFixed(1);
    // 대비·폭(요소 크기)을 키워 광택 줄기가 버튼 면 전체를 가로지르게 — SWEEP_WIDTH(각도 폭) 값 자체는 그대로
    var alpha = (0.46 + mag * 0.54).toFixed(2);
    sweep.style.background = 'conic-gradient(from ' + a0 + 'deg at 50% 50%, rgba(23,20,26,0), rgba(23,20,26,' + alpha + ') ' + (SWEEP_WIDTH / 2).toFixed(1) + 'deg, rgba(23,20,26,0) ' + SWEEP_WIDTH.toFixed(1) + 'deg)';
    sweep.style.opacity = String(0.8 + mag * 0.4);
    // 기울기에 연동된 바닥 그림자 — 버튼이 기운 방향으로 그림자가 옮겨가며 면적을 만든다
    var floorShift = Math.cos(angle * Math.PI / 180) * mag * 34;
    var floorScale = 1 + mag * 0.28;
    floor.style.transform = 'translateX(' + floorShift.toFixed(1) + 'px) scaleX(' + floorScale.toFixed(2) + ')';
    floor.style.opacity = (0.08 + mag * 0.88).toFixed(2);
    // 배경에 반사광 레이어 — % 좌표만 써서 이동시키므로 폭 넘침 위험이 없다(transform 미사용)
    var gx = (50 + Math.cos(angle * Math.PI / 180) * mag * 38).toFixed(0);
    var gy = (50 + Math.sin(angle * Math.PI / 180) * mag * 38).toFixed(0);
    glow.style.background = 'radial-gradient(circle at ' + gx + '% ' + gy + '%, rgba(255,255,255,' + (0.18 + mag * 0.5).toFixed(2) + '), rgba(255,255,255,0) 60%)';
  }

누적면적이 12.4% 로 아홉 개 중 두 번째로 높은 이유는, 광택 줄기·바닥 그림자·반사광 레이어 세 겹이 같은 angle·mag 값 하나로 동시에 움직이기 때문입니다.

03기울기 값이 그림자 층 세 겹으로 쌓이는 버튼

정규화 좌표의 반대 방향으로 for 반복문이 그림자 세 겹을 쌓습니다. 층마다 오프셋·번짐·투명도가 조금씩 벌어져 두께감을 만듭니다.

기울기 값이 쌓는 그림자 층 버튼
새 창에서 열기

03 · 기울기 값이 쌓는 그림자 층 버튼 — 측정 누적면적 6.126% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MAX_DEGLERPboxShadow
  function render(nx, ny) {
    var rotY = nx * MAX_DEG;
    var rotX = -ny * MAX_DEG;
    btn.style.transform = 'rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
    var ox = -nx, oy = -ny;
    var layers = [];
    for (var i = 1; i <= 3; i++) {
      var d = i * 7;
      var blur = i * 9;
      var alpha = (0.22 - i * 0.03).toFixed(2);
      layers.push((ox * d).toFixed(1) + 'px ' + (oy * d + i * 3).toFixed(1) + 'px ' + blur + 'px rgba(23,20,26,' + alpha + ')');
    }
    btn.style.boxShadow = layers.join(', ');
  }

층마다 alpha 가 0.22 - i * 0.03 으로 옅어지므로, 가장 안쪽 층이 가장 짙고 바깥으로 갈수록 흐려지는 순서가 저절로 지켜집니다.

04깊이 값이 아이콘과 글자를 따로 밀어내는 버튼

기울기는 8도로 작게 두고, 같은 정규화 좌표를 ICON_DEPTH·LABEL_DEPTH 두 상수에 각각 곱해 아이콘과 글자를 서로 다른 거리로 translate3d 시킵니다.

깊이 값이 아이콘만 따로 밀어내는 버튼
새 창에서 열기

04 · 깊이 값이 아이콘만 따로 밀어내는 버튼 — 측정 누적면적 2.401% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ICON_DEPTHLABEL_DEPTHtranslate3d
  function syntheticTarget(t) { return [Math.sin(t * 1.05), Math.sin(t * 0.7 + 2.1)]; }

  function render(nx, ny) {
    btn.style.transform = 'rotateX(' + (-ny * 8).toFixed(2) + 'deg) rotateY(' + (nx * 8).toFixed(2) + 'deg)';
    icon.style.transform = 'translate3d(' + (nx * ICON_DEPTH).toFixed(1) + 'px, ' + (ny * ICON_DEPTH).toFixed(1) + 'px, 0)';
    label.style.transform = 'translate3d(' + (nx * LABEL_DEPTH).toFixed(1) + 'px, ' + (ny * LABEL_DEPTH).toFixed(1) + 'px, 0)';
  }

  var t0 = null;
  function loop(now) {
    if (t0 === null) t0 = now;
    var t = (now - t0) / 1000;
    if (!hasPointer && !reduce) {
      var s = syntheticTarget(t);
      targetNX = s[0]; targetNY = s[1];
    }
    curNX += (targetNX - curNX) * LERP;
    curNY += (targetNY - curNY) * LERP;
    render(curNX, curNY);
    requestAnimationFrame(loop);
  }

ICON_DEPTH 가 LABEL_DEPTH 보다 커야 아이콘이 글자보다 더 크게 밀리면서 두 층이 어긋나 보입니다. 값이 같으면 아이콘과 글자가 한 판처럼 붙어 움직여 패럴랙스가 사라집니다.

05기울기 각도 값이 테두리 베벨을 밝히는 버튼

정규화 좌표를 atan2 로 각도(ang)와 크기(mag)로 나눠, 각도는 conic-gradient 베벨의 시작점으로, 크기는 폭·투명도·측면 두께로 씁니다.

각도 값이 테두리를 밝히는 버튼
새 창에서 열기

05 · 각도 값이 테두리를 밝히는 버튼 — 측정 누적면적 13.927% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MAX_DEGconic-gradientmask-composite
  function render(nx, ny) {
    var rotY = nx * MAX_DEG;
    var rotX = -ny * MAX_DEG;
    btn.style.transform = 'rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
    var mag = clamp(Math.sqrt(nx * nx + ny * ny), 0, 1);
    var ang = Math.atan2(ny, nx) * 180 / Math.PI + 90;
    var width = 70 + mag * 110;
    var alpha = (0.38 + mag * 0.6).toFixed(2);
    bevel.style.background = 'conic-gradient(from ' + ang.toFixed(1) + 'deg, rgba(91,140,248,0), rgba(91,140,248,' + alpha + ') ' + (width / 2).toFixed(1) + 'deg, rgba(91,140,248,0) ' + width.toFixed(1) + 'deg)';
    // 바닥 그림자 — 기운 방향으로 옮겨간다
    var floorShift = nx * 46;
    floor.style.transform = 'translateX(' + floorShift.toFixed(1) + 'px) scale(' + (1 + mag * 0.4).toFixed(2) + ')';
    floor.style.opacity = (0.14 + mag * 0.7).toFixed(2);
    // 측면 두께 — 기운 크기만큼 버튼 아래 「옆면」이 길어져 3D 두께로 읽힌다(MAX_DEG 불변, 순수 표현 한 겹)
    side.style.transform = 'scaleY(' + (0.4 + mag * 2.6).toFixed(2) + ')';
    side.style.opacity = (0.25 + mag * 0.7).toFixed(2);
  }

측면 두께 층의 주석에 적힌 대로, side 요소는 MAX_DEG 자체를 건드리지 않고 scaleY 만 늘립니다. 회전각은 그대로 두고 표현 한 겹만 얹었다는 뜻입니다.

06눌림 깊이 값이 스프링 감쇠로 튕겨 돌아오는 버튼

기울기는 관성 lerp 그대로 두고, z축에는 별도로 스프링 물리를 얹었습니다. 실제 누름은 pointerdown·pointerup·keyup 세 이벤트로, 포인터가 없을 때는 합성 홀드로 판정합니다.

눌림 깊이 값이 스프링으로 되돌아오는 버튼
새 창에서 열기

06 · 눌림 깊이 값이 스프링으로 되돌아오는 버튼 — 측정 누적면적 3.7% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PRESS_DEPTH_CAPDAMP_BASESTIFFNESS
    if (t0 === null) t0 = now;
    var t = (now - t0) / 1000;
    var heldSynthetic = false;
    if (!hasPointer && !reduce) {
      var s = syntheticTarget(t);
      targetNX = s[0]; targetNY = s[1];
      heldSynthetic = syntheticHold(t);
    }
    var held = pressedReal || heldSynthetic;
    if (held && !wasHeld) { pressStartNow = now; }
    if (!held && wasHeld) {
      var heldMs = now - pressStartNow;
      var depth = clamp(heldMs / PRESS_DEPTH_CAP, 0, 1);
      damping = DAMP_BASE + depth * DAMP_DEPTH_RANGE;
    }
    wasHeld = held;
    aimZ = held ? -14 : 0;

    curNX += (targetNX - curNX) * LERP;
    curNY += (targetNY - curNY) * LERP;
    velZ = (velZ + (aimZ - curZ) * STIFFNESS) * damping;
    curZ += velZ;
    render(curNX, curNY, curZ);
    requestAnimationFrame(loop);

css 버튼 눌림 효과를 스프링으로 표현할 때 핵심은 damping 값으로, 오래 누를수록 heldMs 가 커지고 그 값이 DAMP_BASE 에 더해져 감쇠가 커지므로 짧게 누르면 통통 튕기고 길게 누르면 무겁게 가라앉습니다.

07기울기 값마다 배경 격자가 어긋나 밀리는 버튼

정규화 좌표를 FAR_DEPTH·NEAR_DEPTH 두 값에 각각 곱해 두 겹의 배경 격자를 서로 반대 방향·다른 폭으로 backgroundPosition 이동시킵니다.

기울기 값마다 어긋나는 격자무늬 버튼
새 창에서 열기

07 · 기울기 값마다 어긋나는 격자무늬 버튼 — 측정 누적면적 4.097% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FAR_DEPTHNEAR_DEPTHbackgroundPosition
  function render(nx, ny) {
    btn.style.transform = 'rotateX(' + (-ny * MAX_DEG).toFixed(2) + 'deg) rotateY(' + (nx * MAX_DEG).toFixed(2) + 'deg)';
    gridFar.style.backgroundPosition = (nx * FAR_DEPTH).toFixed(1) + 'px ' + (ny * FAR_DEPTH).toFixed(1) + 'px';
    gridNear.style.backgroundPosition = (-nx * NEAR_DEPTH).toFixed(1) + 'px ' + (-ny * NEAR_DEPTH).toFixed(1) + 'px';
  }

  var t0 = null;
  function loop(now) {
    if (t0 === null) t0 = now;
    var t = (now - t0) / 1000;
    if (!hasPointer && !reduce) {
      var s = syntheticTarget(t);
      targetNX = s[0]; targetNY = s[1];
    }
    curNX += (targetNX - curNX) * LERP;
    curNY += (targetNY - curNY) * LERP;
    render(curNX, curNY);
    requestAnimationFrame(loop);
  }

gridNear 는 부호를 반대로 뒤집어 이동합니다. 먼 격자와 가까운 격자가 서로 엇갈려 밀리는 그 차이만으로 깊이가 읽힙니다.

08기울기 크기 값이 테두리 광의 번짐 반경을 정하는 버튼

회전은 04·07 과 같은 단순 매핑이고, 정규화 좌표의 크기(mag)만 따로 뽑아 box-shadow 의 번짐 반경(blur)·퍼짐(spread)·투명도에 씁니다.

기울기 크기 값이 세기를 정하는 테두리 광
새 창에서 열기

08 · 기울기 크기 값이 세기를 정하는 테두리 광 — 측정 누적면적 10.337% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BLUR_BASEBLUR_RANGEmag
  function render(nx, ny) {
    btn.style.transform = 'rotateX(' + (-ny * MAX_DEG).toFixed(2) + 'deg) rotateY(' + (nx * MAX_DEG).toFixed(2) + 'deg)';
    var mag = clamp(Math.sqrt(nx * nx + ny * ny), 0, 1);
    var blur = BLUR_BASE + mag * BLUR_RANGE;
    var spread = 1 + mag * 5;
    var alpha = (0.28 + mag * 0.5).toFixed(2);
    btn.style.boxShadow = '0 0 ' + blur.toFixed(1) + 'px ' + spread.toFixed(1) + 'px rgba(91,140,248,' + alpha + '), 0 12px 20px rgba(23,20,26,.28)';
  }

  var t0 = null;
  function loop(now) {
    if (t0 === null) t0 = now;
    var t = (now - t0) / 1000;
    if (!hasPointer && !reduce) {
      var s = syntheticTarget(t);
      targetNX = s[0]; targetNY = s[1];
    }
    curNX += (targetNX - curNX) * LERP;
    curNY += (targetNY - curNY) * LERP;
    render(curNX, curNY);
    requestAnimationFrame(loop);
  }

blur 값이 BLUR_BASE + mag * BLUR_RANGE 하나뿐이라, 기울기가 클수록 빛이 넓게 번지고 정면을 보면 광이 옅게 맺히는 두 상태가 같은 식 하나로 이어집니다.

09포인터가 없으면 값이 사인 곡선으로 스스로 흔드는 버튼

idleDrift(t) 는 서로 다른 주기의 사인파 두 개를 더해 불규칙하게 보이는 좌표를 고정 시드로 만듭니다. 렌더 함수는 같은 좌표로 들림·그림자·하이라이트 세 층을 짝지어 움직입니다.

포인터가 없을 때 값이 스스로 흔드는 버튼
새 창에서 열기

09 · 포인터가 없을 때 값이 스스로 흔드는 버튼 — 측정 누적면적 6.923% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MAX_DEGLERPidleDrift
  function idleDrift(t) {
    var x = 0.85 * Math.sin(t * 0.55) + 0.55 * Math.sin(t * 1.31 + 0.8);
    var y = 0.76 * Math.sin(t * 0.41 + 1.3) + 0.48 * Math.sin(t * 0.97 + 2.4);
    return [clamp(x, -1, 1), clamp(y, -1, 1)];
  }

  function render(nx, ny) {
    var rotY = nx * MAX_DEG;
    var rotX = -ny * MAX_DEG;
    var mag = Math.min(1, Math.sqrt(nx * nx + ny * ny));
    var lift = 1 - mag * 0.4;
    btn.style.transform = 'translateY(' + ((1 - lift) * -6).toFixed(2) + 'px) rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
    var blur = 16 + Math.abs(nx) * 10 + Math.abs(ny) * 10;
    btn.style.boxShadow = '0 ' + (10 * lift).toFixed(1) + 'px ' + blur.toFixed(1) + 'px rgba(8,6,12,.34)';
    // 바닥 그림자 — 기운 반대쪽으로 떨어져 나가며 면적을 만든다
    floor.style.transform = 'translate(' + (nx * 38).toFixed(1) + 'px, ' + (ny * 16).toFixed(1) + 'px) scale(' + (1 + mag * 0.32).toFixed(2) + ')';
    floor.style.opacity = (0.16 + mag * 0.55).toFixed(2);
    // 하이라이트 — 기운 반대쪽(들린 쪽)에서 밝아져 그림자와 짝으로 움직인다
    var gx = (50 - nx * 32).toFixed(0);
    var gy = (50 - ny * 32).toFixed(0);
    glow.style.background = 'radial-gradient(circle at ' + gx + '% ' + gy + '%, rgba(255,255,255,' + (0.16 + mag * 0.42).toFixed(2) + '), rgba(255,255,255,0) 62%)';
  }

바닥 그림자와 하이라이트가 nx·ny 부호를 서로 반대로 받는 점이 이 항목의 한 겹으로, 버튼이 기운 쪽은 그림자가 짙어지고 반대로 들린 쪽은 하이라이트가 밝아지는 짝을 이룹니다.

이 아홉 버튼, 실무 화면 어디에 붙일까?

카드형 CTA 버튼에는 01·02 처럼 좌표 값을 바로 각도로 쓰는 쪽이 잘 맞습니다. 계산이 단순해 목록 안에 여러 개를 나란히 둬도 값 하나가 서로 간섭하지 않고, css 버튼 클릭 효과까지 이어지는 click 핸들러 하나만 얹으면 마무리됩니다.

결제·폼 제출처럼 사용자가 버튼을 눌렀다는 사실 자체가 중요한 자리에는 06 의 스프링 감쇠가 맞습니다. 짧게 누르면 가볍게, 오래 누르면 무겁게 되돌아오는 차이가 실수로 스친 클릭과 의도한 누름을 시각적으로 갈라 줍니다.

다크 톤 랜딩의 히어로 버튼처럼 정지 화면에서도 눈길을 끌어야 하는 자리에는 09 가 맞습니다. 방문자가 아직 커서를 움직이지 않은 순간에도 사인 곡선 값이 스스로 들림·그림자·하이라이트를 흔들어 버튼이 죽어 있지 않다는 신호를 보냅니다.

세 가지로 바뀐 값 — 기울기·감쇠·번짐 폭

같은 계산에 값 하나만 바꿔도 체감은 크게 달라집니다. 01 은 MAX_DEG 를 14 에서 20 으로 올려 회전 폭 자체를 키운 쪽이고, 06 은 DAMP_BASE 를 0.60 에서 0.40 으로 낮춰 감쇠를 줄인 쪽이고, 08 은 BLUR_RANGE 를 34 에서 50 으로 넓혀 광 번짐의 최대치를 늘린 쪽입니다.

항목 값 방향
01 커서 좌표 값이 기울기 각도로 바뀌는 버튼 MAX_DEG 14 → 20 회전 폭이 커져 입체감이 더 또렷해집니다
06 눌림 깊이 값이 스프링 감쇠로 튕겨 돌아오는 버튼 DAMP_BASE 0.60 → 0.40 감쇠가 줄어 되돌아올 때 더 크게 튕깁니다
08 기울기 크기 값이 테두리 광의 번짐 반경을 정하는 버튼 BLUR_RANGE 34 → 50 기울일 때 광이 훨씬 넓게 번집니다

아홉 개 전체 측정값

아홉 개를 다 재도 누적면적은 2~14% 대에 머무릅니다. 버튼 편이라 화면에서 실제로 바뀌는 부분이 버튼 자체와 그림자·하이라이트뿐이라, 배경 전체가 움직이는 장면형 데모보다 면적이 작게 나오는 게 정상입니다 — 컴포넌트 편에서 보는 건 면적이 아니라 값 하나가 얼마나 여러 겹을 겸하는지입니다.

항목 누적면적 강도 평균 움직인 프레임
01 커서 회전 3.1% 34.0 23/23
02 하이라이트 스윕 12.4% 22.3 23/23
03 그림자 층 6.1% 30.2 22/23
04 아이콘 시차 2.4% 63.2 23/23
05 테두리 베벨 13.9% 20.7 23/23
06 스프링 감쇠 3.7% 39.1 23/23
07 격자 시차 4.1% 22.4 23/23
08 림 글로우 10.3% 25.8 23/23
09 아이들 드리프트 6.9% 21.7 23/23

클릭 직후 260밀리초 — 기울기가 깨지는 함정

01 의 click 핸들러를 그대로 옮겨 붙이면 실무에서 자주 놓치는 지점이 하나 생깁니다. 클릭 순간 hasPointer 를 켜고 목표 좌표를 고정값으로 바꾼 뒤, clearTimeout(clickTimer) 로 이전 타이머를 지우고 260ms 뒤 다시 hasPointer 를 끄도록 setTimeout 을 걸어 둡니다. 이 리셋은 커서가 버튼 위에 그대로 머물러 있어도 예외 없이 실행됩니다. 그래서 클릭한 자리에 커서를 두고 가만히 있으면 260ms 뒤 버튼이 순간적으로 평평해졌다가, 다음 pointermove 이벤트가 들어와야 다시 기울기가 붙습니다.

프레임 값 자체는 03 처럼 22/23 으로 한 번 멈추는 경우도 있는데, 이건 버그가 아니라 정규화 좌표(curNX·curNY)가 목표값에 LERP 비율만큼 충분히 가까워지면 소수점 둘째 자리 반올림값이 같아져 픽셀 상으로 변화가 없는 프레임이 한 번 섞이기 때문입니다. 값을 더 세밀하게 반올림하지 않는 한 자연스러운 결과입니다.

qvyanve8 을 풀어 실제 파일을 프로젝트에 옮겨 붙일 때는 scene·btn·sheen·floor·glow 같은 자리표시자 클래스명이 기존 마크업과 겹치지 않는지부터 확인해야 합니다.

접근성 — 동작 줄이기와 키보드로 값 대신 각도를 옮기는 법

prefers-reduced-motion 가 켜진 환경에서는 아홉 개 전부 requestAnimationFrame 루프 자체를 시작하지 않고, render() 를 값 한 번만 고정으로 호출해 살짝 기운 정지 자세만 보여줍니다. 자동 시연과 포인터 추적은 꺼지지만, 클릭·화살표 키 입력은 관성 보간 없이 그 자리에서 render() 를 한 번 더 호출해 즉시 화면에 반영됩니다.

키보드로는 버튼에 포커스를 두고 방향키를 누르면 step 값(0.24)만큼 정규화 좌표가 움직여 마우스 없이도 기울기를 체감할 수 있습니다. e.preventDefault() 로 스크롤과 겹치지 않게 막아 두었고, Enter·Space 는 click 이벤트로 이어져 마우스 클릭과 같은 반응을 냅니다. getBoundingClientRect() 로 좌표를 정규화하는 부분은 포인터 입력에만 걸려 있어 키보드 흐름에는 관여하지 않습니다.

버튼 입체감을 적용하기 전에 자주 받는 질문

css 버튼 입체감을 CSS 값만으로 못 만드나요?

:hover 의 transform: rotateX() 로 고정된 기울기는 낼 수 있지만, 커서 위치에 따라 각도가 실시간으로 바뀌는 css 버튼 마우스 오버 효과는 좌표를 읽어야 해서 JavaScript 계산이 필요합니다. perspective 와 transform-style: preserve-3d 는 CSS 쪽 값이고, 그 값을 매 프레임 무엇으로 채울지는 JS 몫입니다.

모바일 터치에서도 커서 추적이 되나요?

pointermove 는 터치에서도 발생하지만 손가락이 눌린 순간에만 좌표가 들어옵니다. 손을 떼면 pointerleave 로 목표 좌표가 0 으로 돌아가므로, 터치 환경에서는 누른 채 미세하게 움직여야 기울기 차이가 드러납니다.

왜 06 만 css 버튼 눌림 효과에 별도 이벤트가 붙었나요?

나머지 여덟 개는 기울기만 다루지만 06 은 z축 깊이까지 계산해야 해서 실제로 눌렸다 떼어지는 순간(pointerdown·pointerup)과 키보드로 누른 순간(keyup)을 따로 잡습니다. 이 값이 있어야 heldMs 로 누른 시간을 재고, 그 시간만큼 감쇠를 다르게 줄 수 있습니다.

이 계산 방식은 css 3d 큐브 캐러셀 틸트 9종 — 한 줄 복붙으로 편의 좌표-회전 매핑과 뿌리가 같고, 카드 단위로 적용하고 싶다면 CSS 카드 애니메이션 호버 효과 9종 — 한 줄 복붙으로 편을, 커서 자체를 꾸미는 쪽은 마우스 이펙트 9가지 — 커서부터 드래그까지 편을 함께 보면 좋습니다.

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

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