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

마우스 따라다니는 효과 9종 — 커서를 쫓는 글씨·빛·파티클

마우스 따라다니는 효과는 커서 좌표를 매 프레임 읽어서 요소가 한 박자 늦게 쫓아오게 만드는 인터랙션입니다. 핵심은 lerp(선형 보간) 한 줄이고, 캔버스 없이 transform 한 줄로 만들어 무겁지 않습니다.

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

아홉 개는 흔한 순서가 아니라 따라오는 대상이 커지는 순서입니다. 01에서 점 하나가 지연 추적의 원리를 배우고, 02·03에서 배경과 빛처럼 무대 자체가 반응하는 범위로 넓어지고, 04·05는 글씨와 파티클처럼 커서 뒤에 줄을 서는 대상, 06부터 08까지는 메뉴·카드·목록처럼 실제 UI 부품이 따라오는 단계, 09는 그림이 속도에 반응해 늘어나는 마무리입니다. 원리는 전부 같은 lerp라서 한 번 이해하면 아홉 개 전부가 같은 코드의 변주입니다.

01따라다니기 기본 — 지연 추적

모든 추적의 바탕으로, 남은 거리의 일부만 매 프레임 더 가는 lerp 한 줄로 요소가 커서를 한 박자 늦게 쫓게 하고 잔상 세 개가 그 지연을 프레임 차이로 보여 줍니다.

따라다니기 기본 — 지연 추적
새 창에서 열기

01 · 따라다니기 기본 — 지연 추적 — 측정 누적면적 3.684% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointermoverequestAnimationFrametranslate3d

lerp 한 줄이 지연을 만든다

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

function tick() {
  dot.x = lerp(dot.x, target.x, 0.16);
  dot.y = lerp(dot.y, target.y, 0.16);
  draw();
  requestAnimationFrame(tick);
}

k가 0.16이면 매 프레임 남은 거리의 16%만 가므로 커서가 멈추면 거리가 84%, 71%, 59%로 줄며 스르르 붙고, 잔상 세 개는 이 점을 계수 0.10, 0.065, 0.04로 다시 쫓게 해 지연이 눈에 보입니다.

얼마나 뒤처지는지 실측했다

누적면적 3.68%·강도 120.2·움직인 프레임 23/23이고, 실제 마우스를 움직인 프로브에서는 마지막 이동 40프레임 뒤 점과 커서 사이가 2.2px로 수렴했습니다.

02따라오는 배경 — 세 겹 시차

먼 언덕·가까운 언덕·풀이 커서의 가로 위치를 서로 다른 비율로 쫓아오고, 풀잎은 커서 쪽에 눕고 해의 눈동자도 커서를 따라갑니다.

따라오는 배경 — 세 겹 시차
새 창에서 열기

02 · 따라오는 배경 — 세 겹 시차 — 측정 누적면적 6.548% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translate3dskewXvar(--px)

겹마다 밀리는 폭이 다르다

pxNow = lerp(pxNow, px, 0.08);
scene.style.setProperty('--px', pxNow.toFixed(4));
var dx = pxNow - 0.5;
far.style.transform   = 'translate3d(' + (dx * -10).toFixed(1) + 'px,0,0)';
mid.style.transform   = 'translate3d(' + (dx * -22).toFixed(1) + 'px,0,0)';
grass.style.transform = 'translate3d(' + (dx * -34).toFixed(1) + 'px,0,0)';

--px는 0부터 1 사이 커서 가로 위치를 담는 사용자 정의 속성으로, 풀잎 기울임은 이 값 하나를 아홉 잎이 skewX(calc((0.5 - var(--px)) * 26deg)) 한 줄로 공유하고 언덕은 멀수록 -10px·가까울수록 -22px씩 밀리며, 풀은 -34px으로 가장 멀리 밀려 시차가 생깁니다.

시차가 수치로 찍히는 순간

누적면적 6.55%·강도 123.7·움직인 프레임 15/23이고, 커서를 오른쪽 끝에 둔 뒤 30프레임 만에 --px가 0.913까지 가까워져(목표 0.963) 앞풀이 -13.9px 밀린 같은 프레임이 찍혔습니다.

03따라다니는 빛 — 스포트라이트

radial-gradient 빛 덩어리가 mix-blend-mode: screen으로 어두운 무대 위를 커서보다 늦게 지나가며 지나온 글자와 별을 밝힙니다.

따라다니는 빛 — 스포트라이트
새 창에서 열기

03 · 따라다니는 빛 — 스포트라이트 — 측정 누적면적 29.695% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

radial-gradientmix-blend-moderequestAnimationFrame

빛의 재료와 합성

.sl__light {
  width: 220px; height: 220px;
  background: radial-gradient(circle, rgba($subject-cream, .95) 0%,
              rgba($stage-yellow, .38) 38%, rgba($stage-yellow, 0) 68%);
  mix-blend-mode: screen;
  border-radius: 50%;
}

screen은 어두운 쪽만 더 밝게 합성하므로 빛이 지나면 그 아래 글자가 드러났다 돌아가고, 추적 계수는 느리게 흐르는 0.09로 뒀습니다.

느린 빛의 산수

누적면적 29.70%로 아홉 개 중 가장 넓게 변했고 강도 45.0·움직인 프레임 17/23이며, 커서를 고정하고 30프레임을 세니 빛 중심까지 처음 거리의 약 8%가 남아 한 박자 늦은 빛이 목표를 오래도록 쫓는다는 것이 숫자로 드러났습니다.

04따라 다니는 글씨들 — 글자 기차

단어를 글자마다 나눠 첫 글자는 커서를, 뒤 글자는 앞 글자를 쫓게 하면 글자들이 커서 뒤에 한 박자씩 밀려오는 기차가 됩니다.

따라 다니는 글씨들 — 글자 기차
새 창에서 열기

04 · 따라 다니는 글씨들 — 글자 기차 — 측정 누적면적 10.345% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointermovewill-changetranslate3d

앞 글자를 쫓는 사슬

var follow = [0.22, 0.16, 0.12, 0.09, 0.07];

function tick() {
  spots[0].x = lerp(spots[0].x, target.x, follow[0]);
  spots[0].y = lerp(spots[0].y, target.y - 34, follow[0]);
  for (var i = 1; i < spots.length; i++) {
    spots[i].x = lerp(spots[i].x, spots[i - 1].x, follow[i]);
    spots[i].y = lerp(spots[i].y, spots[i - 1].y + 8, follow[i]);
  }
  draw();
  requestAnimationFrame(tick);
}

계수가 뒤로 갈수록 작아지는 것이 핵심이라 커서가 급선회하면 뒤 글자가 바깥으로 흩어지는 꼬리가 잡히고, 글자에는 will-change: transform을 줘서 브라우저가 합성 계층을 미리 만들게 합니다.

포스터에서 잡힌 겹침

누적면적 10.35%·강도 120.3·움직인 프레임 23/23인데, 첫 버전은 다섯 글자가 같은 경로를 딜레이로만 어긋나게 움직여, 뽑은 프레임의 오른쪽 끝에서 두 글자가 한 덩이로 겹쳐 글자마다 시작점을 비스듬히 벌려 대각선 기차로 고쳤습니다.

05화려한 파티클 마우스 — DOM 파편

캔버스 없이 span 파편을 커서 속도에 비례해 터뜨리고 element.animate() 호출로 날려 보냈다 사라지게 합니다.

화려한 파티클 마우스 — DOM 파편
새 창에서 열기

05 · 화려한 파티클 마우스 — DOM 파편 — 측정 누적면적 2.71% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

element.animate()pointermoveopacity

이동 거리로 터뜨린다

function spawn(x, y, dx, dy) {
  var element = pool[next];
  next = (next + 1) % pool.length;
  element.className = 'pb__p ' + shapes[(Math.random() * shapes.length) | 0];
  element.style.setProperty('--pc', colors[(Math.random() * colors.length) | 0]);
  var ang = Math.atan2(dy, dx) + (Math.random() - 0.5) * 2.4;
  var dist = 34 + Math.random() * 46;
  var tx = x + Math.cos(ang) * dist;
  var ty = y + Math.sin(ang) * dist;
  element.animate([
    { transform: 'translate3d(' + (x - 5) + 'px,' + (y - 5) + 'px,0) rotate(0deg) scale(1)', opacity: 1 },
    { transform: 'translate3d(' + (tx - 5) + 'px,' + (ty - 5) + 'px,0) rotate(200deg) scale(.15)', opacity: 0 }
  ], { duration: 620 + Math.random() * 320, easing: 'cubic-bezier(.2,.8,.2,1)' });
}

커서가 26px 움직일 때마다 파편 하나가 터지므로 빨리 움직일수록 많이 쏟아지고, 점·고리·마름모 세 도형의 색은 --pc 속성 하나로 접히며 캔버스 파티클 축은 캔버스 파티클 엔진 9종이 이어받습니다.

살아 있는 애니메이션을 세었다

이동 중 문서에 살아 있는 애니메이션이 41개까지 올라갔다 멈춘 뒤 1.2초면 0개로 꺼지는데 재생이 끝난 파편은 fill을 남기지 않아 처음의 opacity: 0으로 돌아가 잔해가 쌓이지 않고, 측정은 누적면적 2.71%·강도 112.1·움직인 프레임 23/23입니다.

06네비게이션 커서 이펙트 — 미끄러지는 알약

메뉴 항목 아래 알약 하이라이트가 커서가 올라간 항목 자리로 미끄러져 옮겨 가고, 로고 눈동자는 커서의 가로 위치를 따라갑니다.

네비게이션 커서 이펙트 — 미끄러지는 알약
새 창에서 열기

06 · 네비게이션 커서 이펙트 — 미끄러지는 알약 — 측정 누적면적 3.0% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointerovergetBoundingClientRecttranslate3d

항목 자리를 재고 lerp로 간다

function measure(el) {
  var r = el.getBoundingClientRect();
  var w = wrap.getBoundingClientRect();
  return { x: r.left - w.left, w: r.width };
}

function tick() {
  now.x = lerp(now.x, slot.x, 0.16);
  now.w = lerp(now.w, slot.w, 0.16);
  pill.style.transform = 'translate3d(' + now.x.toFixed(1) + 'px,0,0)';
  pill.style.width = now.w.toFixed(1) + 'px';
  requestAnimationFrame(tick);
}

pointerenter에서 getBoundingClientRect로 항목의 왼쪽 끝과 폭을 재면 알약이 그 자리로 이동하며 폭까지 늘어나고, 항목이 button이라 탭으로 이동해도 focus에서 같은 측정이 일어나 키보드에도 반응합니다.

0.1px 안쪽으로 붙는다

누적면적 3.00%·강도 129.2·움직인 프레임 16/23이며, 두 번째 항목에 pointerenter를 보내고 0.7초 뒤 알약의 x는 44.9px로 항목의 실제 x 44.9px와 정확히 일치했고, 미리보기 루프의 키프레임도 로드 시 잰 항목 자리를 CSS 변수로 받아 폰트나 언어가 바뀌어도 알약이 글자를 넘어서지 않습니다.

07카드 오버 이펙트 — 글로우 링과 자석 붙음

카드 안을 도는 radial-gradient 빛 고리가 커서를 쫓고, 카드 전체는 3D 기울임 없이 이동만으로 커서 쪽에 살짝 붙습니다.

카드 오버 이펙트 — 글로우 링과 자석 붙음
새 창에서 열기

07 · 카드 오버 이펙트 — 글로우 링과 자석 붙음 — 측정 누적면적 17.272% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointermoveradial-gradientvar(--gx)

고리는 카드 안 좌표를 좇는다

function setTarget(cx, cy) {
  var r = card.getBoundingClientRect();
  t.x = Math.min(r.width, Math.max(0, cx - r.left));
  t.y = Math.min(r.height, Math.max(0, cy - r.top));
  pull.x = Math.max(-7, Math.min(7, (t.x - r.width / 2) * 0.09));
  pull.y = Math.max(-7, Math.min(7, (t.y - r.height / 2) * 0.12));
}

고리의 위치는 --gx, --gy 두 속성에 담아 SCSS에서 translate3d(calc(var(--gx) * 1px - 42px), calc(var(--gy) * 1px - 42px), 0)로 옮기는데 속성은 고리 요소 자신에게 설정해야 계산이 그 요소 기준으로 묶이고, 카드 붙음은 커서가 중심에서 멀수록 최대 7px 따라가는 클램프입니다.

카드 밖으로는 나가지 않는다

css 마우스 오버 효과 같은 말로 검색해 찾아오는 분께 이 항목이 출발점이 좋은데 씨앗은 JS 여덟 줄이고 CSS는 자리만 잡아 줘서 어느 카드에나 붙이기 쉽고, 실측은 누적면적 17.27%·강도 71.5·움직인 프레임 19/23이고 커서를 오른쪽 아래로 옮긴 뒤 --gx 263.9, --gy 108.6에 도달했고 커서가 카드 밖으로 나가면 좌표가 가장자리에 클램프돼 고리가 카드를 벗어나지 않았습니다.

08오버 시 이미지 프리뷰 — 리본 꼬리

목록 줄에 올리면 그 항목의 그림이 커서를 늦게 쫓아 떠오르고, 그림에는 리본 이름표가 붙어 같이 돌아다닙니다.

오버 시 이미지 프리뷰 — 리본 꼬리
새 창에서 열기

08 · 오버 시 이미지 프리뷰 — 리본 꼬리 — 측정 누적면적 12.68% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointerovertranslate3drotate

장면 갈아 끼우고 기울이며 따라온다

function tick() {
  p.x = lerp(p.x, t.x, 0.13);
  p.y = lerp(p.y, t.y, 0.13);
  rotNow = lerp(rotNow, rot, 0.1);
  rot = lerp(rot, 0, 0.06);
  preview.style.transform = 'translate3d(' + (p.x - 75).toFixed(1) + 'px,'
    + (p.y - 55).toFixed(1) + 'px,0) rotate(' + rotNow.toFixed(1) + 'deg)';
  requestAnimationFrame(tick);
}

장면 세 개는 같은 크기로 겹쳐 두고 opacity로 갈아 끼우며, 기울임은 커서의 가로 이동량으로 최대 ±7도 섰다 돌아오는데 이 값이 있으면 그림이 커서에 끌려가는 느낌이 납니다.

줄이 바뀌면 리본도 바뀐다

누적면적 12.68%·강도 91.5·움직인 프레임 18/23이며, 두 번째 줄에 pointerenter를 보내니 보이는 장면 인덱스가 1로 바뀌고 리본 문구가 그 줄의 이름으로 바뀌었고 그림 안 풍경은 사진 파일 없이 그라디언트 세 장이라 zip을 어디서 열어도 깨지지 않습니다.

09이미지 왜곡 추적 — 속도 늘어짐

커서의 매 프레임 이동량을 재서 그림을 그 방향으로 비틀고 늘렸다가, 커서가 멈추면 스르르 되돌립니다.

이미지 왜곡 추적 — 속도 늘어짐
새 창에서 열기

09 · 이미지 왜곡 추적 — 속도 늘어짐 — 측정 누적면적 5.497% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

skewXscalerequestAnimationFrame

속도를 왜곡으로 바꾼다

function tick() {
  sx = lerp(sx, vx, 0.24);
  sy = lerp(sy, vy, 0.24);
  vx = lerp(vx, 0, 0.12);
  vy = lerp(vy, 0, 0.12);
  var skew = Math.max(-11, Math.min(11, sx * 0.32));
  var stretch = 1 + Math.min(0.14, Math.abs(sx) * 0.011 + Math.abs(sy) * 0.009);
  art.style.transform = 'skewX(' + skew.toFixed(2) + 'deg) scale(' + stretch.toFixed(3) + ')';
  requestAnimationFrame(tick);
}

속도 vx, vy는 매 프레임 12%씩 스스로 죽어 커서가 멈춰도 값이 즉시 0이 되지 않으므로 그림이 튕겼다 펴지는 잔상이 남습니다.

늘어났다 돌아오는 폭

누적면적 5.50%·강도 63.6·움직인 프레임 16/23이고, 커서를 왕복으로 훑는 동안 그림이 skewX(-11deg) scale(1.14)까지 늘어났다 1.5초 뒤 skewX(0deg) scale(1)로 돌아왔습니다.

마우스 커서 따라다니는 효과를 라이브러리 없이 어디까지 만들 수 있나?

여기까지 아홉 개를 관통하는 재료는 네 개뿐입니다. pointermove로 좌표를 얻고, requestAnimationFrame으로 매 프레임 lerp 한 줄을 돌리고, transform으로만 옮기고, prefers-reduced-motion에서 뒤처짐을 끊습니다. 마우스 커서 따라다니는 효과를 라이브러리로 붙이는 글도 많지만, 이 네 개로 글씨·빛·배경·파편·메뉴·카드·그림 전부가 커버된다는 것을 위에서 보셨습니다. 같은 lerp 루프를 쓰는 물리감 효과는 물리 인터랙션 시뮬레이션 9종이 이어받습니다.

계수 하나로 성격이 갈린다

계수 쓴 곳 성격
0.22 04 첫 글자 커서에 바짝 붙는다
0.16 01 점, 06 알약 한 박자 뒤의 기본값
0.09 03 빛 느리게 흐르는 무대 조명
0.04 01 마지막 잔상 한참 뒤에야 도착하는 꼬리

어떤 축의 마우스 효과인지 정해 두면 좋다

이 편은 따라오는 대상 축입니다. 커서 모양 자체를 바꾸는 효과는 마우스 이펙트 9가지가, 커서를 따라 도는 3D 장면은 포인터 3D 장면 9종이 각각 다룹니다. 마우스 효과 css 검색으로 들어온 분들 가운데 커서 꾸미기를 찾는 분은 그 두 글이 더 가깝고, 배경·글씨·카드가 반응하는 쪽이 필요하면 이 글의 아홉 개가 답입니다.

어디서 깨지는가 — 프로브가 잡은 것, 눈이 잡은 것

죽은 기능 검사가 조용히 통과했다

첫 버전의 05는 typeof Element.animate === 'function'을 검사한 뒤에만 파편을 터뜨렸는데, 이 식은 어떤 브라우저에서도 항상 거짓입니다. animate는 프로토타입 메서드라 생성자인 Element에는 없으므로, 가드가 항상 실패해 파편이 한 번도 안 터진 채 미리보기 루프는 멀쩡하게 돌았습니다. 측정 게이트가 보는 것은 .is-demo의 CSS 키프레임이라 이 죽은 경로를 잡지 못했고, 실제로 마우스를 움직여 애니메이션 개수를 세는 프로브에서 0개가 나와서야 발견했습니다. 검사를 고쳐 document.body.animate로 잰 뒤 이동 중 41개로 정상화됐습니다. 미리보기는 도는데 손에서는 안 도는 코드는 이렇게 생깁니다.

translate의 퍼센트는 요소 자기 크기 기준이다

01의 미리보기 경로를 처음에 translate3d(78%, 22px, 0)처럼 썼다가 커서가 의도보다 훨씬 짧게 움직였습니다. translate의 퍼센트는 무대가 아니라 요소 자기 폭 기준이라 18px 고리의 78%인 14px밖에 안 가기 때문입니다. 경로 전부를 px로 다시 썼고, 같은 원리로 글자 기차의 첫 미리보기에서는 다섯 글자가 한 덩이로 겹쳐 찍혀 시작점을 벌려 고쳤습니다. 프레임을 뽑아 눈으로 보기 전엔 통과가 아니라는 원칙이 두 번 다 들어맞은 셈입니다.

아홉 데모와 React 판 전체를 담은 zip의 비밀번호는 abmheerh이고, 터치 화면에서는 위치를 쫓는 데모가 손가락을 대는 pointerdown으로 같은 추적을 이어가게 해 두었습니다.

변형 세 가지와 접근성

이름 바꾼 값 느낌
duration $dur-enter 600ms → $dur-quick 220ms 따라잡기가 빨라져 커서에 바짝 붙는다
easing $ease-out → $ease-pop 도착할 때 한 번 더 튄다
color $subject-blue → $stage-yellow 쫓아오는 요소가 차가운 쪽에서 따뜻한 쪽으로 온다

접근성은 두 겹입니다. CSS 쪽에서 prefers-reduced-motion 블록이 미리보기 루프를 끄고 각 데모의 기본 자세를 보여 주고, JS 쪽에서 같은 설정을 재서 lerp 추적 대신 커서 자리로 즉시 붙게 합니다. 03의 빛은 한가운데에 머물고, 09의 그림은 늘어나지 않습니다. 알약은 06에서 버튼의 focus에도 같은 이동을 걸어 키보드 이동에 반응하고, 포인터가 없는 화면을 위해 위치를 쫓는 일곱 개는 pointerdown을 함께 듣고, 06·08처럼 올라간 대상을 갈아 끼우는 부품은 pointerenter와 focus가 그 역할을 맡습니다.

FAQ

마우스 따라다니는 효과를 스마트폰에서도 쓸 수 있나요?

네. 위치를 쫓는 일곱 개는 pointerdown도 함께 들어서 손가락을 대거나 끄는 위치를 따라옵니다. 다만 터치에서는 커서가 없으므로 글자 기차처럼 커서 뒤에 줄 서는 효과보다 02·07처럼 면이 반응하는 효과가 읽히기 쉽습니다.

lerp 대신 transition 만으로는 안 되나요?

포인터가 움직일 때마다 transition을 새로 거는 방식은 이벤트가 연달아 올 때 목적지가 서로 부딪혀 떨림이 생깁니다. lerp 루프는 목표가 매번 바뀌어도 남은 거리에 비례해 한결같이 감쇠하므로, 여러 대상을 줄 세우는 04·05 같은 구성에서 특히 안정적입니다.

파티클 개수를 늘리면 느려지지 않나요?

이 구조는 파편 여덟 개를 돌려 쓰는 풀이라 개수를 늘려도 DOM 노드 수는 그대로입니다. 동시에 살아 있는 애니메이션은 실측에서 최대 41개였고 각각 1초 안에 스스로 정리됩니다. 수천 개를 띄우는 구성이라면 그때는 캔버스 파티클 엔진 9종의 방식이 맞습니다.

출처

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

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