틸트 카드

Tilt Card

커서 위치에 따라 카드가 마치 손에 쥔 것처럼 살짝 기우는 3D 효과. CSS perspective와 rotateX/rotateY로 만듭니다.

다른 이름: 3D tiltParallax tilt card
···
html
<div class="tiltwrap"><div class="tilt" id="tc"><div class="shine"></div><strong>3D Tilt</strong></div></div>
css
.fx-dot{position:absolute;left:0;top:0;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;
  background:var(--accent);box-shadow:0 0 0 5px color-mix(in srgb,var(--accent) 18%,transparent),0 2px 6px rgba(0,0,0,.3);
  pointer-events:none;z-index:9999;transition:opacity .25s}
.tiltwrap{perspective:600px;width:min(72%,240px);aspect-ratio:16/10}
.tilt{position:relative;width:100%;height:100%;border-radius:16px;background:linear-gradient(135deg,var(--accent),var(--accent-3));
  display:grid;place-items:center;color:#fff;font-weight:700;font-size:clamp(13px,4vmin,18px);
  transform-style:preserve-3d;transition:transform .08s linear;box-shadow:0 20px 40px rgba(0,0,0,.25);overflow:hidden}
.shine{position:absolute;inset:-40%;background:radial-gradient(circle at var(--sx,50%) var(--sy,50%),rgba(255,255,255,.55),transparent 45%);
  pointer-events:none}
js
const fxDot=document.createElement('div');fxDot.className='fx-dot';document.body.appendChild(fxDot);
let fxAuto=true,fxX=innerWidth/2,fxY=innerHeight/2;
function fxHide(){fxDot.style.opacity='0';}
addEventListener('pointerdown',()=>{fxAuto=false;fxHide();},{capture:true});
addEventListener('pointermove',e=>{fxAuto=false;fxHide();fxX=e.clientX;fxY=e.clientY;},{capture:true});
function fxWalk(x,y,ms){
  return new Promise(res=>{
    const sx=fxX,sy=fxY,start=performance.now();
    function step(now){
      if(!fxAuto)return res();
      const p=Math.min(1,(now-start)/ms),e=1-Math.pow(1-p,3);
      fxX=sx+(x-sx)*e;fxY=sy+(y-sy)*e;
      fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
      if(p<1)requestAnimationFrame(step);else res();
    }
    requestAnimationFrame(step);
  });
}
function fxWait(ms){
  return new Promise(res=>{
    const start=performance.now();
    function step(now){ if(!fxAuto)return res(); if(now-start<ms)requestAnimationFrame(step);else res(); }
    requestAnimationFrame(step);
  });
}

const card=document.getElementById('tc');
function render(x,y){
  const r=card.getBoundingClientRect();
  const px=Math.min(1,Math.max(0,(x-r.left)/r.width));
  const py=Math.min(1,Math.max(0,(y-r.top)/r.height));
  card.style.transform='rotateX('+((py-0.5)*-18)+'deg) rotateY('+((px-0.5)*18)+'deg)';
  card.style.setProperty('--sx',(px*100)+'%');
  card.style.setProperty('--sy',(py*100)+'%');
}
let t=0;
(function loop(){
  if(fxAuto){
    t+=0.02;
    const r=card.getBoundingClientRect();
    fxX=r.left+r.width/2+Math.sin(t)*r.width*0.55;
    fxY=r.top+r.height/2+Math.cos(t*1.3)*r.height*0.55;
    fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
  }
  render(fxX,fxY);
  requestAnimationFrame(loop);
})();

부모에 `perspective`를 주고, 카드에는 `transform-style: preserve-3d`를 준다. 카드 내부에서 커서의 상대 위치(0~1)를 구해서, 세로 위치는 `rotateX`(위로 갈수록 뒤로 젖혀짐), 가로 위치는 `rotateY`로 바꿔준다 — 중심(0.5, 0.5)이 회전 없음이다.

위에 반투명 원형 그라데이션(`shine`)을 커서 위치에 맞춰 함께 움직이면 유리 표면에 빛이 스치는 느낌이 더해져 입체감이 훨씬 살아난다. transition은 짧게(0.05~0.1초)만 줘야 커서를 따라잡는 지연이 느껴지지 않는다.

회전 각도를 너무 크게(20도 이상) 주면 카드 안의 텍스트가 왜곡돼 읽기 힘들어진다. 보통 ±15도 안쪽에서 멈춘다.

언제 쓰나

상품 카드, 프로필 카드처럼 "물성"을 강조하고 싶은 요소에 씁니다. 터치 기기에서는 hover가 없으니 자동으로 꺼지게 합니다.