GODRICH

마우스를 따라오는 CSS 3D 회전 장면 9종

마우스를 따라오는 효과는 화면 위 요소가 포인터 위치에 반응해 도는 CSS 3D 회전 장면입니다. CSS 3D 회전을 perspective 와 transform-style: preserve-3d, 바닐라 자바스크립트 포인터 수학만으로 만든 아홉 장면이라, 라이브러리 없이 붙여 넣으면

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

아홉 개를 늘어놓은 기준은 화려함이 아니라 포인터를 따라 움직이는 방식의 순서입니다. 먼저 각도로 반응하는 셋 — 마우스 x·y 를 rotateY·rotateX 각도로 바꾸는 제품 목업(01)에 깊이별 계수를 얹어 카드 층을 갈라 놓고(02), 드래그로 자유 회전시킵니다(03). 다음은 방향으로 반응하는 셋 — 커서까지의 각도를 재는 눈동자(04)에, 같은 낱말을 여러 겹 띄우는 3D 텍스트(05)와 커서를 광원으로 쓰는 하이라이트(06)가 이어집니다. 마지막 셋은 포인터가 없어도 사는 법 — 풍경 파노라마로 시차 감각을 붙이고(07), 기울기 센서로 넘어가며(08), 자동 회전과 prefers-reduced-motion 정지로 마무리합니다(09). 무대 색은 잉크 셋·페이퍼 둘·노랑 둘·주황 둘로 돌렸습니다. 스크롤에 반응하는 화면은 스크롤 스토리 템플릿에, 커서 자체를 바꾸는 효과는 마우스 커서 효과 9종에 있습니다.

01마우스를 따라 도는 제품 목업

포인터의 x 를 rotateY ±16도, y 를 rotateX ±6도로 바꾸는 기본 장면입니다. 관성과 스프링 복귀를 한 식으로 처리하는데, 목표 각도로 당기는 힘을 더하고 마찰을 매 프레임 곱하면 떼었을 때 튕기듯 정면으로 돌아옵니다. 격자 미리보기는 포인터가 왼쪽 끝에서 오른쪽 끝으로 지나간 것과 같은 범위를 CSS 키프레임 궤적으로 재생해 누적면적 3.96%, 움직인 프레임 23/23 을 기록했고, 제품 상세 페이지의 대표 이미지에 씁니다.

perspectiverotateYtransform-style
// 감쇠 추적: 목표로 당기는 힘(stiffness)과 마찰(damping)로 관성과 스프링 복귀를 한 식으로 처리한다
function step() {
  velX = (velX + (aimX - nowX) * 0.18) * 0.76;
  velY = (velY + (aimY - nowY) * 0.18) * 0.76;
  nowX += velX;
  nowY += velY;
  model.style.transform = 'rotateX(' + nowX.toFixed(2) + 'deg) rotateY(' + nowY.toFixed(2) + 'deg)';
  var resting = Math.abs(velX) < 0.01 && Math.abs(velY) < 0.01 && Math.abs(aimX - nowX) < 0.05 && Math.abs(aimY - nowY) < 0.05;
  if (resting && !holding) { running = false; return; }
  requestAnimationFrame(step);
}
/* perspective 는 장면(부모)에 두고 preserve-3d 는 도는 요소에 둔다 — 둘을 한 요소에 겹치면 면이 납작해진다 */
.pr__scene {
  position: relative;
  perspective: 620px;
  perspective-origin: 50% 42%;
}
.pr__model {
  position: absolute; inset: 0; margin: auto;
  transform-style: preserve-3d;
}

02깊이별로 어긋나는 카드 스택

카드 석 장에 각자 다른 깊이 계수를 두고, 자바스크립트는 포인터 x 를 −1~1 값 하나로만 넘깁니다. 앞 카드는 26px, 가운데 16px, 뒤는 8px 밀리면서 translateZ 가 원근 축소까지 맡아 층이 갈라져 보이고, 같은 값으로 광택 띠도 좌우로 쓸립니다. 미리보기에서 누적면적 11.46%, 움직인 프레임 19/23 — 요금제 비교 카드 묶음에 씁니다.

translate3dlinear-gradientrotateY
/* 깊이 계수: 앞 카드가 가장 크게 밀린다. translate3d 의 z 가 원근 축소까지 맡는다 */
.cs__card {
  transform: translate3d(calc(-50% + var(--px) * var(--shift)), calc(-50% + var(--y)), var(--z))
             rotateY(calc(var(--px) * var(--tilt)));
}
.cs__card--back  { --shift: 8px;  --z: -56px; --tilt: 3deg; --y: -28px; }
.cs__card--mid   { --shift: 16px; --z: -28px; --tilt: 5deg; --y: -18px; }
.cs__card--front { --shift: 26px; --z: 0px;   --tilt: 7deg; --y: 0px; }
function step() {
  now += (aim - now) * 0.16;
  scene.style.setProperty('--px', now.toFixed(3));
  if (Math.abs(aim - now) < 0.002) { scene.style.setProperty('--px', aim.toFixed(3)); running = false; return; }
  requestAnimationFrame(step);
}

03드래그로 돌리는 여섯 면 상자

면 여섯 개에 콘텐츠를 두고 드래그로 자유 회전시키는 상자입니다. 놓으면 남은 속도로 더 돌다가 90도 눈금 중 가장 가까운 쪽으로 당겨져 멈춥니다 — 관성과 스냅이 같은 루프 안에서 이어집니다. 미리보기에서 누적면적 9.35%, 움직인 프레임 18/23 — 여섯 개 카테고리를 고르는 화면에 씁니다.

rotateXpointermovesetPointerCapture
// 놓은 뒤에는 남은 속도로 더 돌다가(관성) 90도 눈금 중 가장 가까운 쪽으로 당겨져 멈춘다
function step() {
  if (dragging) { running = false; return; }
  var snap = Math.round(angle / 90) * 90;
  spin = spin * 0.92 + (snap - angle) * 0.08;
  angle += spin;
  paint();
  if (Math.abs(spin) < 0.05 && Math.abs(snap - angle) < 0.3) { angle = snap; paint(); running = false; return; }
  requestAnimationFrame(step);
}
/* 여섯 면은 같은 정사각형을 반 변(--s / 2)만큼 바깥으로 밀어 세운 것이다 */
.cb__face--f { transform: translateZ(calc(var(--s) / 2)); }
.cb__face--r { transform: rotateY(90deg) translateZ(calc(var(--s) / 2)); }
.cb__face--u { transform: rotateX(90deg) translateZ(calc(var(--s) / 2)); }

04눈으로 커서를 쫓는 캐릭터

눈마다 자기 중심에서 커서까지의 각도를 Math.atan2 로 재고, 그 방향으로 눈동자를 눈 크기의 22% 만큼 내보냅니다. 깜빡임은 눈꺼풀을 steps(1, end) 컷으로 덮었다 여는 별도 애니메이션이고, 고양이와 로봇 두 캐릭터는 서 있는 자리가 달라 같은 커서를 다른 각도로 봅니다. 미리보기에서 누적면적 2.57%, 움직인 프레임 20/23 — 404 페이지의 마스코트에 씁니다.

Math.atan2scaleYsteps
// 눈마다 자기 중심에서 커서까지의 각도를 재고, 그 방향으로 눈동자를 반지름만큼만 내보낸다
function look(clientX, clientY) {
  for (var i = 0; i < eyes.length; i++) {
    var eye = eyes[i];
    var pupil = eye.firstElementChild;
    var r = eye.getBoundingClientRect();
    var angle = Math.atan2(clientY - (r.top + r.height / 2), clientX - (r.left + r.width / 2));
    var reach = Math.min(r.width, r.height) * 0.22;
    pupil.style.transform = 'translate(' + (Math.cos(angle) * reach).toFixed(1) + 'px, ' + (Math.sin(angle) * reach).toFixed(1) + 'px)';
  }
}

05층층이 뜬 3D 글자

같은 낱말을 다섯 겹으로 겹치고 겹마다 translateZ 를 −32px 부터 0px 까지 달리해 두께를 만듭니다. 포인터를 따라 기울면 앞장과 뒷장이 다른 각도로 밀려 글자가 통짜로 조각된 것처럼 읽히고, 뒷장들은 반투명 잉크로 물러나 앞장이 또렷하게 드러납니다. 미리보기에서 누적면적 3.03%, 움직인 프레임 23/23 — 행사 페이지의 큰 제목에 씁니다.

translateZperspectiverotateX
.tx__layer {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%) translateZ(var(--z));
  color: rgba($subject-ink, .55);
  font-weight: 800; font-size: clamp(26px, 8vw, 40px); line-height: 1; letter-spacing: .04em;
}
.tx__layer:nth-child(1) { --z: -32px; }
.tx__layer:nth-child(2) { --z: -24px; }
.tx__layer:nth-child(3) { --z: -16px; }
.tx__layer:nth-child(4) { --z: -8px; }
.tx__layer--top {
  --z: 0px;
  position: relative; left: auto; top: auto;
  transform: translateZ(0);
}

06커서를 광원으로 쓰는 하이라이트

커서 자리에 둥근 빛을 만들고 그 위치를 면마다 자기 상자 기준 백분율로 넘깁니다. 각진 면은 conic-gradient 로 접힌 다면체처럼, 물결 면은 반복 방사형으로 파문처럼 놓고, 빛은 mix-blend-mode: screen 으로 아래 무늬를 살린 채 밝힙니다. 미리보기에서 누적면적 13.95%, 움직인 프레임 22/23 — 다크 테마 대시보드의 강조 패널에 씁니다.

radial-gradientconic-gradientmix-blend-mode
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }

/* 빛은 커서 자리에서 퍼지는 둥근 밝음이다. mix-blend-mode 로 아래 면의 무늬를 살린 채 밝힌다 */
.lt__glow {
  position: absolute; inset: 0;
  background: radial-gradient(circle 76px at var(--mx) var(--my),
    rgba($stage-paper, .92), rgba($stage-paper, .28) 42%, rgba($stage-paper, 0) 72%);
  mix-blend-mode: screen;
}
function lightAt(clientX, clientY) {
  var faces = panel.querySelectorAll('.lt__surface');
  for (var i = 0; i < faces.length; i++) {
    var r = faces[i].getBoundingClientRect();
    faces[i].style.setProperty('--mx', ((clientX - r.left) / r.width * 100).toFixed(1) + '%');
    faces[i].style.setProperty('--my', ((clientY - r.top) / r.height * 100).toFixed(1) + '%');
  }
}

07앞뒤로 갈라지는 풍경 파노라마

하늘 8px, 산 22px, 들판 44px — 겹마다 이동 계수를 가까운 겹일수록 크게 벌려 창밖 풍경의 깊이를 만듭니다. 자바스크립트는 02 와 같은 −1~1 값 하나만 넘기고 계수는 CSS 가 각 겹의 --depth 에 둡니다. 산은 clip-path 삼각형 두 개로 그렸고 미리보기에서 누적면적 14.1%, 움직인 프레임 21/23 — 여행 상품 소개 배너에 씁니다.

translateXfilterclip-path
.tp__layer {
  position: absolute; left: -12%; right: -12%;
  transform: translateX(calc(var(--px) * var(--depth)));
}
.tp__layer--sky {
  --depth: 8px;
  top: 0; height: 62%;
}
.tp__layer--hill {
  --depth: 22px;
  bottom: 26%; height: 48%;
}
.tp__layer--field {
  --depth: 44px;
  bottom: 0; height: 30%;
}

/* 산은 clip-path 삼각형 두 개 — 그라디언트로 그리면 반쪽 쐐기가 되어 능선으로 안 읽힌다 */
.tp__peak {
  position: absolute; bottom: 0; height: 100%;
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
}

08기울기 센서로 넘어가는 폴백

마우스가 없는 기기에서는 deviceorientationbeta·gamma 값으로 같은 장면을 돌립니다. 지금 누가 조종하는지 라벨로 보여 주는 것이 이 부품의 본체인데, iOS 13 부터는 DeviceOrientationEvent.requestPermission 을 사람이 누른 뒤에만 센서를 줍니다 — 그 전까지는 포인터가 맡습니다. 미리보기에서 누적면적 1.71%, 움직인 프레임 23/23 — 모바일에서 여는 제품 미리보기에 씁니다.

deviceorientationDeviceOrientationEventpointermove
// iOS 13+ 는 DeviceOrientationEvent.requestPermission 을 사람이 누른 뒤에만 준다 — 그 전까지는 마우스가 맡는다
var hasSensor = typeof window.DeviceOrientationEvent !== 'undefined'
             && typeof window.DeviceOrientationEvent.requestPermission !== 'function';

if (hasSensor) {
  window.addEventListener('deviceorientation', function (e) {
    var beta = e.beta === null ? 0 : e.beta;     // 앞뒤 기울기
    var gamma = e.gamma === null ? 0 : e.gamma;  // 좌우 기울기
    tilt(Math.max(-12, Math.min(12, (beta - 40) * 0.4)), Math.max(-18, Math.min(18, gamma * 0.6)), TILT_LABEL);
  });
}

09포인터가 없을 때의 자동 회전

(hover: hover) and (pointer: fine) 미디어 쿼리로 포인터가 있는 기기인지 먼저 묻고, 있으면 사람이 돌리고, 없으면 CSS 애니메이션이 8초에 한 바퀴씩 스스로 돕니다. 움직임을 줄이라는 설정에서는 회전을 끄고 한 면을 세워 둡니다. 미리보기에서 누적면적 7.72%, 강도 145.8, 움직인 프레임 23/23 — 키오스크의 대기 화면에 씁니다.

prefers-reduced-motionmatchMediaanimation-duration
// 포인터가 아예 없는 기기인지 먼저 묻는다 — 있으면 사람이 돌리고, 없으면 스스로 돈다
var fine = window.matchMedia('(hover: hover) and (pointer: fine)');

function backToAuto() {
  prism.style.transform = '';
  prism.classList.add('ar__prism--auto');
  state.textContent = AUTO_LABEL;
}
/* 자동 회전은 클래스 하나로 붙였다 뗀다 — 사람이 돌리는 동안에는 이 클래스가 없다 */
.ar__prism--auto {
  animation-name: arTurn;
  animation-duration: 8s;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
}

어디서 깨지는가 — 함정

첫 번째 함정은 미리보기의 대표 장면이 늘 정면뿐이었다는 것입니다. 회전한 면의 보이는 폭은 각도의 코사인이라 면적 변화율은 사인 × 각속도인데, 등속으로 ±16도를 왕복시키면 정면(0도) 근처에서 프레임 간 차이가 가장 커져 poster 가 늘 납작한 정면 장면으로 뽑혔습니다. 키프레임을 갈아 정면을 느리게 지나고 양 끝에서 빠르게 꺾게 했더니 같은 측정 조건에서 최대 변화 프레임이 돌아간 각도에서 나와 기운 장면이 대표로 잡혔습니다. 두 번째는 07 의 산을 conic-gradient 로 그렸다가 반쪽만 그려지며 쐐기로 읽힌 것 — 꼭짓점이 하단 중앙인 부채꼴은 180도보다 좁게 열어야 살아남는데, 능선은 애초에 clip-path 삼각형이 정답이었습니다. 세 번째는 03 의 파란 면 위 크림 글자가 기본 토큰 파랑(#2f6df6) 위에서 4.25:1 로 AA(4.5:1)에 못 미쳤던 것 — 면 색을 한 단계 내린 #0a4fe8 에서 5.98:1 을 쟀습니다(근거: run/290/_대비실측.json). 포인터가 진짜로 따라오는지는 아홉 개 전부 Playwright mouse.move 실측으로 확인했고 그 기록이 run/290/_probe/실측.json 입니다.

함정 증상 고침
등속 왕복 회전 poster 가 늘 정면 장면 정면을 느리게, 양 끝을 빠르게
conic-gradient 산 반쪽 쐐기로 그려짐 clip-path 삼각형 두 개
파랑 면 위 크림 글자 4.25:1 AA 대비 미달 면을 #0a4fe8 로 내려 5.98:1

아홉 장면 전체의 압축 비밀번호는 27ds8fgs 이고, 풀면 항목마다 vanilla 판과 React 판이 짝으로 들어 있습니다.

접근성

3D 효과는 장식이므로 아홉 장면 전부 role="img" 와 대체 요약 aria-label 을 붙였고, 포인터 의존 기능에는 키보드 경로를 함께 뒀습니다 — 화살표로 회전(01·05), 한 면씩 넘기기(03), 시선 보내기(04), 빛 옮기기(06), 기울이기(08)이고 09 는 Escape 로 자동 회전에 돌아갑니다. 08 의 라벨은 실제로 센서가 맡았는지 포인터가 맡았는지를 문장으로 알려 줍니다. prefers-reduced-motion 에서는 회전·시차·깜빡임을 끄고 각 장면이 가장 잘 읽히는 고정 각도로 세워 둡니다 — 06 만은 빛이 어디 있는지가 정보라 빛 자리를 남기고 움직임만 끕니다.

FAQ

마우스가 없는 휴대폰에서는 어떻게 되나요?

matchMedia(hover: hover) and (pointer: fine) 쿼리가 터치 전용 기기를 알아내고, 그런 기기에서는 장면이 스스로 천천히 돕니다. 08 은 붙이면 기울기 센서 값으로 넘어가서 기기를 기울이는 손이 마우스를 대신합니다.

3D 변환 때문에 화면이 느려지는데 어디가 원인인가요?

대부분은 회전시키는 면의 수 때문입니다. transform-style: preserve-3d 를 두른 요소는 겹겹이 합성 층이 되어 면 수에 비례해 비용이 듭니다. 07 처럼 겹마다 계수만 다른 시차 장면은 will-change: transform 을 이동하는 겹에만 걸고, 매 프레임 그림자나 filter 를 다시 계산하는 짓은 피하십시오.

관성 없이 바로 따라오게 하려면요?

01 의 step() 에서 속도 항을 빼고 now += (aim - now) * 0.14 한 줄만 남기면(05 는 처음부터 이 모양입니다) 즉시 추적이 됩니다. 반대로 관성을 크게 하려면 마찰 계수(0.76)를 1 쪽으로 올리면 되는데, 0.9 를 넘으면 손을 뗀 뒤 한참 흔들리니 0.76~0.85 사이를 씁니다. 각도 재기의 출처는 Math.atan2 문서, 포인터 이벤트는 Pointer Events 문서에 있습니다.

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

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