---
name: custom-cursor-magnetism
description: Use when you want "Premium, editorial, experimental" - Changes cursor appearance and attraction around interactive elements.
---

# Custom Cursor Magnetism

> **Category:** Motion / Interaction  -  **Personality:** Premium, editorial, experimental
>
> **Best use:** Creative agencies, portfolios
>
> **Ratings:** Professional 3/5 - Casual 4/5 - Exotic 5/5 - Visual impact 5/5 - Difficulty 5/5 - Performance cost 4/5

## What it is

The OS arrow is hidden (`cursor:none`) and replaced by a bespoke element — here a small **dot** that tracks your hand precisely plus a **trailing ring** that lags behind it on an eased follow. As the pointer nears an interactive target, the ring is *pulled toward that target's centre* (magnetism) and **morphs**: it swells into a labelled disc ("View" / "Drag") over the work, or stretches into a "Let's talk →" pill over the call-to-action. It is the signature flourish of high-end creative-agency and portfolio sites, signalling craft the instant the cursor enters the page.

This is **distinct from "Magnetic Hover"**: there the *buttons* slide toward the cursor; here the *cursor itself* is custom and snaps to the targets, which stay put.

## Dependencies / CDN

**None — vanilla JS + CSS.** No libraries. The demo only loads display/body/mono fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Schibsted+Grotesk:wght@400;500;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
```

## HTML

The cursor is two absolutely-positioned nodes inside the stage; targets opt in with `data-cursor` (+ `data-label` for the morph text).

```html
<div class="cm-stage" id="cmStage">
  <!-- the custom cursor -->
  <div class="cm-cursor" id="cmCursor" aria-hidden="true">
    <div class="cm-ring" id="cmRing"><span class="cm-label" id="cmLabel"></span></div>
    <div class="cm-dot" id="cmDot"></div>
  </div>

  <!-- targets opt in -->
  <a class="cm-link" data-cursor="link">Work</a>

  <figure class="cm-tile" data-cursor="view" data-label="View">
    <img src="/path/architecture-01.jpg" alt="Pavilion 23">
  </figure>

  <button class="cm-cta" data-cursor="cta" data-label="Let's talk &rarr;">Start a project</button>

  <div class="cm-reel" id="cmReel" data-cursor="drag" data-label="Drag &#8646;"> … </div>
</div>
```

## CSS

```css
/* hide the native arrow only while the custom cursor is live (JS adds .cm-on) */
.cm-stage{position:relative; overflow:hidden; isolation:isolate; border-radius:28px; min-height:660px}
.cm-stage.cm-on, .cm-stage.cm-on *{cursor:none}

/* the cursor layer + its two parts */
.cm-cursor{position:absolute; inset:0; z-index:30; pointer-events:none; opacity:0; transition:opacity .25s ease}
.cm-stage.cm-on .cm-cursor.is-vis{opacity:1}                 /* shown only while pointer is inside */
.cm-ring,.cm-dot{position:absolute; top:0; left:0; pointer-events:none; will-change:transform}

.cm-ring{display:grid; place-items:center; width:42px; height:42px; border-radius:999px;
  border:1.5px solid #efe9df; background:transparent; mix-blend-mode:difference;  /* always contrasts */
  transition:width .4s cubic-bezier(.22,.8,.2,1), height .4s cubic-bezier(.22,.8,.2,1),
             background-color .3s, border-color .3s, border-width .3s}
.cm-dot{width:6px; height:6px; border-radius:999px; background:#efe9df; mix-blend-mode:difference}
.cm-label{font:11px/1 'Space Mono',monospace; letter-spacing:.16em; text-transform:uppercase; color:#0c0b0e;
  white-space:nowrap; opacity:0; transform:scale(.5); transition:opacity .25s, transform .35s cubic-bezier(.22,.8,.2,1)}

/* morph states (toggled by JS) */
.cm-cursor.is-link .cm-ring{width:62px; height:62px}                         /* ring blooms around links */
.cm-cursor.is-down .cm-ring{width:30px; height:30px}                         /* press feedback */
.cm-cursor.is-view .cm-ring,.cm-cursor.is-drag .cm-ring,.cm-cursor.is-cta .cm-ring{mix-blend-mode:normal; border-width:0}
.cm-cursor.is-view .cm-ring{width:98px; height:98px; background:#cdf24a}      /* "View" disc */
.cm-cursor.is-drag .cm-ring{width:92px; height:92px; background:#efe9df}      /* "Drag" disc */
.cm-cursor.is-cta  .cm-ring{width:160px; height:54px; background:#cdf24a}     /* "Let's talk" pill */
.cm-cursor.is-view .cm-label,.cm-cursor.is-drag .cm-label,.cm-cursor.is-cta .cm-label{opacity:1; transform:scale(1)}
.cm-cursor.is-view .cm-dot,.cm-cursor.is-drag .cm-dot,.cm-cursor.is-cta .cm-dot{opacity:0}

/* never hijack touch / no-hover devices */
@media (hover:none),(pointer:coarse){ .cm-cursor{display:none!important} .cm-stage,.cm-stage *{cursor:auto!important} }
/* reduced motion = a static custom cursor */
@media (prefers-reduced-motion:reduce){ .cm-ring,.cm-dot,.cm-label{transition:none} }
```

## JavaScript

```js
(function(){
  var stage=document.getElementById('cmStage'), cursor=document.getElementById('cmCursor'),
      ring=document.getElementById('cmRing'), dot=document.getElementById('cmDot'),
      label=document.getElementById('cmLabel');
  // only on a fine, hover-capable pointer — touch keeps the native cursor
  if(!matchMedia('(hover:hover) and (pointer:fine)').matches) return;
  var reduce=matchMedia('(prefers-reduced-motion:reduce)').matches;
  stage.classList.add('cm-on');

  var px=0,py=0, rx=0,ry=0, dx=0,dy=0, active=null, stick=0, started=false;
  var STICK={view:.78, cta:.82, drag:.22, link:0};   // pull strength toward target centre

  function setState(el){
    active=el; cursor.classList.remove('is-link','is-view','is-drag','is-cta');
    if(!el){ stick=0; label.textContent=''; return; }
    var k=el.getAttribute('data-cursor'); stick=STICK[k]||0; cursor.classList.add('is-'+k);
    label.textContent=/view|drag|cta/.test(k) ? (el.getAttribute('data-label')||'') : '';
  }
  function aim(){                                    // pointer blended toward the active target's centre
    var tx=px, ty=py;
    if(active && stick){
      var s=stage.getBoundingClientRect(), r=active.getBoundingClientRect();
      var cx=r.left-s.left+r.width/2, cy=r.top-s.top+r.height/2;
      tx=px+(cx-px)*stick; ty=py+(cy-py)*stick;
    }
    return [tx,ty];
  }
  function paint(a,b,c,d){
    ring.style.transform='translate('+a+'px,'+b+'px) translate(-50%,-50%)';
    dot.style.transform ='translate('+c+'px,'+d+'px) translate(-50%,-50%)';
  }
  function loop(){
    var t=aim();
    rx+=(t[0]-rx)*.18; ry+=(t[1]-ry)*.18;            // ring eases (magnetic trail)
    dx+=(px-dx)*.45;   dy+=(py-dy)*.45;              // dot tracks the precise pointer
    paint(rx,ry,dx,dy); requestAnimationFrame(loop);
  }

  stage.addEventListener('pointermove',function(e){
    if(e.pointerType==='touch') return;
    var s=stage.getBoundingClientRect(); px=e.clientX-s.left; py=e.clientY-s.top;
    if(!started){ started=true; rx=dx=px; ry=dy=py; }
    if(reduce){ var t=aim(); paint(t[0],t[1],px,py); }  // static: no rAF loop
  });
  stage.addEventListener('pointerover',function(e){
    var el=e.target.closest('[data-cursor]'); if(el!==active) setState(el);
  });
  stage.addEventListener('pointerenter',function(){ cursor.classList.add('is-vis'); });
  stage.addEventListener('pointerleave',function(){ cursor.classList.remove('is-vis'); setState(null); });
  stage.addEventListener('pointerdown',function(){ cursor.classList.add('is-down'); });
  window.addEventListener('pointerup',function(){ cursor.classList.remove('is-down'); });

  if(!reduce) requestAnimationFrame(loop);
})();
```

## How it works

- **Hide & replace.** `cursor:none` removes the OS arrow; a `<div>` cursor is drawn in its place. It lives *inside the stage* and is positioned with `transform: translate(x,y) translate(-50%,-50%)`, so the `-50%` keeps it centred on its point even as it resizes.
- **Two-speed follow.** Every frame the ring and dot `lerp` (`pos += (target - pos) * k`) toward their targets — the dot fast (`k=0.45`) so it feels precise, the ring slow (`k=0.18`) so it trails like a magnet on a spring.
- **Magnetism = re-aiming.** When the pointer is over a `data-cursor` target, `aim()` blends the pointer toward the element's **centre** by a `stick` factor. A high factor (0.78–0.82) makes the ring snap to and cling at the centre; a low one (0.22) lets it drift. That blend is the whole magnet trick.
- **Morph via state classes.** `pointerover` resolves `closest('[data-cursor]')` and toggles `is-view/-drag/-cta/-link`. CSS transitions on `width/height/background` animate the ring from a thin outline into a filled disc or pill, and reveal the mono label.
- **Contrast for free.** `mix-blend-mode:difference` on the default dot/ring inverts against whatever is behind, so the cursor stays visible over dark UI and photos alike; the labelled discs switch to `normal` blend for legible text.

## Customization

- **Feel:** raise the ring `lerp` (`0.18 → 0.3`) for a tighter, snappier cursor; lower it for more lag/elasticity.
- **Magnet strength:** tune the `STICK` map per target type — `1` locks to centre, `0` just follows the pointer.
- **More states:** add `data-cursor="play"` (or any name), a matching `STICK` entry, and a `.cm-cursor.is-play .cm-ring` rule — no JS branching needed.
- **Look:** swap the difference blend for a solid colour, give the ring `backdrop-filter:blur()` for a glassy lens, or add a second slower ring for a comet trail.

## Accessibility & performance

- **Pointer-only.** Gate the whole thing behind `(hover:hover) and (pointer:fine)` so phones/tablets keep their native cursor; never set `cursor:none` on touch.
- **Reduced motion.** Honour `prefers-reduced-motion`: skip the rAF loop and place the cursor directly on `pointermove` — a *static* custom cursor, no smoothing or morph easing.
- **Cheap to animate.** Only `transform` (and `opacity`) change per frame — both are GPU-composited; `will-change:transform` hints the compositor. One rAF loop drives both nodes; no layout thrash.
- **Keep semantics.** The cursor is `aria-hidden` and `pointer-events:none`; real `<a>`/`<button>` targets stay keyboard-focusable and the effect never blocks clicks.

## Gotchas

- **Centre on resize:** position with `translate(x,y) translate(-50%,-50%)`, *not* `left/top` — the percentage re-centres automatically as the ring grows, and transforms stay off the main thread.
- **`mix-blend-mode` needs a context:** give the stage `isolation:isolate` so the blend samples only inside it (and so the labelled discs read correctly); set `mix-blend-mode:normal` on any state that shows text.
- **Hide the children from hit-testing:** the cursor layer (and decorative reel images) need `pointer-events:none`, or they intercept clicks and break `closest('[data-cursor]')` resolution.
- **`pointerover` not `mouseenter`:** it bubbles, so one delegated listener covers every target; recompute `closest()` each time and bail early when the resolved target hasn't changed.
- **Confine & hide:** show the cursor only between `pointerenter`/`pointerleave` and keep the stage `overflow:hidden`, or the element strays outside its frame.
