커서 모프

Cursor morph

기본 커서를 숨기고 그 자리를 대신하는 요소가, 지금 가리키고 있는 대상에 따라 크기·모양·라벨을 바꾸는 기법. 링크 위에서는 커지고, 텍스트 위에서는 얇은 막대가 되는 식입니다.

다른 이름: Adaptive cursorContext cursor
···
html
<div class="stage" id="stage">
  <div class="zone z1">Link</div>
  <div class="zone z2">Drag</div>
  <div class="zone z3">Body text long enough to show the caret cursor clearly as it crosses this reading area.</div>
  <i class="fcur" id="cur"><span id="curLabel"></span></i>
</div>
css
.stage{position:relative;width:100%;height:100%;display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:6%;padding:8%}
.zone{border:1px dashed var(--line);border-radius:10px;display:grid;place-items:center;color:var(--muted);font:600 12px/1.4 sans-serif;text-align:center;padding:6%}
.z3{grid-column:1 / -1}
.fcur{position:absolute;top:0;left:0;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;
  background:var(--accent);opacity:.9;pointer-events:none;
  display:grid;place-items:center;transition:width .18s,height .18s,margin .18s,border-radius .18s,background .18s}
.fcur span{font:700 9px/1 sans-serif;color:#fff;opacity:0;transition:opacity .15s}
.fcur.link{width:46px;height:46px;margin:-23px 0 0 -23px;background:var(--accent-2)}
.fcur.link span{opacity:1}
.fcur.drag{width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:8px;background:var(--accent-3)}
.fcur.text{width:2px;height:20px;margin:-10px 0 0 -1px;border-radius:1px;background:var(--fg)}
js
const stage = document.getElementById('stage');
const cur = document.getElementById('cur');
const label = document.getElementById('curLabel');
const zones = [...document.querySelectorAll('.zone')];

function centerPct(el) {
  const s = stage.getBoundingClientRect();
  const r = el.getBoundingClientRect();
  return { x: ((r.left + r.width / 2 - s.left) / s.width) * 100, y: ((r.top + r.height / 2 - s.top) / s.height) * 100 };
}
const waypoints = [
  { p: centerPct(zones[0]), state: 'link', label: 'OPEN' },
  { p: centerPct(zones[1]), state: 'drag', label: '' },
  { p: centerPct(zones[2]), state: 'text', label: '' },
  { p: { x: 50, y: 12 }, state: '', label: '' },
];
let wi = 0;
let px = waypoints[0].p.x, py = waypoints[0].p.y;
function loop() {
  const target = waypoints[wi];
  px += (target.p.x - px) * 0.06;
  py += (target.p.y - py) * 0.06;
  cur.style.left = px + '%';
  cur.style.top = py + '%';
  cur.className = 'fcur' + (target.state ? ' ' + target.state : '');
  label.textContent = target.label;
  if (Math.abs(target.p.x - px) < 0.6 && Math.abs(target.p.y - py) < 0.6) {
    if (!loop.hold) loop.hold = 0;
    loop.hold++;
    if (loop.hold > 55) { loop.hold = 0; wi = (wi + 1) % waypoints.length; }
  }
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

실제 시스템 커서는 cursor: none으로 숨기고, position: fixed(또는 iframe 내부처럼 좁은 컨테이너라면 absolute)인 대체 요소를 포인터 좌표에 맞춰 따라다니게 합니다. 대체 요소의 이동에는 보통 약간의 lerp(선형 보간, pos += (target - pos) × 0.2 정도)를 줘서 시스템 커서보다 살짝 느리게, 그러나 지연이 거슬리지 않을 만큼만 따라오게 합니다.

"모프"의 핵심은 mouseenter/mouseleave(또는 이 요소들에 대한 이벤트 위임)로 지금 커서 아래에 있는 요소의 종류(링크, 버튼, 텍스트, 드래그 가능한 영역 등)를 판별해 커서 요소에 상태 클래스를 토글하는 것입니다. 각 상태는 크기·모양·배경·라벨 텍스트가 다른 CSS로 정의해두고, 전환에는 transform과 opacity만 애니메이션해 상태가 바뀌는 순간에도 프레임이 끊기지 않게 합니다.

터치 기기에는 마우스 커서 자체가 없으므로 이 패턴은 무의미합니다 — 포인터 타입을 감지해(@media (pointer: fine) 또는 JS의 matchMedia) 데스크톱에서만 적용하고, 터치 환경에서는 시스템 커서를 그대로 두는 게 맞습니다.

언제 쓰나

포트폴리오·에디토리얼 사이트처럼 커서 자체가 브랜드 표현의 일부일 때 씁니다. 업무용 도구·폼처럼 정확한 클릭 위치가 중요한 UI에는 커서를 대체하지 마세요 — 위치 감각이 흐트러집니다.