Blend-Mode Cursor

A white disc in mix-blend-mode: difference trails the pointer with a 0.12 lerp, inverting headlines, a lime block and an image as it passes, swelling from 24px to 120px over links with a tiny label and pulsing a ring on click.

Loading

This effect is desktop-only; the demo shows a static fallback on touch devices.

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1638 words of exact values — rewritten live as you tune the Playground.

prompt.md · Blend-Mode Cursor
### EFFECT: Blend-Mode Cursor
**What it does (visual):** The native cursor is hidden on desktop. In its place two shapes follow the pointer: a small 8px white dot that sits almost exactly on the pointer position, and a 24px white disc that drifts behind it with a soft lag (it closes a fraction of 0.12 of the remaining distance every 60fps frame — at 0.12 it settles in ~0.4s — and never overshoots). Both shapes are composited with `mix-blend-mode: difference`, so they do not have a colour of their own: over the near-black page they look white, over an accent-coloured (`#d4ff3a`) block they turn into its complement (lime becomes deep violet), over a photograph they show its negative, over white text the letters turn black inside the disc. When the pointer enters a link marked `data-cursor-grow`, the disc swells to 5× its size over 0.45s with a decelerating ease and a tiny uppercase mono label ("View", "Open", "Read" — whatever `data-cursor-label` says) fades in at its centre, painted in the *backdrop's* colour because the label is black inside the inverted group. Leaving the link shrinks the disc back to 24px over 0.45s and the label fades out in about a third of that. Pressing the mouse squeezes the disc to 80% and — when the pulse is enabled (true) — fires a 40px hairline ring that expands to 2.4× while fading out over 0.7s; releasing springs the disc back. When the pointer leaves the window the whole layer fades out in 0.3s. The demo page is a studio homepage: a nav, a two-column hero (large serif headline + a procedurally painted dusk photograph tile), a full-width `#d4ff3a` band with a serif statement, and a list of journal links — enough contrasting surfaces to show the inversion.

**Tech stack:**
- GSAP 3.13+ core only (ticker, quickSetter, overwriting tweens). No plugins.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`
- npm: `npm i gsap` → `import gsap from "gsap";`
- Fonts: any variable serif for headlines (demo uses Fraunces 300), a sans for body copy, a mono for the label.

**HTML structure:**
```html
<div class="bmc">
  <header class="bmc__nav">
    <a href="#top" class="bmc__brand" data-cursor-grow data-cursor-label="Home">Halide &amp; Co.</a>
    <nav class="bmc__links">
      <a href="#work" class="bmc__link" data-cursor-grow data-cursor-label="Open">Work</a>
      <a href="#journal" class="bmc__link" data-cursor-grow data-cursor-label="Read">Journal</a>
    </nav>
  </header>
  <section class="bmc__hero">
    <div class="bmc__copy">
      <p class="bmc__eyebrow">Cursor · mix-blend-mode: difference</p>
      <h1 class="bmc__title">Move the <em>light</em> across the page.</h1>
      <p class="bmc__lede">One white disc inverts whatever sits beneath it.</p>
    </div>
    <a href="#work" class="bmc__tile" data-cursor-grow data-cursor-label="View">
      <canvas class="bmc__tile-art" width="800" height="1000"></canvas>
      <span class="bmc__tile-caption"><span>Dusk Harbour</span><span>01 / 06</span></span>
    </a>
  </section>
  <section class="bmc__band"><h2 class="bmc__band-title">Lime turns violet. Black turns white.</h2></section>
  <section class="bmc__outro"><ul class="bmc__list"><li><a class="bmc__row" data-cursor-grow data-cursor-label="Read">On negative space</a></li></ul></section>
</div>

<!-- the cursor layer is a SIBLING of the page root, last in <body> -->
<div class="bmc-cursor" aria-hidden="true">
  <div class="bmc-cursor__dot"></div>
  <div class="bmc-cursor__blob"><div class="bmc-cursor__blob-inner"></div></div>
  <div class="bmc-cursor__pulse"></div>
  <span class="bmc-cursor__label"></span>
</div>
```

**CSS requirements:**
- `.bmc-cursor`: `position: fixed; inset: 0; pointer-events: none; z-index: 2147483000; mix-blend-mode: difference; display: none; opacity: 0;` — the blend mode goes on the **layer**, not on the shapes, so the label is painted normally on top of the white disc and then the whole group is inverted against the page. `.bmc.has-cursor ~ .bmc-cursor { display: block; }`.
- `.bmc.has-cursor, .bmc.has-cursor a, .bmc.has-cursor button { cursor: none; }` — only once JS adds `has-cursor`.
- Shapes are all `position: absolute; top: 0; left: 0; will-change: transform;` and are centred with `xPercent: -50, yPercent: -50` from JS.
- `.bmc-cursor__dot`: `width: 8px; height: 8px; border-radius: 50%; background: #fff;`
- `.bmc-cursor__blob`: `width: 24px; height: 24px;` (this element receives the grow `scale`); `.bmc-cursor__blob-inner`: `width/height: 100%; border-radius: 50%; background: #fff;` (receives the press `scale`).
- `.bmc-cursor__pulse`: `width: 40px; height: 40px; border-radius: 50%; border: 1.5px solid #fff; opacity: 0;`
- `.bmc-cursor__label`: mono 11px, `letter-spacing: .08em; text-transform: uppercase; color: #000; white-space: nowrap; opacity: 0;`
- `@media (hover: none), (pointer: coarse) { .bmc-cursor { display: none !important; } .bmc.has-cursor, .bmc.has-cursor a { cursor: auto; } }`
- Page: `.bmc__nav` 72px tall flex row with a `1px solid var(--line)` bottom hairline. `.bmc__hero`: `display: grid; grid-template-columns: 1.1fr .9fr; gap: 3rem; align-items: center; min-height: calc(100vh - 72px); padding: 3.75rem 5vw;`. `.bmc__title`: display serif, `font-weight: 300; font-size: clamp(2.6rem, 6.4vw, 6.4rem); line-height: .95; letter-spacing: -0.04em;`. `.bmc__tile`: `position: relative; display: block; height: min(78vh, 620px); border-radius: 14px; overflow: hidden;` with the canvas `object-fit: cover`. `.bmc__band`: `background: var(--accent, #d4ff3a); color: #0A0A0A; padding: 10rem 5vw;` with a serif title `clamp(2.2rem, 5.5vw, 5.5rem)`. `.bmc__row`: flex row, serif `clamp(1.4rem, 2.6vw, 2.4rem)`, `border-bottom: 1px solid var(--line)`.
- The tile art is painted on a canvas: linear base `#0d1430 → #2b1838 → #090909`, then with `globalCompositeOperation = 'screen'` three radial orbs (`rgba(255,138,76,.95)` at 66%/36% radius 42% of width; `rgba(160,56,140,.55)` at 20%/72%; `rgba(70,140,210,.45)` at 10%/15%), a 6%-tall horizon highlight at 60%, and per-pixel grain `±11` on RGB.

**Animation spec (exact values):**
1. Guard: `const finePointer = matchMedia('(hover: hover) and (pointer: fine)').matches;` — if false, do nothing (native cursor). Otherwise `root.classList.add('has-cursor')`.
2. State: `target = {x, y}` (last pointer position), `disc = {x, y}` (lagging disc), `tip = {x, y}` (dot). Create `gsap.quickSetter(el, 'x', 'px')` / `'y'` for dot, blob, label, pulse. `gsap.set([dot, blob, label, pulse], { xPercent: -50, yPercent: -50 })`.
3. Loop on `gsap.ticker.add((time, deltaMs) => …)`: frame-rate independent lerp
   - `k = 1 - Math.pow(1 - 0.12, deltaMs / 16.667)` for the disc, `kd = 1 - Math.pow(1 - 0.55, deltaMs / 16.667)` for the dot.
   - `disc.x += (target.x - disc.x) * k` (same for y); `tip.x += (target.x - tip.x) * kd`.
   - Write `disc` to blob **and** label, `tip` to dot via the quickSetters.
4. `pointermove` (window, passive): `target = {clientX, clientY}`. On the first move: snap `disc = tip = target` and `gsap.to(layer, { opacity: 1, duration: 0.35, ease: 'power2.out' })` (the layer starts at `opacity: 0` so there is no disc parked at the centre before the user moves).
5. Grow: `const growTo = (scale) => gsap.to(blob, { scale, duration: 0.45, ease: 'power3.out', overwrite: true })` (`quickTo` cannot drive the compound `scale` property — use a short overwriting tween, or two `quickTo`s on `scaleX`/`scaleY`). On `pointerover` (delegated on `.bmc`, `e.target.closest('[data-cursor-grow]')`): `growTo(5)` (24px × 5), `label.textContent = el.dataset.cursorLabel`, `gsap.to(label, { opacity: 1, duration: 0.45 * 0.7, delay: 0.45 * 0.3, overwrite: true })`. On `pointerout` (ignore when `relatedTarget` is inside the same element): `growTo(1)`, `gsap.to(label, { opacity: 0, duration: 0.45 * 0.35, overwrite: true })`.
6. Press: `const pressTo = (scale) => gsap.to(blobInner, { scale, duration: 0.3, ease: 'power3.out', overwrite: true })`. `pointerdown` → `pressTo(0.8)`, move the pulse to `disc`, then (only if the pulse ring is enabled: true) `gsap.fromTo(pulse, { scale: 0.6, opacity: 0.9 }, { scale: 2.4, opacity: 0, duration: 0.7, ease: 'expo.out', overwrite: true })`. `pointerup` → `pressTo(1)`.
7. Window edge: `document.documentElement` `mouseleave` → `gsap.to(layer, { opacity: 0, duration: 0.3 })`; `mouseenter` → back to `1`.
8. Page entrance (decorative): `gsap.from('.bmc__eyebrow, .bmc__title, .bmc__lede, .bmc__tile', { y: 28, opacity: 0, duration: 1.1, ease: 'power3.out', stagger: 0.08, delay: 0.1 })`.

**Interaction:** Hover any `[data-cursor-grow]` → disc scale 1→5 in 0.45s `power3.out`, label fades in after 0.45 × 0.3. Leave → 5→1 in 0.45s, label out in 0.45 × 0.35. Mouse down → disc inner 1→0.8 (0.3s) + (if true) ring pulse 0.6→2.4 scale / 0.9→0 opacity (0.7s `expo.out`). Mouse up → inner back to 1. Links keep working normally (the layer has `pointer-events: none`).

**Responsive behavior:** Under 768px the hero collapses to one column (`grid-template-columns: 1fr`) and the tile is `62vw` tall. The custom cursor is **desktop only**: `(hover: none), (pointer: coarse)` hides the layer in CSS and the JS guard skips the whole follower, so phones and tablets keep their native behaviour. No `gsap.matchMedia()` is needed beyond that guard because nothing else changes.

**Accessibility:** `prefers-reduced-motion: reduce` → the follower still works (it is the pointer, not decoration) but with **no lag**: `k = 1` so disc and dot sit exactly on the pointer, grow/press use `duration: 0` and the pulse ring is skipped; the page entrance tween is skipped. The cursor layer is `aria-hidden="true"` and `pointer-events: none`. `cursor: none` is only applied once the follower is running, so keyboard users and coarse pointers never lose the system cursor. Links keep a visible `:focus-visible` outline (`2px solid var(--accent); outline-offset: 4px`).

**Performance rules:** Only `transform` and `opacity` are animated; positions are written with `quickSetter` (no tween allocation per frame). One `gsap.ticker` listener drives both shapes; it is removed on `visibilitychange` (hidden) and on the host `ml:pause` event, and re-added on resume. All listeners are removed and `ctx.revert()` is called on unmount. The blend layer is a single fixed element, so the compositor only re-blends the 120px region that changes.

**Customization knobs:**
- `LERP` (0.12) — lag; 0.08 is dreamier, 0.2 is snappier.
- Disc size (24px) — the resting diameter of the blob.
- `GROW` (5) — hover scale (24px base × 5); grow duration (0.45s).
- Click pulse ring on/off (true).
- `PRESS` (0.8) — squeeze on mouse down.
- Band colour (`#d4ff3a`) — the block the disc turns into its complement.
- `data-cursor-label` — per-element label text; omit the attribute for a label-less grow.
- Disc colour stays `#fff` for a true inversion; use `#D4FF3A` for a tinted difference.

**Acceptance criteria:**
- [ ] On desktop the native cursor is hidden and an 8px dot + 24px disc follow the pointer; the disc visibly lags and settles without overshoot.
- [ ] Over the `#d4ff3a` band the disc appears in its complementary colour (violet for lime); over the headline the letters inside the disc turn black; over the tile the image is inverted.
- [ ] Hovering a `[data-cursor-grow]` link scales the disc to 5× and shows its label; leaving reverses it.
- [ ] Clicking squeezes the disc and (when true) emits an expanding ring.
- [ ] On touch devices nothing is injected and the native cursor/behaviour is untouched.
- [ ] With reduced motion the disc tracks the pointer with no lag and no pulse; no console errors.

Source

Full source code

<div class="bmc">
  <header class="bmc__nav">
    <a href="#top" class="bmc__brand" data-cursor-grow data-cursor-label="Home">Halide &amp; Co.</a>
    <nav class="bmc__links" aria-label="Demo navigation">
      <a href="#work" class="bmc__link" data-cursor-grow data-cursor-label="Open">Work</a>
      <a href="#journal" class="bmc__link" data-cursor-grow data-cursor-label="Read">Journal</a>
      <a href="#contact" class="bmc__link" data-cursor-grow data-cursor-label="Say hi">Contact</a>
    </nav>
  </header>

  <section class="bmc__hero" id="top">
    <div class="bmc__copy">
      <p class="bmc__eyebrow">Cursor · mix-blend-mode: difference</p>
      <h1 class="bmc__title">Move the <em>light</em> across the page.</h1>
      <p class="bmc__lede">One white disc inverts whatever sits beneath it — type, blocks, photographs — and swells over anything you can click.</p>
    </div>
    <a href="#work" class="bmc__tile" data-cursor-grow data-cursor-label="View" aria-label="View the Dusk Harbour series">
      <canvas class="bmc__tile-art" width="800" height="1000" aria-hidden="true"></canvas>
      <span class="bmc__tile-caption"><span>Dusk Harbour</span><span>01 / 06</span></span>
    </a>
  </section>

  <section class="bmc__band" id="work">
    <h2 class="bmc__band-title">Lime turns violet. Black turns white.</h2>
    <p class="bmc__band-copy">Difference blending subtracts colours channel by channel, so the disc never has to know what it is hovering.</p>
  </section>

  <section class="bmc__outro" id="journal">
    <p class="bmc__eyebrow">Selected writing</p>
    <ul class="bmc__list" role="list">
      <li><a href="#journal" class="bmc__row" data-cursor-grow data-cursor-label="Read"><span>On negative space</span><span class="bmc__row-meta">Essay · 6 min</span></a></li>
      <li><a href="#journal" class="bmc__row" data-cursor-grow data-cursor-label="Read"><span>Printing the dark</span><span class="bmc__row-meta">Process · 9 min</span></a></li>
      <li><a href="#contact" class="bmc__row" data-cursor-grow data-cursor-label="Say hi" id="contact"><span>Studio hours</span><span class="bmc__row-meta">Tue – Sat</span></a></li>
    </ul>
  </section>
</div>

<div class="bmc-cursor" aria-hidden="true">
  <div class="bmc-cursor__dot"></div>
  <div class="bmc-cursor__blob"><div class="bmc-cursor__blob-inner"></div></div>
  <div class="bmc-cursor__pulse"></div>
  <span class="bmc-cursor__label"></span>
</div>