Cursor morph

커서 모프

Hiding the system cursor and replacing it with an element that changes size, shape, and label depending on what it's currently hovering — growing over a link, thinning into a caret over text.

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

Hide the real system cursor with cursor: none, then have a replacement element — position: fixed, or absolute inside a small container like an iframe — track the pointer's coordinates. Moving it with a touch of lerp (pos += (target − pos) × ~0.2) makes it trail the system cursor just slightly, enough to feel alive without the lag becoming annoying.

The "morph" itself comes from mouseenter/mouseleave (or delegated events) telling you what kind of element the cursor is currently over — a link, a button, plain text, a draggable region — and toggling a state class on the cursor element accordingly. Define each state's size, shape, background, and label in CSS, and animate only transform and opacity between them so the frame stays smooth through the switch.

Touch devices have no mouse cursor at all, so this pattern is meaningless there — detect pointer capability (@media (pointer: fine), or matchMedia in JS) and apply it only on desktop, leaving the system cursor alone on touch.

When to use

Use it where the cursor itself is part of the brand expression — portfolios, editorial sites. Avoid replacing the cursor in utility UIs and forms where precise click position matters — it muddies the user's sense of exactly where the pointer is.