Blend-Difference Cursor
A white circle replaces the pointer and inverts everything under it via `mix-blend-mode: difference`, so it is legible over any background without a single contrast rule. It has three sizes — 16px idle, 44px over a link, 150px over a `data-cursor="view"` target, the last carrying a text label — and the size change is CSS while the position is GSAP, deliberately kept on different properties.
Blend-Difference Cursor
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/blend-difference-cursor.jsonPrompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
One file, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Content the cursor inverts. Mark targets with data-cursor="view" or use plain a/button. |
| label | string | = 'View' | Text shown inside the 150px state. Must fit at 15px inside the circle. |
| className | string | — | Merged onto the root container via cn. |
How it works
The technique in order, not the code in order.
Difference blending subtracts the backdrop from the source per channel. A pure white source therefore outputs
1 - backdrop: black over white, white over black, the complement over any colour. That is the entire contrast strategy — no theme awareness, no luminance sampling, no per-section overrides.Position is two
gsap.quickTosetters created once, each backed by a single tween whose end value is mutated per event. At 0.32s withpower3the dot trails the pointer by a few frames — enough to read as a physical object, short enough that clicking still feels direct.xPercent: -50, yPercent: -50centres the dot on its own box once at mount, so the handler can pass raw pointer coordinates and, critically, the centring survives the width and height changing from 16px to 150px. A pixel offset would have to be recomputed for every size.Size transitions list
width,heightandbackground-colorexplicitly.transformis excluded because GSAP writes it every frame; a CSS transition on the same property would interpolate toward a value GSAP has already replaced, and the result is a cursor that lags by a constant distance no tween duration accounts for.Hover state is resolved by hit-testing
e.target.closest(...)on each move rather than binding listeners to interactive children. React can add, remove and reorder those children freely and the cursor keeps working, with no subscription bookkeeping.The dot is
display: nonein markup and only switched toflexafter the(pointer: fine)check passes, so nothing renders before JS confirms there is a cursor to replace — no stray blob flashes during hydration on a phone.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| SIZE_IDLE / SIZE_HOVER / SIZE_VIEW | = 16 / 44 / 150 | 16px reads as a cursor rather than a dot of dust. 44px is roughly the tap-target minimum and clearly signals "this is interactive" without hiding the word underneath. 150px is enormous on purpose: it has to hold a 15px label with breathing room, and at that size the inverted region becomes a design element rather than a pointer. |
| FOLLOW_DURATION | = 0.32 | Seconds of catch-up. Below ~0.15 the dot is welded to the pointer and the blend looks like a rendering artefact; above ~0.5 clicking feels disconnected because the visual lands after the press. |
| ease: 'power3' | = power3 (in-out) | In-out rather than .out. quickTo remutates the end value continuously, so the tween is always restarting mid-flight; the symmetric curve absorbs direction changes instead of snapping into a fresh outward ramp on every event. |
| size transition timing | = 0.4s cubic-bezier(0.22, 1, 0.36, 1) | A strong ease-out. The dot reaches most of its new size in the first ~120ms so the hover feels instant, then settles. Linear at this scale looks mechanical; a longer duration means the dot is still growing when the pointer has left the target. |
| label scale | = 0.5 -> 1 | The label grows with the dot rather than fading in at full size, so at the halfway point of the transition the text is proportionate to the circle around it instead of overflowing it. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The cursor still appears, still inverts and still changes size — those are affordances, not motion. Both quickTo durations drop to 0 so the dot is welded to the pointer, and the fade in and out is instant. The CSS size transition is left in place; it is a 400ms state change, not continuous movement.
Removes both container listeners, restores the container's original inline cursor value, resets the dot to display: none, then gsap.killTweensOf and clearProps: "all" to drop the two quickTo tweens and the centring transform. Nothing is left on the element for a remount to inherit.
Move across the panel and a white dot follows a few frames behind, inverting the text and background under it — dark text goes light where the dot crosses it. Hover a link and it grows to 44px; hover a data-cursor="view" target and it grows to 150px with a label that scales up inside it. During the growth the dot stays centred on the pointer and never lags further behind than it did at rest. Leave the panel and it fades out. On touch there is no dot at all and the native cursor is untouched.
Pitfalls
- An ancestor that creates a stacking context. A
transform,filter,opacitybelow 1,will-change,isolation: isolate,contain: paint, aposition: fixedparent or abackdrop-filteranywhere between the intended blend root and the dot silently confines the blend to that subtree — the dot inverts its immediate parent and nothing else. This is the single most common reason this effect "does not work", and it produces no error, no warning and no console output. - Transitioning
allon the dot. It catchestransform, which GSAP is writing at 60-120fps, and CSS interpolates toward values that are already stale. The cursor then trails the pointer by a fixed distance that changing the tween duration does not fix, because the tween is not the thing lagging. - Scaling instead of resizing.
scale()is a transform, so it collides with the follow tween AND it scales the label's stroke weight and letter-spacing along with the circle. Animatingwidth/heightkeeps typography at its designed size. - Listening for
pointermoveon the dot itself. It sits under the pointer, so it receives its ownpointerleaveas it slides, and the cursor chases itself out of the panel. Listen on the container and keeppointer-events: noneon the dot. - Hiding the native cursor from CSS. A
cursor: nonerule applies on touch devices too, where this component renders nothing to replace it, so links become untappable-looking with no visible affordance. Set it from JS after the(pointer: fine)check and restore it on cleanup. - Centring with a negative margin instead of
xPercent/yPercent. The margin is a fixed pixel value and the dot changes size by a factor of nine, so it visibly slides off the pointer as it grows. - Leaving the effect on over navigation. A 150px inverted blob over link text is genuinely worse than an arrow; a real deployment needs an escape hatch that restores the native cursor over the nav.
- Using a theme token for the dot colour. Difference blending only produces a clean inversion from pure white; a near-white token yields a muddy tint over mid-greys.
Notes
- Gated on
(pointer: fine). On touch nothing mounts, the native cursor is never hidden, and the markup staysdisplay: none— there is no hover to grow into andpointermoveonly exists mid-drag, which would put the dot under the finger already covering it. - Scoped to its own container with
position: absoluteinside arelative isolateroot rather thanposition: fixed. A production site-level cursor isfixedat the document root; the isolate here is what makes the blend stop at the component edge instead of inverting the whole page. - Reduced motion keeps the cursor but sets every duration to 0, so the affordance and the inversion survive while the drift does not.
- The label is
#000and the dot#fffas raw literals, not tokens. Difference blending is defined on the actual channel values; a token would break the inversion the moment the theme shifted.