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

css 버튼 마우스 오버 효과 9가지 — 거리 값 하나로 갈라진다

css 버튼 마우스 오버 효과는 커서와 버튼 사이 거리 값 하나를 계산해 반응 세기를 정하는 호버 방식이다. 만약 커서가 버튼에서 아직 한 뼘 떨어져 있다면 거리 값은 크게 남아 있다 — 그 값이 줄어드는 동안 01번은 후광 반경을 키우고, 06번은 테두리선을 그어 나가며, 09번은

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

커서 거리 하나가 css 버튼 마우스 오버 효과 아홉 갈래를 가르는 지점

아래 아홉 개는 전부 같은 뼈대를 쓴다 — 커서와 버튼 사이 거리를 재고, 그 거리를 0~1 사이 근접도 값(p)으로 뒤집어, 그 p 하나로 반경·각도·색·간격 같은 서로 다른 CSS 속성 하나씩을 계산한다. :hover 가 켜짐·꺼짐 둘뿐이라면, 이 9종은 커서가 닿기 전부터 값이 연속으로 움직인다는 점이 다르다. 순서는 값이 향하는 속성이 단순한 밝기(01)에서 시작해 위치·색·모양을 거쳐 캔버스 입자(09)로 넘어가도록 잡았다.

01거리 값만큼 후광이 넓어지는 버튼

커서가 버튼 쪽으로 다가올수록 distanceToButton 이 잰 거리를 1 - 거리 / PROX_RADIUS_PX 로 뒤집은 근접도 p 하나가 후광 반경과 밝기를 함께 키운다. 랜딩 페이지에서 하나뿐인 CTA 버튼을 강조할 때 쓴다.

거리 값만큼 후광이 넓어지는 버튼
새 창에서 열기

01 · 거리 값만큼 후광이 넓어지는 버튼 — 측정 누적면적 20.583% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PROX_GLOW_RADIUS_MAX_PXdistanceToButtonsyntheticOffset
  function distanceToButton(x, y) {
    var r = btn.getBoundingClientRect();
    var cx = r.left + r.width / 2;
    var cy = r.top + r.height / 2;
    return Math.hypot(x - cx, y - cy);
  }

  function render(p) {
    var blurRadius = p * PROX_GLOW_RADIUS_MAX_PX;
    var spread = p * PROX_GLOW_RADIUS_MAX_PX * 0.3;
    var alpha = (0.18 + p * 0.55).toFixed(2);
    btn.style.boxShadow = '0 0 ' + blurRadius.toFixed(1) + 'px ' + spread.toFixed(1) + 'px rgba(255,247,230,' + alpha + ')';
    btn.style.filter = 'brightness(' + (1 + p * 0.22).toFixed(3) + ')';
    badge.textContent = '거리 값 ' + Math.round((1 - p) * 100) + '%';
  }

측정: 누적면적 20.6% · 강도 평균 52.6 · 움직인 프레임 22/23

02거리 값이 줄수록 서서히 커지는 버튼

근접도가 임계 범위에 들어오는 순간 버튼이 켜짐·꺼짐처럼 튀지 않고, springStep 이 계산하는 강성·감쇠 값 덕에 살짝 넘쳤다가 천천히 제자리를 잡으며 커진다. 카드형 버튼 목록에서 한 칸만 반응시킬 때 맞는다.

거리 값이 줄수록 서서히 커지는 버튼
새 창에서 열기

02 · 거리 값이 줄수록 서서히 커지는 버튼 — 측정 누적면적 18.788% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

springStepSTIFFNESSDAMPING
  function render(p) {
    var scale = 1 + p * SL_SCALE_MAX;
    var lift = p * SL_LIFT_MAX_PX;
    var depth = 8 + p * 18;
    btn.style.transform = 'translateY(' + (-lift).toFixed(2) + 'px) scale(' + scale.toFixed(4) + ')';
    btn.style.boxShadow = '0 ' + depth.toFixed(1) + 'px ' + (depth * 1.6).toFixed(1) + 'px rgba(23,20,26,' + (0.18 + p * 0.18).toFixed(2) + ')';
    // 계산 레이어: 스프링 오버슛(p) 을 배경 톤(그림자 뒤 글로우)에도 같은 값으로 전파한다.
    var tone = clamp(p, 0, 1.15);
    scene.style.setProperty('--tone-p', tone.toFixed(3));
    badge.textContent = '거리 값 ' + Math.round((1 - clamp(p, 0, 1)) * 100) + '%';
  }

  function springStep(target) {
    var force = (target - pos) * STIFFNESS;
    vel = (vel + force) * DAMPING;
    pos += vel;
  }

측정: 누적면적 18.8% · 강도 평균 47.4 · 움직인 프레임 18/23

03거리 값이 각도 값으로 바뀌는 기울임 버튼

커서가 버튼에 닿기도 전에, vectorToButton 이 구한 방향 벡터가 각도(rotX·rotY)로 바뀌어 커서 쪽으로 버튼이 미리 살짝 기운다. 제품 카드의 구매 버튼처럼 손이 다가오는 방향에 먼저 반응해야 하는 자리에 쓴다.

거리 값이 각도 값으로 바뀌는 기울임 버튼
새 창에서 열기

03 · 거리 값이 각도 값으로 바뀌는 기울임 버튼 — 측정 누적면적 13.244% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

vectorToButtonTA_MAX_DEGPROX_RADIUS_PX
  function render(dx, dy) {
    // 방향 벡터 → 각도(atan2) 변환 — 거리 값(가까울수록 1)을 각도 크기로 쓴다.
    var d = Math.hypot(dx, dy);
    var p = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
    var ux = d > 0.001 ? dx / d : 0;
    var uy = d > 0.001 ? dy / d : 0;
    var rotY = ux * p * TA_MAX_DEG;
    var rotX = -uy * p * TA_MAX_DEG;
    var lean = p * 6;
    btn.style.transform = 'translate(' + (ux * lean).toFixed(2) + 'px,' + (uy * lean).toFixed(2) + 'px) rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
    // 계산 레이어: 같은 기울기 각도(ux·uy·p) 를 테두리 하이라이트 위치·깊이 그림자에도 전파한다.
    var shadowX = ux * p * 16;
    var shadowY = 6 + uy * p * 12;
    var shadowBlur = 14 + p * 22;
    btn.style.boxShadow = shadowX.toFixed(1) + 'px ' + shadowY.toFixed(1) + 'px ' + shadowBlur.toFixed(1) + 'px rgba(23,20,26,' + (0.2 + p * 0.3).toFixed(2) + ')';
    btn.style.setProperty('--hl-x', (50 + ux * 38).toFixed(1) + '%');
    btn.style.setProperty('--hl-y', (50 + uy * 38).toFixed(1) + '%');
    btn.style.setProperty('--hl-o', (0.12 + p * 0.5).toFixed(2));
    var angleDeg = Math.atan2(dy, dx) * 180 / Math.PI;
    badge.textContent = '각도 값 ' + Math.round(rotY) + '°';
    void angleDeg;
  }

측정: 누적면적 13.2% · 강도 평균 52.4 · 움직인 프레임 18/23

04거리 값이 멀수록 흐려지는 주변 버튼

버튼 네 개가 나란히 있을 때 updateTargets 가 구한 거리 순위와 근접도 중 더 큰 흐림 값이 커서에서 먼 버튼일수록 흐려지게 만들어, 가장 가까운 버튼만 또렷하게 남는다. 버튼 그룹 중 하나에 주의를 집중시킬 때 쓴다.

거리 값이 멀수록 흐려지는 주변 버튼
새 창에서 열기

04 · 거리 값이 멀수록 흐려지는 주변 버튼 — 측정 누적면적 18.241% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

updateTargetscentersOfBF_BLUR_MAX_PX
  function centersOf() {
    return btns.map(function (b) {
      var r = b.getBoundingClientRect();
      return [r.left + r.width / 2, r.top + r.height / 2];
    });
  }

  function updateTargets(px, py) {
    var centers = centersOf();
    var dists = centers.map(function (c) { return Math.hypot(px - c[0], py - c[1]); });
    // 거리 정렬: 가장 가까운 버튼의 순위(rank)를 구해 순위가 낮을수록(가까울수록) 흐림을 줄인다.
    var order = dists.map(function (d, i) { return i; }).sort(function (a, b) { return dists[a] - dists[b]; });
    order.forEach(function (idx, rank) {
      var near = clamp(1 - dists[idx] / PROX_RADIUS_PX, 0, 1);
      var byRank = rank === 0 ? 0 : Math.min(1, rank / (btns.length - 1));
      var mix = Math.max(byRank, 1 - near);
      targetBlur[idx] = mix * BF_BLUR_MAX_PX;
    });
  }

측정: 누적면적 18.2% · 강도 평균 19.6 · 움직인 프레임 16/23

05거리 값이 색상 값 보간 지점이 되는 버튼

커서가 멀 때는 차분한 색이던 버튼이, lerpColor 가 채널마다 계산하는 보간 값 t=p 를 따라 가까워질수록 강조 색 쪽으로 서서히 물든다. 강조가 필요한 구독·신청 버튼의 색 전환에 쓴다.

거리 값이 색상 값 보간 지점이 되는 버튼
새 창에서 열기

05 · 거리 값이 색상 값 보간 지점이 되는 버튼 — 측정 누적면적 14.462% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

lerpColorCOLOR_FARCOLOR_NEAR
  // 두 색상 값 보간 — 채널마다 선형 보간(계산 한 겹, 속성 트윈이 아니다)
  function lerpColor(c1, c2, t) {
    return [
      Math.round(c1[0] + (c2[0] - c1[0]) * t),
      Math.round(c1[1] + (c2[1] - c1[1]) * t),
      Math.round(c1[2] + (c2[2] - c1[2]) * t)
    ];
  }

  function render(p) {
    var c = lerpColor(COLOR_FAR, COLOR_NEAR, p);
    btn.style.background = 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')';
    // 계산 레이어: 같은 보간 색을 테두리(퍼지는 링)·카드 전체 배경 톤에도 전파한다.
    var ringPx = (6 + p * 20).toFixed(1);
    var ringA = (0.14 + p * 0.26).toFixed(2);
    btn.style.boxShadow = '0 0 0 ' + ringPx + 'px rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + ringA + ')';
    scene.style.background = 'radial-gradient(circle, rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + (0.1 + p * 0.22).toFixed(2) + '), transparent 72%)';
    badge.textContent = '보간 지점 ' + Math.round(p * 100) + '%';
  }

측정: 누적면적 14.5% · 강도 평균 46.7 · 움직인 프레임 20/23

06거리 값만큼 테두리가 그어지는 버튼

커서가 다가올수록 rect.getTotalLength() 로 실측한 둘레 길이 위에서 stroke-dashoffset 이 줄어들며 테두리선이 한 바퀴를 그리며 채워진다. 배경이 비치는 아웃라인 버튼을 강조할 때 쓴다.

거리 값만큼 테두리가 그어지는 버튼
새 창에서 열기

06 · 거리 값만큼 테두리가 그어지는 버튼 — 측정 누적면적 14.921% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getTotalLengthstrokeDashoffsettotalLength
  // 실제 도형 둘레 길이(px) — 짐작값이 아니라 SVG 가 직접 잰 값이다.
  var totalLength = rect.getTotalLength();
  rect.style.strokeDasharray = String(totalLength);

  var targetP = 0;
  var curP = 0;
  var hasPointer = false;

  function distanceToButton(x, y) {
    var r = wrap.getBoundingClientRect();
    var cx = r.left + r.width / 2;
    var cy = r.top + r.height / 2;
    return Math.hypot(x - cx, y - cy);
  }

  function render(p) {
    var offset = (1 - p) * totalLength;
    rect.style.strokeDashoffset = String(offset);
    rect.style.opacity = String(0.2 + p * 0.8);
    badge.textContent = '테두리 값 ' + Math.round(p * 100) + '%';
    // 계산 레이어: 같은 진행률(p)을 링 주변 글로우·버튼 배경 하이라이트 각(그라디언트 각)에도 전파한다.
    wrap.style.setProperty('--p', p.toFixed(3));
  }

측정: 누적면적 14.9% · 강도 평균 46.5 · 움직인 프레임 21/23

07거리 값만큼 떠오르는 그림자 버튼

커서가 가까워질수록 render(p) 가 p * PROX_SHADOW_LIFT_MAX_PX 로 구한 오프셋에서 번짐(blur)까지 배수로 파생시켜, 그림자가 함께 커지며 버튼이 화면에서 들리는 것처럼 보인다. 카드 버튼에 입체감을 줄 때 쓴다.

거리 값만큼 떠오르는 그림자 버튼
새 창에서 열기

07 · 거리 값만큼 떠오르는 그림자 버튼 — 측정 누적면적 7.634% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PROX_SHADOW_LIFT_MAX_PXdistanceToButtonrender
  function distanceToButton(x, y) {
    var r = btn.getBoundingClientRect();
    var cx = r.left + r.width / 2;
    var cy = r.top + r.height / 2;
    return Math.hypot(x - cx, y - cy);
  }

  // 오프셋·번짐을 거리 함수로 — 둘 다 같은 값(p)에서 갈라져 나온다
  function render(p) {
    var offsetY = p * PROX_SHADOW_LIFT_MAX_PX;
    var blur = offsetY * 1.7;
    var lift = p * 8;
    btn.style.transform = 'translateY(' + (-lift).toFixed(2) + 'px)';
    btn.style.boxShadow = '0 ' + offsetY.toFixed(1) + 'px ' + blur.toFixed(1) + 'px rgba(23,20,26,' + (0.22 + p * 0.24).toFixed(2) + ')';
    badge.textContent = '거리 값 ' + Math.round((1 - p) * 100) + '%';
  }

측정: 누적면적 7.6% · 강도 평균 62.1 · 움직인 프레임 21/23

08거리 값이 글자 간격 값으로 벌어지는 버튼

커서가 멀 때는 라벨 글자가 좁게 뭉쳐 있다가, letter-spacing 이 벌어진 뒤 getBoundingClientRect().width 로 다시 실측한 글자 폭이 밑줄 길이에 그대로 전파돼 가까워질수록 넓게 벌어진다. 텍스트만 있는 버튼의 라벨을 강조할 때 쓴다.

거리 값이 글자 간격 값으로 벌어지는 버튼
새 창에서 열기

08 · 거리 값이 글자 간격 값으로 벌어지는 버튼 — 측정 누적면적 26.463% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LS_MAX_EMletterSpacingsyntheticOffset
  // 거리 값을 글자 사이 간격 값으로 바꾼다 — 멀 때는 0, 가까워질수록 벌어진다
  function render(p) {
    var spacing = p * LS_MAX_EM;
    label.style.letterSpacing = spacing.toFixed(3) + 'em';
    btn.style.filter = 'brightness(' + (1 + p * 0.12).toFixed(3) + ')';
    // 계산 레이어: 벌어진 실제 글자 폭(getBoundingClientRect)을 라벨 배경·밑줄 길이에 전파한다.
    var w = label.getBoundingClientRect().width;
    underline.style.width = w.toFixed(1) + 'px';
    btn.style.setProperty('--underline-o', (0.2 + p * 0.6).toFixed(2));
    btn.style.setProperty('--label-w', w.toFixed(1) + 'px');
    btn.style.setProperty('--label-o', (0.12 + p * 0.4).toFixed(2));
    // 계산 레이어: 같은 진행률(p)을 버튼 후광 그림자 반경·불투명도에도 전파한다.
    var spread = (6 + p * 26).toFixed(1);
    btn.style.boxShadow = '0 0 ' + spread + 'px ' + spread + 'px rgba(10,79,232,' + (0.16 + p * 0.42).toFixed(2) + ')';
    badge.textContent = '간격 값 ' + spacing.toFixed(2) + 'em';
  }

측정: 누적면적 26.5% · 강도 평균 37.6 · 움직인 프레임 21/23

09거리 값만큼 입자가 모여드는 버튼

커서가 가까워질수록 draw(p, t) 가 p * PROX_PARTICLE_MAX_COUNT 로 정하는 입자 개수와 반경 보간 값이 함께 늘어나 버튼 주변에 떠 있는 작은 입자의 밀도가 짙어진다. 프리미엄 느낌을 주고 싶은 CTA 버튼에 쓴다.

거리 값만큼 입자가 모여드는 버튼
새 창에서 열기

09 · 거리 값만큼 입자가 모여드는 버튼 — 측정 누적면적 11.006% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PROX_PARTICLE_MAX_COUNTdrawglobalAlpha
  function draw(p, t) {
    var w = canvas.width, h = canvas.height;
    ctx.clearRect(0, 0, w, h);
    var cx = w / 2, cy = h / 2;
    var count = p * PROX_PARTICLE_MAX_COUNT;
    var radius = (RADIUS_FAR_PX + (RADIUS_NEAR_PX - RADIUS_FAR_PX) * p) * radiusScale;
    var positions = [];
    particles.forEach(function (pt, i) {
      var visible = clamp(count - i, 0, 1);
      if (visible <= 0) { return; }
      var ang = pt.baseAngle + t * 0.5 + pt.noise * Math.sin(t * 1.3 + pt.phase) * 0.6;
      var rad = radius + pt.noise * 10 * radiusScale * Math.sin(t * 0.9 + pt.phase);
      var x = cx + Math.cos(ang) * rad;
      var y = cy + Math.sin(ang) * rad * 0.72;
      positions.push({ x: x, y: y, size: pt.size * radiusScale * 1.3, alpha: (0.4 + p * 0.6) * visible });
    });

측정: 누적면적 11.0% · 강도 평균 102.3 · 움직인 프레임 20/23

값 하나가 반경·색·밀도로 갈라지는 계산 층

아홉 개 소스에는 전부 // 계산 레이어: 라고 적힌 주석이 하나씩 있다 — 같은 근접도 값(p)을 주 속성(반경·각도·색·간격) 하나에만 쓰지 않고 그림자·글로우·배경 톤 같은 보조 속성에도 그대로 흘려보내는 지점이다. 속성마다 따로 transition: 0.3s 를 걸면 브라우저 프레임 타이밍이 미세하게 어긋나 두 속성이 따로 노는 것처럼 보이는데, 값 하나를 여러 곳에 같은 프레임에서 동시에 계산해 넣으면 그 어긋남이 사라져 한 몸으로 움직인다는 인상을 준다. 이게 이 9종이 :hover { transition: all } 한 줄로는 못 만드는 차이다.

포인터 좌표를 읽는 지점(01)은 MDN Element: mousemove event 문서가 정리해 둔 좌표 읽기와 같은 원리이고, 04번의 흐림은 MDN filter 문서에 있는 blur 함수를 그대로 쓴다.

랜딩페이지 CTA 버튼에 이 아홉 개를 실제로 붙이는 자리

전환을 유도하는 화면일수록 커서가 버튼에 닿기 전 몇 백 밀리초가 아깝다 — 랜딩 페이지 맨 아래 하나뿐인 신청 버튼이라면 01번 후광이나 07번 그림자로 존재감을 미리 키우고, 가격표처럼 버튼이 여러 개 나열된 화면이라면 04번으로 지금 커서가 어느 카드 쪽에 있는지 흐림으로 정리해 주는 편이 낫다. 텍스트 링크에 가까운 얇은 버튼(더 알아보기, 자세히 보기류)에는 08번 글자 간격이 과하지 않게 어울리고, 결제·구독처럼 한 번 더 확신을 주고 싶은 자리에는 05번 색 전환이나 09번 입자로 누르기 직전의 순간을 화려하게 만든다.

다른 css 버튼 효과처럼 클릭 순간에만 화려한 게 아니라, 커서가 다가오는 몇 백 밀리초 동안 미리 신호를 준다는 점이 이 9종이 CTA 버튼에서 유리한 지점이다. 값 하나(PROX_RADIUS_PX)만 버튼 크기에 맞춰 다시 재면 카드 목록·모달 안 버튼·고정 헤더 버튼에도 같은 계산을 그대로 옮길 수 있다.

아홉 개 버튼이 24프레임 동안 실제로 얼마나 움직였는가

Playwright 로 24프레임을 캡처해 프레임 사이 픽셀 델타(23구간)를 잰 값이다. 누적면적은 한 프레임 안에서 바뀐 화면 비율을 23구간 모두 더한 값이라 100%를 넘을 수 있다.

항목 누적면적 강도 평균 움직인 프레임
01 후광 반경 20.6% 52.6 22/23
02 스프링 확대 18.8% 47.4 18/23
03 방향 기울임 13.2% 52.4 18/23
04 순위 흐림 18.2% 19.6 16/23
05 색상 보간 14.5% 46.7 20/23
06 테두리 그리기 14.9% 46.5 21/23
07 그림자 들림 7.6% 62.1 21/23
08 글자 간격 26.5% 37.6 21/23
09 입자 밀도 11.0% 102.3 20/23

09번 입자 밀도는 누적면적이 가장 작은데도 강도 평균이 102.3 으로 가장 높다 — 캔버스에 매 프레임 새로 그려 넣는 작은 점들이라 바뀌는 면적은 좁아도 그 안에서 픽셀 값 차이(밝은 입자 vs 어두운 배경)가 크게 벌어지기 때문이다.

숫자 하나만 바꿔도 느낌이 달라지는 변형 세 가지

변형 바꾼 값 느낌
후광 반경 최대 값 조정 01 PROX_GLOW_RADIUS_MAX_PX 40 → 80 40 근처면 커서가 바짝 붙어도 후광이 작아 있는 듯 없는 듯하고, 80 으로 올리면 버튼 테두리를 넘어 배경까지 번져 눈에 확 띈다
그림자 들림 최대 값 조정 07 PROX_SHADOW_LIFT_MAX_PX 24 → 48 24 는 버튼이 바닥에서 살짝 뜬 정도지만, 48 은 그림자가 버튼과 한참 벌어져 훨씬 높은 곳에 떠 있는 듯 보인다
입자 최대 개수 값 조정 09 PROX_PARTICLE_MAX_COUNT 6 → 14 6 개는 커서를 바짝 붙여도 점이 듬성듬성해 절제된 인상이고, 14 개는 버튼 둘레가 점으로 붐벼 화려한 쪽으로 기운다

고정 px 반경을 그대로 옮기면 깨지는 지점

이 아홉 개를 그대로 복사해서 쓸 때 가장 먼저 걸리는 함정은 PROX_RADIUS_PX·BF_BLUR_MAX_PX 같은 거리 값이 전부 고정 px 라는 점이다. 데모 화면 크기(480×300)에서는 자연스럽지만, 버튼이 지금보다 훨씬 크거나 화면 폭이 좁은 실제 프로젝트에 그대로 옮기면 같은 170px 가 버튼 크기에 비해 너무 넓거나 좁아져 반응이 너무 일찍 켜지거나 거의 걸리지 않는다 — 버튼 크기·컨테이너 폭에 맞춰 이 값부터 다시 재야 한다. 두 번째 함정은 reduce 변수다. 아홉 파일 모두 matchMedia('(prefers-reduced-motion: reduce)').matches 를 스크립트가 처음 실행될 때 딱 한 번만 읽어 두고, 그 뒤로 OS 설정이 바뀌는 걸 듣는 change 리스너가 없다 — 페이지를 열어 둔 채 동작 줄임 설정을 켜도 새로고침하기 전까지는 반영되지 않는다. zfbz3x5g 로 여는 zip 안 9개 폴더도 이 두 지점은 데모와 같은 상태이니, 실제 프로젝트에 옮길 때는 값과 리스너를 같이 손봐야 한다.

동작을 줄이면 아홉 개가 반응만 남기고 멈추는 접근성 처리

아홉 개 모두 style.scss 에 @media (prefers-reduced-motion: reduce) { transition: none; } 을 넣어 CSS 트랜지션을 꺼 두지만, 자바스크립트가 매 프레임 계산해 넣는 box-shadow·transform 같은 인라인 스타일은 트랜지션과 별개라 그것만으로는 멈추지 않는다. 그래서 각 index.html 스크립트는 시작할 때 reduce 값을 확인해 커서가 없을 때 도는 자동 궤도 시연을 아예 돌리지 않고 p=0 상태로 한 번만 그려 둔다 — 다만 실제 포인터가 버튼 위로 오거나 키보드로 포커스를 받으면 pointermove·focus 핸들러 안에서 목표 값을 즉시 현재 값으로 맞추고 다시 그리므로, 반경이 넓어졌는지·색이 바뀌었는지 같은 근접 상태 자체는 화면에 그대로 반영된다 — 없어지는 건 그 사이를 채우는 보간 애니메이션뿐이다. prefers-reduced-motion 을 직접 감지해 자동 재생 루프만 끄고 상태는 지키는 쪽이, requestAnimationFrame 루프를 통째로 지우는 것보다 일관된 경험을 준다.

css 버튼 마우스 오버 효과는 css 버튼 호버 애니메이션과 무엇이 다른가

css 버튼 마우스 오버 효과와 css 버튼 호버 애니메이션은 거의 같은 화면을 가리키지만, 이 9종이 다루는 건 그 중에서도 커서가 아직 닿지 않았을 때의 반응이다. 357번 버튼 눌림 효과 UI 9종 은 이미 누른 뒤 눌림 값이 스프링처럼 되돌아오는 css 버튼 눌림 효과 쪽이라 포인터가 버튼에 닿고 난 다음부터 시작하고, 393번 css 버튼 입체감 9가지 는 버튼 표면 위에서 커서 위치를 3D 회전으로 바꾸는 쪽이라 커서가 버튼 안에 있어야 값이 생긴다. 404번 css 버튼 클릭 애니메이션 9가지 는 클릭 순간 캔버스 픽셀을 다시 그리는 쪽이다. 이번 9종만 버튼 바깥 거리 값 하나로, 닿기 전부터 반응이 연속으로 이어진다.

css 버튼 마우스 오버 효과를 적용하기 전에 자주 확인하는 것

마우스가 없는 터치 입력 환경에서는 이 9종이 어떻게 동작하나요?

터치 입력 환경에는 pointermove 가 지속적으로 오지 않아 거리 기반 연속 반응을 만들기 어렵다. 그래서 각 스크립트는 pointermove 말고 focus·blur 핸들러도 같이 걸어 뒀다 — 버튼을 탭하면 브라우저가 focus 를 주므로 focus 핸들러가 목표 값을 즉시 최대치로 맞추고, 포커스가 빠지면 blur 핸들러가 0으로 되돌린다. 거리에 따라 서서히 변하던 게 켜짐·꺼짐에 가깝게 동작한다.

css 버튼 클릭 애니메이션과 같이 조합해도 되나요?

된다. 다만 이 9종은 매 프레임 style.boxShadow·style.transform 같은 인라인 스타일을 직접 덮어쓰므로, 별도의 CSS :active 트랜지션이 같은 속성(transform 등)을 건드리면 다음 프레임에 인라인 값이 그걸 덮어써 버린다. 클릭 애니메이션은 다른 속성(예: 배경색·아이콘)을 쓰거나, 클릭 상태를 이 스크립트의 render 함수 안 계산에 같이 넣어 한 곳에서 관리하는 쪽이 충돌을 피한다.

데모에서 커서가 저절로 움직이는 건 실제 방문자 데이터인가요?

아니다. syntheticOffset 함수가 만든 가상 좌표다 — 흐른 시간(t)마다 각도(ang)와 반지름(rad = 최댓값 × (1 + cos(t·π)))을 계산해, 실제 포인터가 없을 때도 버튼에 가까워졌다 멀어지기를 반복하는 궤도를 그린 것뿐이다. 실제 마우스 로그를 재생하는 게 아니고, 포인터가 스테이지에 들어오는 순간 이 가상 궤도는 즉시 멈추고 실제 좌표로 넘어간다.

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

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