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.
- Cursor
- gsap
- intermediate
- light perf
- mobile: desktop-only
- reduced-motion ✓
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.
### 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 & 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. Add this effect to my existing site/section: **Blend-Mode Cursor**. Append a fixed, `pointer-events: none`, `aria-hidden` layer as the last child of `<body>` with `mix-blend-mode: difference` containing an 8px white dot, a 24px white disc (with an inner element for the press scale), a 40px hairline pulse ring and a mono label (`color: #000`). Only on `(hover: hover) and (pointer: fine)`: add `has-cursor` to my root so `cursor: none` applies, then in a `gsap.ticker` loop lerp the disc toward the pointer with `k = 1 - (1 - 0.12) ** (dt / 16.667)` and the dot with 0.55, writing positions through `gsap.quickSetter`. For every element I mark with `data-cursor-grow`, tween the disc `scale` to 5 (`0.45s power3.out`, `overwrite: true`) and fade in the `data-cursor-label` text; reverse on leave. On `pointerdown` squeeze the inner disc to 0.8 and, if true, play a ring pulse (`scale 0.6→2.4, opacity .9→0, 0.7s expo.out`). Respect `prefers-reduced-motion` by removing the lag and the pulse, hide the layer on coarse pointers, and remove listeners + `ctx.revert()` on unmount. Do not change my colours, fonts or layout. ### EFFECT (React / Next.js): Blend-Mode Cursor
Build **Blend-Mode Cursor** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react`
- File: `components/BlendModeCursor.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP);
export default function BlendModeCursor() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="bmc">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`BlendModeCursor.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
---
### 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 & 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.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```html
<div class="bmc">
<header class="bmc__nav">
<a href="#top" class="bmc__brand" data-cursor-grow data-cursor-label="Home">Halide & 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>
```
```css
.bmc { min-height: 100vh; }
.bmc.has-cursor, .bmc.has-cursor a, .bmc.has-cursor button { cursor: none; }
.bmc__nav { display: flex; justify-content: space-between; align-items: center; height: 72px; padding-inline: clamp(1.25rem, 5vw, 3.75rem); border-bottom: 1px solid var(--line); }
.bmc__brand { font-family: var(--font-display); font-weight: 400; font-size: 1.1rem; letter-spacing: -0.02em; text-decoration: none; }
.bmc__links { display: flex; gap: clamp(1rem, 2.5vw, 2.5rem); }
.bmc__link { font-size: .85rem; text-decoration: none; color: var(--muted); padding: .5rem .1rem; transition: color .25s; }
.bmc__link:hover, .bmc__link:focus-visible { color: var(--text); }
.bmc a:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
.bmc__hero { display: grid; grid-template-columns: 1.1fr .9fr; gap: clamp(2rem, 4vw, 3rem); align-items: center; min-height: calc(100vh - 72px); padding: clamp(2rem, 5vw, 3.75rem) clamp(1.25rem, 5vw, 3.75rem); }
.bmc__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); margin-bottom: 1.25rem; }
.bmc__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.6rem, 6.4vw, 6.4rem); line-height: .95; letter-spacing: -0.04em; text-wrap: balance; }
.bmc__title em { font-style: italic; font-weight: 300; }
.bmc__lede { max-width: 40ch; margin-top: 1.5rem; color: var(--muted); font-size: clamp(1rem, 1.25vw, 1.2rem); line-height: 1.5; }
.bmc__tile { position: relative; display: block; width: 100%; height: min(78vh, 620px); border-radius: 14px; overflow: hidden; background: var(--surface); text-decoration: none; }
.bmc__tile-art { width: 100%; height: 100%; object-fit: cover; }
.bmc__tile-caption { position: absolute; left: 1.25rem; right: 1.25rem; bottom: 1.1rem; display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: rgba(255,255,255,.82); }
.bmc__band { background: var(--knob-accent, var(--accent)); color: #0A0A0A; padding: clamp(4rem, 12vw, 10rem) clamp(1.25rem, 5vw, 3.75rem); }
.bmc__band-title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.2rem, 5.5vw, 5.5rem); line-height: .95; letter-spacing: -0.04em; max-width: 14ch; }
.bmc__band-copy { margin-top: 2rem; max-width: 44ch; font-size: clamp(1rem, 1.2vw, 1.15rem); line-height: 1.5; opacity: .75; }
:root[data-theme="light"] .bmc__band { color: #F4F2EE; }
.bmc__outro { padding: clamp(4rem, 10vw, 8rem) clamp(1.25rem, 5vw, 3.75rem); min-height: 70vh; }
.bmc__list { border-top: 1px solid var(--line); max-width: 900px; }
.bmc__row { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding: clamp(1.1rem, 2.2vw, 1.75rem) 0; border-bottom: 1px solid var(--line); text-decoration: none; font-family: var(--font-display); font-weight: 300; font-size: clamp(1.4rem, 2.6vw, 2.4rem); letter-spacing: -0.03em; }
.bmc__row-meta { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); }
/* ---- the cursor layer: blend applied to the GROUP, so the label is painted first and then inverted with the disc ---- */
.bmc-cursor { position: fixed; inset: 0; pointer-events: none; z-index: 2147483000; mix-blend-mode: difference; display: none; opacity: 0; }
.bmc.has-cursor ~ .bmc-cursor { display: block; }
.bmc-cursor__dot, .bmc-cursor__blob, .bmc-cursor__pulse, .bmc-cursor__label { position: absolute; top: 0; left: 0; will-change: transform; }
.bmc-cursor__dot { width: 8px; height: 8px; border-radius: 50%; background: #fff; }
.bmc-cursor__blob { width: var(--knob-disc, 24px); height: var(--knob-disc, 24px); }
.bmc-cursor__blob-inner { width: 100%; height: 100%; border-radius: 50%; background: #fff; }
.bmc-cursor__pulse { width: 40px; height: 40px; border-radius: 50%; border: 1.5px solid #fff; opacity: 0; }
.bmc-cursor__label { font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: #000; white-space: nowrap; opacity: 0; }
@media (max-width: 767px) {
.bmc__hero { grid-template-columns: 1fr; min-height: 0; }
.bmc__tile { height: 62vw; }
.bmc__links { gap: .9rem; }
}
@media (hover: none), (pointer: coarse) { .bmc-cursor { display: none !important; } .bmc.has-cursor, .bmc.has-cursor a { cursor: auto; } }
@media (prefers-reduced-motion: reduce) { .bmc-cursor__dot, .bmc-cursor__blob, .bmc-cursor__pulse, .bmc-cursor__label { will-change: auto; } }
```
```js
// Blend-Mode Cursor — a white disc in mix-blend-mode: difference trails the pointer and inverts everything it crosses.
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { lerp: 0.12, discSize: 24, grow: 5, growDuration: 0.45, pulse: true, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const DOT_LERP = 0.55; // the small dot is almost glued to the pointer
const PRESS = 0.8; // disc squeeze while the button is down
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const finePointer = matchMedia('(hover: hover) and (pointer: fine)').matches;
const root = document.querySelector('.bmc');
const layer = document.querySelector('.bmc-cursor');
const dot = layer.querySelector('.bmc-cursor__dot');
const blob = layer.querySelector('.bmc-cursor__blob');
const blobInner = layer.querySelector('.bmc-cursor__blob-inner');
const pulse = layer.querySelector('.bmc-cursor__pulse');
const label = layer.querySelector('.bmc-cursor__label');
paintTile(document.querySelector('.bmc__tile-art'));
// Page entrance lives in its own context so a Playground rebuild never replays it.
const introCtx = gsap.context(() => {
if (!reduced) 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 });
});
// Pointer state survives rebuilds so the disc continues from where it is instead of snapping to the centre.
const target = { x: innerWidth / 2, y: innerHeight / 2 };
let shown = false;
let ctx;
const cleanups = [];
function build() {
cleanups.splice(0).forEach((fn) => fn());
ctx?.revert();
root.style.setProperty('--knob-accent', K('accent'));
layer.style.setProperty('--knob-disc', K('discSize') + 'px');
const LERP = K('lerp');
const GROW = K('grow');
const dur = reduced ? 0 : K('growDuration');
const withPulse = K('pulse');
ctx = gsap.context(() => {
if (!finePointer) return; // touch / coarse pointers keep the native cursor
root.classList.add('has-cursor');
const disc = { ...target };
const tip = { ...target };
if (shown) gsap.set(layer, { opacity: 1 });
const setDot = { x: gsap.quickSetter(dot, 'x', 'px'), y: gsap.quickSetter(dot, 'y', 'px') };
const setBlob = { x: gsap.quickSetter(blob, 'x', 'px'), y: gsap.quickSetter(blob, 'y', 'px') };
const setLabel = { x: gsap.quickSetter(label, 'x', 'px'), y: gsap.quickSetter(label, 'y', 'px') };
const setPulse = { x: gsap.quickSetter(pulse, 'x', 'px'), y: gsap.quickSetter(pulse, 'y', 'px') };
gsap.set([dot, blob, label, pulse], { xPercent: -50, yPercent: -50 });
gsap.set(blob, { transformOrigin: '50% 50%' });
// quickTo cannot drive the compound `scale`, so grow/press are short overwriting tweens.
const growTo = (scale) => gsap.to(blob, { scale, duration: dur, ease: 'power3.out', overwrite: true });
const pressTo = (scale) => gsap.to(blobInner, { scale, duration: reduced ? 0 : 0.3, ease: 'power3.out', overwrite: true });
// Frame-rate independent lerp: the same feel at 60Hz and 120Hz.
const tick = (_, dt) => {
const k = reduced ? 1 : 1 - Math.pow(1 - LERP, dt / 16.667);
const kd = reduced ? 1 : 1 - Math.pow(1 - DOT_LERP, dt / 16.667);
disc.x += (target.x - disc.x) * k; disc.y += (target.y - disc.y) * k;
tip.x += (target.x - tip.x) * kd; tip.y += (target.y - tip.y) * kd;
setBlob.x(disc.x); setBlob.y(disc.y);
setLabel.x(disc.x); setLabel.y(disc.y);
setDot.x(tip.x); setDot.y(tip.y);
};
let ticking = false;
const start = () => { if (!ticking) { gsap.ticker.add(tick); ticking = true; } };
const stop = () => { if (ticking) { gsap.ticker.remove(tick); ticking = false; } };
start();
const onMove = (e) => {
target.x = e.clientX; target.y = e.clientY;
if (!shown) { // first contact: snap, then fade the layer in
shown = true;
disc.x = tip.x = target.x; disc.y = tip.y = target.y;
gsap.to(layer, { opacity: 1, duration: 0.35, ease: 'power2.out' });
}
};
const onOver = (e) => {
const el = e.target.closest('[data-cursor-grow]');
if (!el) return;
growTo(GROW);
label.textContent = el.dataset.cursorLabel || '';
gsap.to(label, { opacity: 1, duration: dur * 0.7, delay: dur * 0.3, overwrite: true });
};
const onOut = (e) => {
const el = e.target.closest('[data-cursor-grow]');
if (!el || (e.relatedTarget && el.contains(e.relatedTarget))) return;
growTo(1);
gsap.to(label, { opacity: 0, duration: dur * 0.35, overwrite: true });
};
const onDown = () => {
pressTo(PRESS);
setPulse.x(disc.x); setPulse.y(disc.y);
if (!reduced && withPulse) gsap.fromTo(pulse, { scale: 0.6, opacity: 0.9 }, { scale: 2.4, opacity: 0, duration: 0.7, ease: 'expo.out', overwrite: true });
};
const onUp = () => pressTo(1);
const onLeaveWindow = () => gsap.to(layer, { opacity: 0, duration: 0.3 });
const onEnterWindow = () => shown && gsap.to(layer, { opacity: 1, duration: 0.3 });
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('pointermove', onMove, { passive: true });
root.addEventListener('pointerover', onOver);
root.addEventListener('pointerout', onOut);
window.addEventListener('pointerdown', onDown);
window.addEventListener('pointerup', onUp);
document.documentElement.addEventListener('mouseleave', onLeaveWindow);
document.documentElement.addEventListener('mouseenter', onEnterWindow);
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
cleanups.push(() => {
stop();
window.removeEventListener('pointermove', onMove);
root.removeEventListener('pointerover', onOver);
root.removeEventListener('pointerout', onOut);
window.removeEventListener('pointerdown', onDown);
window.removeEventListener('pointerup', onUp);
document.documentElement.removeEventListener('mouseleave', onLeaveWindow);
document.documentElement.removeEventListener('mouseenter', onEnterWindow);
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
root.classList.remove('has-cursor');
});
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { cleanups.forEach((fn) => fn()); ctx?.revert(); introCtx.revert(); });
// Procedural "photograph": layered gradients + grain so the disc has something rich to invert.
function paintTile(canvas) {
const w = canvas.width, h = canvas.height, g = canvas.getContext('2d');
const base = g.createLinearGradient(0, 0, w * 0.3, h);
base.addColorStop(0, '#0d1430'); base.addColorStop(0.55, '#2b1838'); base.addColorStop(1, '#090909');
g.fillStyle = base; g.fillRect(0, 0, w, h);
g.globalCompositeOperation = 'screen';
orb(g, w * 0.66, h * 0.36, w * 0.42, 'rgba(255,138,76,0.95)');
orb(g, w * 0.2, h * 0.72, w * 0.55, 'rgba(160,56,140,0.55)');
orb(g, w * 0.1, h * 0.15, w * 0.4, 'rgba(70,140,210,0.45)');
g.globalCompositeOperation = 'source-over';
const horizon = g.createLinearGradient(0, h * 0.6, 0, h * 0.66);
horizon.addColorStop(0, 'rgba(255,220,180,0)'); horizon.addColorStop(0.5, 'rgba(255,220,180,0.35)'); horizon.addColorStop(1, 'rgba(255,220,180,0)');
g.fillStyle = horizon; g.fillRect(0, h * 0.6, w, h * 0.06);
const img = g.getImageData(0, 0, w, h), d = img.data;
for (let i = 0; i < d.length; i += 4) { const n = (Math.random() - 0.5) * 22; d[i] += n; d[i + 1] += n; d[i + 2] += n; }
g.putImageData(img, 0, 0);
}
function orb(g, x, y, r, color) {
const rg = g.createRadialGradient(x, y, 0, x, y, r);
rg.addColorStop(0, color); rg.addColorStop(1, 'rgba(0,0,0,0)');
g.fillStyle = rg; g.fillRect(0, 0, g.canvas.width, g.canvas.height);
}
```
### EFFECT (single HTML file): Blend-Mode Cursor
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Blend-Mode Cursor** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Blend-Mode Cursor</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### 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 & 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.
---
**Reference implementation (use these exact values and class names):**
```html
<div class="bmc">
<header class="bmc__nav">
<a href="#top" class="bmc__brand" data-cursor-grow data-cursor-label="Home">Halide & 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>
```
```css
.bmc { min-height: 100vh; }
.bmc.has-cursor, .bmc.has-cursor a, .bmc.has-cursor button { cursor: none; }
.bmc__nav { display: flex; justify-content: space-between; align-items: center; height: 72px; padding-inline: clamp(1.25rem, 5vw, 3.75rem); border-bottom: 1px solid var(--line); }
.bmc__brand { font-family: var(--font-display); font-weight: 400; font-size: 1.1rem; letter-spacing: -0.02em; text-decoration: none; }
.bmc__links { display: flex; gap: clamp(1rem, 2.5vw, 2.5rem); }
.bmc__link { font-size: .85rem; text-decoration: none; color: var(--muted); padding: .5rem .1rem; transition: color .25s; }
.bmc__link:hover, .bmc__link:focus-visible { color: var(--text); }
.bmc a:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
.bmc__hero { display: grid; grid-template-columns: 1.1fr .9fr; gap: clamp(2rem, 4vw, 3rem); align-items: center; min-height: calc(100vh - 72px); padding: clamp(2rem, 5vw, 3.75rem) clamp(1.25rem, 5vw, 3.75rem); }
.bmc__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); margin-bottom: 1.25rem; }
.bmc__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.6rem, 6.4vw, 6.4rem); line-height: .95; letter-spacing: -0.04em; text-wrap: balance; }
.bmc__title em { font-style: italic; font-weight: 300; }
.bmc__lede { max-width: 40ch; margin-top: 1.5rem; color: var(--muted); font-size: clamp(1rem, 1.25vw, 1.2rem); line-height: 1.5; }
.bmc__tile { position: relative; display: block; width: 100%; height: min(78vh, 620px); border-radius: 14px; overflow: hidden; background: var(--surface); text-decoration: none; }
.bmc__tile-art { width: 100%; height: 100%; object-fit: cover; }
.bmc__tile-caption { position: absolute; left: 1.25rem; right: 1.25rem; bottom: 1.1rem; display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: rgba(255,255,255,.82); }
.bmc__band { background: var(--knob-accent, var(--accent)); color: #0A0A0A; padding: clamp(4rem, 12vw, 10rem) clamp(1.25rem, 5vw, 3.75rem); }
.bmc__band-title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.2rem, 5.5vw, 5.5rem); line-height: .95; letter-spacing: -0.04em; max-width: 14ch; }
.bmc__band-copy { margin-top: 2rem; max-width: 44ch; font-size: clamp(1rem, 1.2vw, 1.15rem); line-height: 1.5; opacity: .75; }
:root[data-theme="light"] .bmc__band { color: #F4F2EE; }
.bmc__outro { padding: clamp(4rem, 10vw, 8rem) clamp(1.25rem, 5vw, 3.75rem); min-height: 70vh; }
.bmc__list { border-top: 1px solid var(--line); max-width: 900px; }
.bmc__row { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding: clamp(1.1rem, 2.2vw, 1.75rem) 0; border-bottom: 1px solid var(--line); text-decoration: none; font-family: var(--font-display); font-weight: 300; font-size: clamp(1.4rem, 2.6vw, 2.4rem); letter-spacing: -0.03em; }
.bmc__row-meta { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); }
/* ---- the cursor layer: blend applied to the GROUP, so the label is painted first and then inverted with the disc ---- */
.bmc-cursor { position: fixed; inset: 0; pointer-events: none; z-index: 2147483000; mix-blend-mode: difference; display: none; opacity: 0; }
.bmc.has-cursor ~ .bmc-cursor { display: block; }
.bmc-cursor__dot, .bmc-cursor__blob, .bmc-cursor__pulse, .bmc-cursor__label { position: absolute; top: 0; left: 0; will-change: transform; }
.bmc-cursor__dot { width: 8px; height: 8px; border-radius: 50%; background: #fff; }
.bmc-cursor__blob { width: var(--knob-disc, 24px); height: var(--knob-disc, 24px); }
.bmc-cursor__blob-inner { width: 100%; height: 100%; border-radius: 50%; background: #fff; }
.bmc-cursor__pulse { width: 40px; height: 40px; border-radius: 50%; border: 1.5px solid #fff; opacity: 0; }
.bmc-cursor__label { font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: #000; white-space: nowrap; opacity: 0; }
@media (max-width: 767px) {
.bmc__hero { grid-template-columns: 1fr; min-height: 0; }
.bmc__tile { height: 62vw; }
.bmc__links { gap: .9rem; }
}
@media (hover: none), (pointer: coarse) { .bmc-cursor { display: none !important; } .bmc.has-cursor, .bmc.has-cursor a { cursor: auto; } }
@media (prefers-reduced-motion: reduce) { .bmc-cursor__dot, .bmc-cursor__blob, .bmc-cursor__pulse, .bmc-cursor__label { will-change: auto; } }
```
```js
// Blend-Mode Cursor — a white disc in mix-blend-mode: difference trails the pointer and inverts everything it crosses.
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { lerp: 0.12, discSize: 24, grow: 5, growDuration: 0.45, pulse: true, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const DOT_LERP = 0.55; // the small dot is almost glued to the pointer
const PRESS = 0.8; // disc squeeze while the button is down
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const finePointer = matchMedia('(hover: hover) and (pointer: fine)').matches;
const root = document.querySelector('.bmc');
const layer = document.querySelector('.bmc-cursor');
const dot = layer.querySelector('.bmc-cursor__dot');
const blob = layer.querySelector('.bmc-cursor__blob');
const blobInner = layer.querySelector('.bmc-cursor__blob-inner');
const pulse = layer.querySelector('.bmc-cursor__pulse');
const label = layer.querySelector('.bmc-cursor__label');
paintTile(document.querySelector('.bmc__tile-art'));
// Page entrance lives in its own context so a Playground rebuild never replays it.
const introCtx = gsap.context(() => {
if (!reduced) 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 });
});
// Pointer state survives rebuilds so the disc continues from where it is instead of snapping to the centre.
const target = { x: innerWidth / 2, y: innerHeight / 2 };
let shown = false;
let ctx;
const cleanups = [];
function build() {
cleanups.splice(0).forEach((fn) => fn());
ctx?.revert();
root.style.setProperty('--knob-accent', K('accent'));
layer.style.setProperty('--knob-disc', K('discSize') + 'px');
const LERP = K('lerp');
const GROW = K('grow');
const dur = reduced ? 0 : K('growDuration');
const withPulse = K('pulse');
ctx = gsap.context(() => {
if (!finePointer) return; // touch / coarse pointers keep the native cursor
root.classList.add('has-cursor');
const disc = { ...target };
const tip = { ...target };
if (shown) gsap.set(layer, { opacity: 1 });
const setDot = { x: gsap.quickSetter(dot, 'x', 'px'), y: gsap.quickSetter(dot, 'y', 'px') };
const setBlob = { x: gsap.quickSetter(blob, 'x', 'px'), y: gsap.quickSetter(blob, 'y', 'px') };
const setLabel = { x: gsap.quickSetter(label, 'x', 'px'), y: gsap.quickSetter(label, 'y', 'px') };
const setPulse = { x: gsap.quickSetter(pulse, 'x', 'px'), y: gsap.quickSetter(pulse, 'y', 'px') };
gsap.set([dot, blob, label, pulse], { xPercent: -50, yPercent: -50 });
gsap.set(blob, { transformOrigin: '50% 50%' });
// quickTo cannot drive the compound `scale`, so grow/press are short overwriting tweens.
const growTo = (scale) => gsap.to(blob, { scale, duration: dur, ease: 'power3.out', overwrite: true });
const pressTo = (scale) => gsap.to(blobInner, { scale, duration: reduced ? 0 : 0.3, ease: 'power3.out', overwrite: true });
// Frame-rate independent lerp: the same feel at 60Hz and 120Hz.
const tick = (_, dt) => {
const k = reduced ? 1 : 1 - Math.pow(1 - LERP, dt / 16.667);
const kd = reduced ? 1 : 1 - Math.pow(1 - DOT_LERP, dt / 16.667);
disc.x += (target.x - disc.x) * k; disc.y += (target.y - disc.y) * k;
tip.x += (target.x - tip.x) * kd; tip.y += (target.y - tip.y) * kd;
setBlob.x(disc.x); setBlob.y(disc.y);
setLabel.x(disc.x); setLabel.y(disc.y);
setDot.x(tip.x); setDot.y(tip.y);
};
let ticking = false;
const start = () => { if (!ticking) { gsap.ticker.add(tick); ticking = true; } };
const stop = () => { if (ticking) { gsap.ticker.remove(tick); ticking = false; } };
start();
const onMove = (e) => {
target.x = e.clientX; target.y = e.clientY;
if (!shown) { // first contact: snap, then fade the layer in
shown = true;
disc.x = tip.x = target.x; disc.y = tip.y = target.y;
gsap.to(layer, { opacity: 1, duration: 0.35, ease: 'power2.out' });
}
};
const onOver = (e) => {
const el = e.target.closest('[data-cursor-grow]');
if (!el) return;
growTo(GROW);
label.textContent = el.dataset.cursorLabel || '';
gsap.to(label, { opacity: 1, duration: dur * 0.7, delay: dur * 0.3, overwrite: true });
};
const onOut = (e) => {
const el = e.target.closest('[data-cursor-grow]');
if (!el || (e.relatedTarget && el.contains(e.relatedTarget))) return;
growTo(1);
gsap.to(label, { opacity: 0, duration: dur * 0.35, overwrite: true });
};
const onDown = () => {
pressTo(PRESS);
setPulse.x(disc.x); setPulse.y(disc.y);
if (!reduced && withPulse) gsap.fromTo(pulse, { scale: 0.6, opacity: 0.9 }, { scale: 2.4, opacity: 0, duration: 0.7, ease: 'expo.out', overwrite: true });
};
const onUp = () => pressTo(1);
const onLeaveWindow = () => gsap.to(layer, { opacity: 0, duration: 0.3 });
const onEnterWindow = () => shown && gsap.to(layer, { opacity: 1, duration: 0.3 });
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('pointermove', onMove, { passive: true });
root.addEventListener('pointerover', onOver);
root.addEventListener('pointerout', onOut);
window.addEventListener('pointerdown', onDown);
window.addEventListener('pointerup', onUp);
document.documentElement.addEventListener('mouseleave', onLeaveWindow);
document.documentElement.addEventListener('mouseenter', onEnterWindow);
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
cleanups.push(() => {
stop();
window.removeEventListener('pointermove', onMove);
root.removeEventListener('pointerover', onOver);
root.removeEventListener('pointerout', onOut);
window.removeEventListener('pointerdown', onDown);
window.removeEventListener('pointerup', onUp);
document.documentElement.removeEventListener('mouseleave', onLeaveWindow);
document.documentElement.removeEventListener('mouseenter', onEnterWindow);
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
root.classList.remove('has-cursor');
});
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { cleanups.forEach((fn) => fn()); ctx?.revert(); introCtx.revert(); });
// Procedural "photograph": layered gradients + grain so the disc has something rich to invert.
function paintTile(canvas) {
const w = canvas.width, h = canvas.height, g = canvas.getContext('2d');
const base = g.createLinearGradient(0, 0, w * 0.3, h);
base.addColorStop(0, '#0d1430'); base.addColorStop(0.55, '#2b1838'); base.addColorStop(1, '#090909');
g.fillStyle = base; g.fillRect(0, 0, w, h);
g.globalCompositeOperation = 'screen';
orb(g, w * 0.66, h * 0.36, w * 0.42, 'rgba(255,138,76,0.95)');
orb(g, w * 0.2, h * 0.72, w * 0.55, 'rgba(160,56,140,0.55)');
orb(g, w * 0.1, h * 0.15, w * 0.4, 'rgba(70,140,210,0.45)');
g.globalCompositeOperation = 'source-over';
const horizon = g.createLinearGradient(0, h * 0.6, 0, h * 0.66);
horizon.addColorStop(0, 'rgba(255,220,180,0)'); horizon.addColorStop(0.5, 'rgba(255,220,180,0.35)'); horizon.addColorStop(1, 'rgba(255,220,180,0)');
g.fillStyle = horizon; g.fillRect(0, h * 0.6, w, h * 0.06);
const img = g.getImageData(0, 0, w, h), d = img.data;
for (let i = 0; i < d.length; i += 4) { const n = (Math.random() - 0.5) * 22; d[i] += n; d[i + 1] += n; d[i + 2] += n; }
g.putImageData(img, 0, 0);
}
function orb(g, x, y, r, color) {
const rg = g.createRadialGradient(x, y, 0, x, y, r);
rg.addColorStop(0, color); rg.addColorStop(1, 'rgba(0,0,0,0)');
g.fillStyle = rg; g.fillRect(0, 0, g.canvas.width, g.canvas.height);
}
```
### EFFECT (Framer & Webflow notes): Blend-Mode Cursor
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withBlendModeCursor(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `bmc`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the timeline sequencing in code — IX2 cannot do stagger/ease control at this precision.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### 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 & 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 & 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>
.bmc { min-height: 100vh; }
.bmc.has-cursor, .bmc.has-cursor a, .bmc.has-cursor button { cursor: none; }
.bmc__nav { display: flex; justify-content: space-between; align-items: center; height: 72px; padding-inline: clamp(1.25rem, 5vw, 3.75rem); border-bottom: 1px solid var(--line); }
.bmc__brand { font-family: var(--font-display); font-weight: 400; font-size: 1.1rem; letter-spacing: -0.02em; text-decoration: none; }
.bmc__links { display: flex; gap: clamp(1rem, 2.5vw, 2.5rem); }
.bmc__link { font-size: .85rem; text-decoration: none; color: var(--muted); padding: .5rem .1rem; transition: color .25s; }
.bmc__link:hover, .bmc__link:focus-visible { color: var(--text); }
.bmc a:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
.bmc__hero { display: grid; grid-template-columns: 1.1fr .9fr; gap: clamp(2rem, 4vw, 3rem); align-items: center; min-height: calc(100vh - 72px); padding: clamp(2rem, 5vw, 3.75rem) clamp(1.25rem, 5vw, 3.75rem); }
.bmc__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); margin-bottom: 1.25rem; }
.bmc__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.6rem, 6.4vw, 6.4rem); line-height: .95; letter-spacing: -0.04em; text-wrap: balance; }
.bmc__title em { font-style: italic; font-weight: 300; }
.bmc__lede { max-width: 40ch; margin-top: 1.5rem; color: var(--muted); font-size: clamp(1rem, 1.25vw, 1.2rem); line-height: 1.5; }
.bmc__tile { position: relative; display: block; width: 100%; height: min(78vh, 620px); border-radius: 14px; overflow: hidden; background: var(--surface); text-decoration: none; }
.bmc__tile-art { width: 100%; height: 100%; object-fit: cover; }
.bmc__tile-caption { position: absolute; left: 1.25rem; right: 1.25rem; bottom: 1.1rem; display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: rgba(255,255,255,.82); }
.bmc__band { background: var(--knob-accent, var(--accent)); color: #0A0A0A; padding: clamp(4rem, 12vw, 10rem) clamp(1.25rem, 5vw, 3.75rem); }
.bmc__band-title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.2rem, 5.5vw, 5.5rem); line-height: .95; letter-spacing: -0.04em; max-width: 14ch; }
.bmc__band-copy { margin-top: 2rem; max-width: 44ch; font-size: clamp(1rem, 1.2vw, 1.15rem); line-height: 1.5; opacity: .75; }
:root[data-theme="light"] .bmc__band { color: #F4F2EE; }
.bmc__outro { padding: clamp(4rem, 10vw, 8rem) clamp(1.25rem, 5vw, 3.75rem); min-height: 70vh; }
.bmc__list { border-top: 1px solid var(--line); max-width: 900px; }
.bmc__row { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding: clamp(1.1rem, 2.2vw, 1.75rem) 0; border-bottom: 1px solid var(--line); text-decoration: none; font-family: var(--font-display); font-weight: 300; font-size: clamp(1.4rem, 2.6vw, 2.4rem); letter-spacing: -0.03em; }
.bmc__row-meta { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); }
/* ---- the cursor layer: blend applied to the GROUP, so the label is painted first and then inverted with the disc ---- */
.bmc-cursor { position: fixed; inset: 0; pointer-events: none; z-index: 2147483000; mix-blend-mode: difference; display: none; opacity: 0; }
.bmc.has-cursor ~ .bmc-cursor { display: block; }
.bmc-cursor__dot, .bmc-cursor__blob, .bmc-cursor__pulse, .bmc-cursor__label { position: absolute; top: 0; left: 0; will-change: transform; }
.bmc-cursor__dot { width: 8px; height: 8px; border-radius: 50%; background: #fff; }
.bmc-cursor__blob { width: var(--knob-disc, 24px); height: var(--knob-disc, 24px); }
.bmc-cursor__blob-inner { width: 100%; height: 100%; border-radius: 50%; background: #fff; }
.bmc-cursor__pulse { width: 40px; height: 40px; border-radius: 50%; border: 1.5px solid #fff; opacity: 0; }
.bmc-cursor__label { font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: #000; white-space: nowrap; opacity: 0; }
@media (max-width: 767px) {
.bmc__hero { grid-template-columns: 1fr; min-height: 0; }
.bmc__tile { height: 62vw; }
.bmc__links { gap: .9rem; }
}
@media (hover: none), (pointer: coarse) { .bmc-cursor { display: none !important; } .bmc.has-cursor, .bmc.has-cursor a { cursor: auto; } }
@media (prefers-reduced-motion: reduce) { .bmc-cursor__dot, .bmc-cursor__blob, .bmc-cursor__pulse, .bmc-cursor__label { will-change: auto; } }
// Blend-Mode Cursor — a white disc in mix-blend-mode: difference trails the pointer and inverts everything it crosses.
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { lerp: 0.12, discSize: 24, grow: 5, growDuration: 0.45, pulse: true, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const DOT_LERP = 0.55; // the small dot is almost glued to the pointer
const PRESS = 0.8; // disc squeeze while the button is down
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const finePointer = matchMedia('(hover: hover) and (pointer: fine)').matches;
const root = document.querySelector('.bmc');
const layer = document.querySelector('.bmc-cursor');
const dot = layer.querySelector('.bmc-cursor__dot');
const blob = layer.querySelector('.bmc-cursor__blob');
const blobInner = layer.querySelector('.bmc-cursor__blob-inner');
const pulse = layer.querySelector('.bmc-cursor__pulse');
const label = layer.querySelector('.bmc-cursor__label');
paintTile(document.querySelector('.bmc__tile-art'));
// Page entrance lives in its own context so a Playground rebuild never replays it.
const introCtx = gsap.context(() => {
if (!reduced) 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 });
});
// Pointer state survives rebuilds so the disc continues from where it is instead of snapping to the centre.
const target = { x: innerWidth / 2, y: innerHeight / 2 };
let shown = false;
let ctx;
const cleanups = [];
function build() {
cleanups.splice(0).forEach((fn) => fn());
ctx?.revert();
root.style.setProperty('--knob-accent', K('accent'));
layer.style.setProperty('--knob-disc', K('discSize') + 'px');
const LERP = K('lerp');
const GROW = K('grow');
const dur = reduced ? 0 : K('growDuration');
const withPulse = K('pulse');
ctx = gsap.context(() => {
if (!finePointer) return; // touch / coarse pointers keep the native cursor
root.classList.add('has-cursor');
const disc = { ...target };
const tip = { ...target };
if (shown) gsap.set(layer, { opacity: 1 });
const setDot = { x: gsap.quickSetter(dot, 'x', 'px'), y: gsap.quickSetter(dot, 'y', 'px') };
const setBlob = { x: gsap.quickSetter(blob, 'x', 'px'), y: gsap.quickSetter(blob, 'y', 'px') };
const setLabel = { x: gsap.quickSetter(label, 'x', 'px'), y: gsap.quickSetter(label, 'y', 'px') };
const setPulse = { x: gsap.quickSetter(pulse, 'x', 'px'), y: gsap.quickSetter(pulse, 'y', 'px') };
gsap.set([dot, blob, label, pulse], { xPercent: -50, yPercent: -50 });
gsap.set(blob, { transformOrigin: '50% 50%' });
// quickTo cannot drive the compound `scale`, so grow/press are short overwriting tweens.
const growTo = (scale) => gsap.to(blob, { scale, duration: dur, ease: 'power3.out', overwrite: true });
const pressTo = (scale) => gsap.to(blobInner, { scale, duration: reduced ? 0 : 0.3, ease: 'power3.out', overwrite: true });
// Frame-rate independent lerp: the same feel at 60Hz and 120Hz.
const tick = (_, dt) => {
const k = reduced ? 1 : 1 - Math.pow(1 - LERP, dt / 16.667);
const kd = reduced ? 1 : 1 - Math.pow(1 - DOT_LERP, dt / 16.667);
disc.x += (target.x - disc.x) * k; disc.y += (target.y - disc.y) * k;
tip.x += (target.x - tip.x) * kd; tip.y += (target.y - tip.y) * kd;
setBlob.x(disc.x); setBlob.y(disc.y);
setLabel.x(disc.x); setLabel.y(disc.y);
setDot.x(tip.x); setDot.y(tip.y);
};
let ticking = false;
const start = () => { if (!ticking) { gsap.ticker.add(tick); ticking = true; } };
const stop = () => { if (ticking) { gsap.ticker.remove(tick); ticking = false; } };
start();
const onMove = (e) => {
target.x = e.clientX; target.y = e.clientY;
if (!shown) { // first contact: snap, then fade the layer in
shown = true;
disc.x = tip.x = target.x; disc.y = tip.y = target.y;
gsap.to(layer, { opacity: 1, duration: 0.35, ease: 'power2.out' });
}
};
const onOver = (e) => {
const el = e.target.closest('[data-cursor-grow]');
if (!el) return;
growTo(GROW);
label.textContent = el.dataset.cursorLabel || '';
gsap.to(label, { opacity: 1, duration: dur * 0.7, delay: dur * 0.3, overwrite: true });
};
const onOut = (e) => {
const el = e.target.closest('[data-cursor-grow]');
if (!el || (e.relatedTarget && el.contains(e.relatedTarget))) return;
growTo(1);
gsap.to(label, { opacity: 0, duration: dur * 0.35, overwrite: true });
};
const onDown = () => {
pressTo(PRESS);
setPulse.x(disc.x); setPulse.y(disc.y);
if (!reduced && withPulse) gsap.fromTo(pulse, { scale: 0.6, opacity: 0.9 }, { scale: 2.4, opacity: 0, duration: 0.7, ease: 'expo.out', overwrite: true });
};
const onUp = () => pressTo(1);
const onLeaveWindow = () => gsap.to(layer, { opacity: 0, duration: 0.3 });
const onEnterWindow = () => shown && gsap.to(layer, { opacity: 1, duration: 0.3 });
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('pointermove', onMove, { passive: true });
root.addEventListener('pointerover', onOver);
root.addEventListener('pointerout', onOut);
window.addEventListener('pointerdown', onDown);
window.addEventListener('pointerup', onUp);
document.documentElement.addEventListener('mouseleave', onLeaveWindow);
document.documentElement.addEventListener('mouseenter', onEnterWindow);
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
cleanups.push(() => {
stop();
window.removeEventListener('pointermove', onMove);
root.removeEventListener('pointerover', onOver);
root.removeEventListener('pointerout', onOut);
window.removeEventListener('pointerdown', onDown);
window.removeEventListener('pointerup', onUp);
document.documentElement.removeEventListener('mouseleave', onLeaveWindow);
document.documentElement.removeEventListener('mouseenter', onEnterWindow);
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
root.classList.remove('has-cursor');
});
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { cleanups.forEach((fn) => fn()); ctx?.revert(); introCtx.revert(); });
// Procedural "photograph": layered gradients + grain so the disc has something rich to invert.
function paintTile(canvas) {
const w = canvas.width, h = canvas.height, g = canvas.getContext('2d');
const base = g.createLinearGradient(0, 0, w * 0.3, h);
base.addColorStop(0, '#0d1430'); base.addColorStop(0.55, '#2b1838'); base.addColorStop(1, '#090909');
g.fillStyle = base; g.fillRect(0, 0, w, h);
g.globalCompositeOperation = 'screen';
orb(g, w * 0.66, h * 0.36, w * 0.42, 'rgba(255,138,76,0.95)');
orb(g, w * 0.2, h * 0.72, w * 0.55, 'rgba(160,56,140,0.55)');
orb(g, w * 0.1, h * 0.15, w * 0.4, 'rgba(70,140,210,0.45)');
g.globalCompositeOperation = 'source-over';
const horizon = g.createLinearGradient(0, h * 0.6, 0, h * 0.66);
horizon.addColorStop(0, 'rgba(255,220,180,0)'); horizon.addColorStop(0.5, 'rgba(255,220,180,0.35)'); horizon.addColorStop(1, 'rgba(255,220,180,0)');
g.fillStyle = horizon; g.fillRect(0, h * 0.6, w, h * 0.06);
const img = g.getImageData(0, 0, w, h), d = img.data;
for (let i = 0; i < d.length; i += 4) { const n = (Math.random() - 0.5) * 22; d[i] += n; d[i + 1] += n; d[i + 2] += n; }
g.putImageData(img, 0, 0);
}
function orb(g, x, y, r, color) {
const rg = g.createRadialGradient(x, y, 0, x, y, r);
rg.addColorStop(0, color); rg.addColorStop(1, 'rgba(0,0,0,0)');
g.fillStyle = rg; g.fillRect(0, 0, g.canvas.width, g.canvas.height);
}