Tilt Card

틸트 카드

A card that tilts in 3D toward the cursor, as if you were holding it — built with CSS perspective and rotateX/rotateY.

Also known as: 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);
})();

Give the parent `perspective` and the card `transform-style: preserve-3d`. Compute the cursor's relative position inside the card (0–1) and map the vertical axis to `rotateX` (tilts back as you move up) and the horizontal axis to `rotateY` — the center (0.5, 0.5) means no rotation.

Move a soft radial-gradient "shine" along with the cursor position on top, and the surface reads much more like glass catching light. Keep the transition short (0.05–0.1s) so it doesn't feel like it's lagging behind the cursor.

Push the rotation too far (past ~20°) and text inside the card distorts into unreadable territory. Most implementations cap it around ±15°.

When to use

Use it on product or profile cards where you want a tactile, physical feel. On touch devices there's no hover, so disable it there.