마그네틱 버튼

Magnetic Button

커서가 가까이 오면 버튼이 자석처럼 그쪽으로 살짝 끌려가는 인터랙션. 클릭하기도 전에 반응한다는 느낌을 줍니다.

다른 이름: Magnetic effectCursor-pull button
···
html
<button class="mag" id="btn"><span>Hover</span></button>
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}
.mag{position:relative;padding:clamp(12px,6vmin,20px) clamp(22px,9vmin,36px);border:none;border-radius:999px;
  background:var(--accent);color:#fff;font-weight:700;font-size:clamp(12px,4vmin,16px);cursor:pointer;
  transition:transform .15s ease-out;box-shadow:0 10px 24px color-mix(in srgb,var(--accent) 35%,transparent)}
.mag span{display:block;transition:transform .15s ease-out}
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 btn=document.getElementById('btn');
const label=btn.querySelector('span');
function render(x,y){
  const r=btn.getBoundingClientRect();
  const cx=r.left+r.width/2, cy=r.top+r.height/2;
  const dx=x-cx, dy=y-cy, dist=Math.hypot(dx,dy);
  const radius=Math.max(r.width,r.height)*1.7;
  if(dist<radius){
    const p=1-dist/radius;
    btn.style.transform='translate('+(dx*0.4*p)+'px,'+(dy*0.4*p)+'px)';
    label.style.transform='translate('+(dx*0.18*p)+'px,'+(dy*0.18*p)+'px)';
  } else {
    btn.style.transform='translate(0,0)';
    label.style.transform='translate(0,0)';
  }
}
let t=0;
(function loop(){
  if(fxAuto){
    t+=0.025;
    fxX=innerWidth/2+Math.sin(t)*innerWidth*0.4;
    fxY=innerHeight/2+Math.cos(t*1.5)*innerHeight*0.4;
    fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
  }
  render(fxX,fxY);
  requestAnimationFrame(loop);
})();

버튼 중심과 커서 사이 거리를 매 프레임 계산해서, 일정 반경 안에 들어오면 그 방향으로 `transform: translate()` 값을 조금씩 준다. 거리가 가까울수록 더 많이 끌려가고, 반경을 벗어나면 원래 자리로 돌아온다.

내부 텍스트나 아이콘을 버튼보다 살짝 더 크게 움직이면(예: 버튼은 40%, 텍스트는 18%) 층이 나뉘어 보이는 패럴랙스감이 생긴다. transition은 짧게(0.1~0.2초) 줘야 손끝처럼 즉각 반응하는 느낌이 난다.

반경과 최대 이동 거리를 너무 크게 잡으면 버튼이 화면을 이리저리 튀는 것처럼 보여 산만하다. 보통 반경은 버튼 크기의 1.5~2배, 최대 이동은 버튼 크기의 30~40%를 넘지 않는다.

언제 쓰나

CTA 버튼, 히어로 섹션의 핵심 액션처럼 "여기 눌러주세요"를 강조할 한두 곳에만 씁니다. 목록의 모든 버튼에 적용하면 피로감을 줍니다.