왜 커서부터 손댔는가
처음에는 커스텀 커서가 꼭 필요하다고 생각하지 않았습니다. 페이지마다 레이아웃과 애니메이션만 잘 맞추면 충분할 거라고 봤거든요. 그런데 검정과 흰색, 연두 포인트로 통일한 화면 위에서 기본 커서는 점점 더 튀어 보였습니다. 정보는 읽히는데, 손끝의 감각은 사이트와 연결되지 않는 느낌이었습니다.
그래서 CursorFollower를 AppProviders 최상단에 두고, 모든 페이지에서 같은 커서 경험을 공유하게 만들었습니다. 구조는 단순합니다. 40px짜리 연두 링 하나, 8px짜리 점 하나. 겉보기엔 작지만, 이 두 요소가 서로 다른 속도로 움직일 때 '쫀득함'이 생깁니다.
점은 마우스를 거의 즉시 따라오고, 링은 한 박자 늦게 따라옵니다. lerp 계수를 각각 0.4와 0.16으로 나눠 두었는데, 이 차이 하나만으로도 커서가 살아 있는 것처럼 느껴집니다. 처음엔 둘 다 같은 속도로 움직였는데, 그러면 그냥 원 두 개가 겹쳐 다니는 수준이었습니다. 반응 속도를 다르게 두는 순간부터 커서가 '따라온다'는 표현이 맞아졌습니다.
const tick = () => {
dotPos.x += (mouse.x - dotPos.x) * 0.4;
dotPos.y += (mouse.y - dotPos.y) * 0.4;
ringPos.x += (mouse.x - ringPos.x) * 0.16;
ringPos.y += (mouse.y - ringPos.y) * 0.16;
const dx = mouse.x - ringPos.x;
const dy = mouse.y - ringPos.y;
const dist = Math.hypot(dx, dy);
const stretch = clamp(dist / 90);
const angle = (Math.atan2(dy, dx) * 180) / Math.PI;
setDot({ x: dotPos.x, y: dotPos.y });
setRing({
x: ringPos.x,
y: ringPos.y,
rotation: angle,
scaleX: base * (1 + stretch),
scaleY: base * (1 - stretch * 0.7),
});
};포인트 컬러 #97c42f도 여기서 역할을 합니다. 링 테두리와 점 색이 같아서 브랜드 톤을 유지하면서도, 움직일 때는 링과 점의 관계 변화로 시선이 자연스럽게 따라갑니다. 화려한 효과 없이도 손끝에서 사이트의 결을 조금 느끼게 만드는 장치가 됐습니다.
쫀쫀함을 만드는 디테일
이 커서에서 제일 마음에 드는 부분은 속도 기반 squash & stretch입니다. 링과 마우스 사이 거리를 구해서, 멀수록 가로로 늘고 세로로 살짝 눌리게 했습니다. dist / 90 값을 clamp(0, 0.42)로 제한해서 과하게 찢어지지 않게 맞췄고, 이동 방향에 맞춰 rotation도 같이 줬습니다.
빠르게 움직일수록 링이 살짝 찌그러졌다가 멈추면 다시 원형으로 돌아옵니다. 마치 고무줄이 따라오는 것 같은 느낌인데, 이건 CSS transition만으로는 만들기 어렵고 GSAP ticker 안에서 매 프레임 계산하는 방식이 맞았습니다. gsap.quickSetter로 x, y, rotation, scaleX, scaleY를 직접 밀어 넣으니 60fps로 부드럽게 유지됩니다.
호버 상태도 따로 설계했습니다. a, button, [data-cursor="hover"] 위에 올라가면 링 안쪽에 연두 배경이 은은하게 채워지고, 점은 scale 0으로 사라집니다. 링만 남는 순간 '여기를 누를 수 있다'는 신호가 더 분명해집니다. ease는 back.out(3)과 back.in(2)를 써서 튀어나오고 들어가는 탄성감을 조금 남겼습니다.
클릭할 때는 mod.press 값을 0.75까지 줄여서 링 전체가 살짝 눌리고, mouseup에서는 elastic.out(1, 0.4)로 다시 튀어 오르게 했습니다. 작은 피드백이지만 클릭했다는 감각이 분명해집니다. 처음 버전은 hover만 있었는데, 클릭 반응까지 넣고 나서야 '만져지는 UI'라는 말이 어울리기 시작했습니다.
보이지 않을 때를 설계하기
커서를 예쁘게 만드는 것만큼, 언제 꺼야 하는지도 중요했습니다. @media (hover: none)과 prefers-reduced-motion: reduce에서는 CursorFollower 자체를 display: none으로 숨기고, JS에서도 같은 조건이면 early return 합니다. 터치 기기나 모션 민감 사용자에게 불필요한 레이어를 얹지 않으려는 의도입니다.
데스크톱에서는 document.documentElement.style.cursor = "none"으로 기본 커서를 숨깁니다. 대신 pointer-events: none인 링과 점만 fixed로 띄워서, 클릭이나 스크롤을 방해하지 않게 했습니다. z-index 9999로 최상단에 두되, 이벤트는 전부 통과시키는 구조입니다.
window 밖으로 나가면 opacity 0, 다시 들어오면 fade in. 첫 mousemove에서 ringPos와 dotPos를 현재 좌표로 맞춰서 화면 중앙에서 갑자기 튀어나오는 현상도 막았습니다. 작은 처리지만, 커서가 '항상 거기 있었다'는 느낌을 주는 데 꽤 큰 차이가 있었습니다.
솔직히 이 커서 하나만 놓고 보면 자랑할 만한 부분이 꽤 있다고 생각합니다. 코드 양은 180줄 남짓인데, ticker + lerp + squash & stretch + hover + press까지 들어가 있습니다. 포트폴리오 전체를 관통하는 작은 레이어로, 내가 좋아하는 모션 감각 — 즉각 반응하고, 쫀득하고, 과하지 않게 — 을 가장 잘 보여주는 컴포넌트가 됐습니다. 앞으로 페이지별로 data-cursor="hover"를 더 붙이면서, 어디서 커서가 반응해야 하는지도 계속 다듬어 볼 예정입니다.