Blend Cursor
A disc that replaces the pointer and inverts whatever it passes over.
free vanilla 14.7KB cursorpointerblendoverlay
Options
| Option | Type | Default | Notes |
|---|---|---|---|
size | number | 24 | Disc diameter in px. |
hoverSize | number | 64 | Diameter over something clickable, in px. |
ease | number | 0.18 | Fraction of the remaining distance closed each 60fps frame. 1 follows exactly, with no lag. |
mode | string | "invert" | 'invert' inverts the backdrop and needs no colour. 'blend' paints color through blendMode. |
color | string | "var(--vc-accent)" | Fill for blend mode. Must stay distinguishable from the page in every theme. |
blendMode | string | "difference" | Any CSS mix-blend-mode, used by blend mode. |
hoverSelector | string | "a, button, [role=\"button\"], [data-cursor=\"hover\"]" | What counts as clickable, and so makes the disc swell. |
nativeSelector | string | "input, textarea, select, [contenteditable=\"true\"]" | Where the real cursor is handed back, because a disc over a text field hides the caret. |
Install
-
Copy
blend-cursor.jsandblend-cursor.cssinto your project. That is the whole component — it imports nothing. -
Include
tokens.cssonce, anywhere on the page. Every component reads its colour, spacing and motion from it. -
Link the two files, then call
createBlendCursoron a<div>.
<link rel="stylesheet" href="tokens.css" />
<link rel="stylesheet" href="blend-cursor.css" />
<script defer src="blend-cursor.js"></script> const instance = VC.createBlendCursor(
document.querySelector('#target'),
{}
);
// Later, on teardown:
instance.destroy();
The bundle is a classic script, so it works over file:// with no
server and no build step, and puts createBlendCursor on
window.VC. If you would rather have the ES module, it is
packages/registry/blend-cursor/ in the repo — same source, four
files once _utils/ comes with it.
View source
/**
* GENERATED — run pnpm build:components — do not edit.
*
* Source of truth: packages/registry/blend-cursor/blend-cursor.js
*
* Flattened from ES modules to one classic script by scripts/lab-bundle.js —
* byte for byte the build the Noir Portfolio template ships, so the preview
* you are watching is the file the copy button hands you.
*
* Exposes: window.VC.createBlendCursor
*/
(function () {
'use strict';
/* ── packages/registry/_utils/dom.js ───────────────────────────────── */
/**
* DOM construction and listener helpers.
*
* Zero dependencies. Every import specifier in this package must start with
* '.' or '/' — see scripts/check-isolation.js.
*/
const SVG_NS = 'http://www.w3.org/2000/svg';
/**
* @typedef {string | number | boolean | null | undefined} AttrValue
*/
/**
* Apply attributes to an element.
*
* - `class` and `className` both set the class attribute.
* - `style` takes a CSS string.
* - `data-*` and `aria-*` pass straight through.
* - `null`, `undefined` and `false` skip the attribute entirely, so callers can
* pass conditionals inline.
* - `true` sets a valueless attribute.
*
* @param {Element} node
* @param {Record<string, AttrValue>} attrs
* @returns {void}
*/
function applyAttrs(node, attrs) {
for (const [key, value] of Object.entries(attrs)) {
if (value === null || value === undefined || value === false) continue;
const name = key === 'className' ? 'class' : key;
node.setAttribute(name, value === true ? '' : String(value));
}
}
/**
* Append children, coercing strings to text nodes.
*
* @param {Element} node
* @param {Array<Node | string>} children
* @returns {void}
*/
function appendChildren(node, children) {
for (const child of children) {
node.append(child);
}
}
/**
* Create an HTML element.
*
* @template {keyof HTMLElementTagNameMap} K
* @param {K} tag
* @param {Record<string, AttrValue>} [attrs]
* @param {Array<Node | string>} [children]
* @returns {HTMLElementTagNameMap[K]}
*
* @example
* const card = el('article', { class: 'vc-card', 'aria-live': 'polite' }, [
* el('h3', null, ['Title'])
* ]);
*/
function el(tag, attrs, children) {
const node = document.createElement(tag);
if (attrs) applyAttrs(node, attrs);
if (children) appendChildren(node, children);
return node;
}
/**
* Create an SVG element. Needed because `document.createElement` produces an
* HTML element that renders as nothing inside an `<svg>`.
*
* @template {keyof SVGElementTagNameMap} K
* @param {K} tag
* @param {Record<string, AttrValue>} [attrs]
* @param {Array<Node | string>} [children]
* @returns {SVGElementTagNameMap[K]}
*/
function svgEl(tag, attrs, children) {
const node = /** @type {SVGElementTagNameMap[K]} */ (
document.createElementNS(SVG_NS, tag)
);
if (attrs) applyAttrs(node, attrs);
if (children) appendChildren(node, children);
return node;
}
/**
* Bind a listener and get its own teardown back.
*
* Returning the cleanup rather than exposing `off()` is what makes `destroy()`
* auditable: a component collects these in an array and the array length is
* the number of things it has to undo.
*
* The returned function is idempotent — calling it twice is harmless.
*
* @template {Event} [E=Event]
* @param {EventTarget} target
* @param {string} type
* @param {(event: E) => void} handler
* @param {AddEventListenerOptions | boolean} [options]
* @returns {() => void} cleanup
*
* @example
* const cleanups = [
* on(window, 'pointermove', onMove, { passive: true }),
* on(el, 'pointerleave', onLeave)
* ];
* // destroy: cleanups.forEach((off) => off());
*/
function on(target, type, handler, options) {
const listener = /** @type {EventListener} */ (/** @type {unknown} */ (handler));
target.addEventListener(type, listener, options);
let removed = false;
return () => {
if (removed) return;
removed = true;
target.removeEventListener(type, listener, options);
};
}
/* ── packages/registry/_utils/motion.js ────────────────────────────── */
/**
* prefers-reduced-motion, as a live value.
*
* Reading the media query once at module load is wrong: the user can change the
* setting while the page is open, and a component that cached the old answer
* keeps animating. Every animated component gates on `prefersReducedMotion()`
* and subscribes to `onReducedMotionChange()`.
*
* The contract: reduced motion means static but complete. Never blank.
*
* Zero dependencies.
*/
const QUERY = '(prefers-reduced-motion: reduce)';
/** @type {MediaQueryList | null} */
let query = null;
/**
* Lazily resolve the MediaQueryList. Returns null where matchMedia is missing,
* which is the SSR pass and some test environments.
*
* @returns {MediaQueryList | null}
*/
function mediaQuery() {
if (query) return query;
if (typeof matchMedia !== 'function') return null;
query = matchMedia(QUERY);
return query;
}
/**
* Whether the user has asked for reduced motion, right now.
*
* Defaults to `false` when the query is unavailable — animation is the design
* intent, and a server render has no user preference to read.
*
* @returns {boolean}
*/
function prefersReducedMotion() {
return mediaQuery()?.matches ?? false;
}
/**
* Subscribe to changes in the preference.
*
* @param {(reduced: boolean) => void} handler
* @returns {() => void} unsubscribe
*
* @example
* let reduced = prefersReducedMotion();
* const stopWatching = onReducedMotionChange((next) => {
* reduced = next;
* if (reduced) settleToFinalState();
* });
* // destroy: stopWatching();
*/
function onReducedMotionChange(handler) {
const media = mediaQuery();
if (!media) return () => {};
/** @param {MediaQueryListEvent} event */
const listener = (event) => handler(event.matches);
media.addEventListener('change', listener);
let removed = false;
return () => {
if (removed) return;
removed = true;
media.removeEventListener('change', listener);
};
}
/* ── packages/registry/_utils/raf.js ───────────────────────────────── */
/**
* One requestAnimationFrame loop for the whole page.
*
* Twelve components each starting a private rAF means twelve callbacks the
* browser schedules separately, and twelve places a leak can hide. Components
* register here and get a deregister function back; the loop starts on the
* first subscriber and stops on the last.
*
* Zero dependencies.
*/
/**
* @callback FrameCallback
* @param {number} time Timestamp from rAF, in ms since page load.
* @param {number} delta Ms since the previous frame. 0 on the first frame.
* @returns {void}
*/
/** @type {Set<FrameCallback>} */
const callbacks = new Set();
/** @type {number} Active rAF handle, or 0 when stopped. */
let handle = 0;
/** @type {number} Timestamp of the previous frame, or 0 before the first. */
let previous = 0;
/**
* @param {number} time
* @returns {void}
*/
function tick(time) {
handle = requestAnimationFrame(tick);
const delta = previous === 0 ? 0 : time - previous;
previous = time;
// Iterate a copy: a callback may deregister itself, or another, mid-frame.
for (const callback of [...callbacks]) {
callback(time, delta);
}
}
/**
* @returns {void}
*/
function start() {
if (handle !== 0) return;
if (typeof requestAnimationFrame !== 'function') return;
previous = 0;
handle = requestAnimationFrame(tick);
}
/**
* @returns {void}
*/
function stop() {
if (handle === 0) return;
cancelAnimationFrame(handle);
handle = 0;
previous = 0;
}
/**
* Run a callback on every frame until the returned function is called.
*
* The returned function is idempotent, so a `destroy()` that runs twice is
* harmless.
*
* @param {FrameCallback} callback
* @returns {() => void} deregister
*
* @example
* const stopFrame = onFrame((time, delta) => {
* x += velocity * delta;
* });
* // destroy: stopFrame();
*/
function onFrame(callback) {
callbacks.add(callback);
start();
let done = false;
return () => {
if (done) return;
done = true;
callbacks.delete(callback);
if (callbacks.size === 0) stop();
};
}
/**
* Number of live subscribers. Exists so the lab harness and the lifecycle
* checklist can assert that `destroy()` actually let go of the loop.
*
* @returns {number}
*/
function frameSubscriberCount() {
return callbacks.size;
}
/* ── packages/registry/blend-cursor/blend-cursor.js ────────────────── */
/**
* Blend Cursor
*
* Replaces the pointer with a disc that inverts whatever it passes over, and
* swells when there is something to click. The native cursor is hidden only
* while this one is actually running, so nothing can leave a page with no
* cursor at all.
*
* @see NOTES.md
*/
const BASE = 'vc-blend-cursor';
/** Applied to the target while the disc is live. Hides the native cursor. */
const HOST = `${BASE}-host`;
/** Below this the disc is treated as having arrived, in px. */
const REST = 0.05;
/** One frame at 60fps, ms. The reference step the easing is expressed in. */
const STEP = 1000 / 60;
/** Longest frame the easing will accept, ms. Beyond this it is a tab-out. */
const MAX_STEP = 64;
/**
* Targets with a live instance. Two discs over one element would fight over
* the native cursor, so a second mount is an error.
*
* @type {WeakSet<HTMLElement>}
*/
const mounted = new WeakSet();
/**
* @typedef {object} BlendCursorOptions
* @property {number} [size] Disc diameter, px.
* @property {number} [hoverSize] Diameter over something clickable, px.
* @property {number} [ease] How much of the remaining distance is closed each
* 60fps frame, 0–1. 1 follows the pointer exactly, with no lag.
* @property {'invert' | 'blend'} [mode] `invert` inverts the backdrop and needs
* no colour. `blend` paints `color` through `blendMode`.
* @property {string} [color] Fill for `blend` mode. Must stay distinguishable
* from the page in every theme — see NOTES.md.
* @property {string} [blendMode] Any CSS mix-blend-mode, for `blend` mode.
* @property {string} [hoverSelector] What counts as clickable.
* @property {string} [nativeSelector] Where the real cursor is handed back,
* because a disc over a text field hides the caret.
*/
/**
* @typedef {object} BlendCursorInstance
* @property {() => void} destroy
*/
/**
* Whether this machine has a pointer that can hover — a mouse or a trackpad,
* not a finger. Read live, because a tablet gains one the moment a mouse is
* paired and loses it again when that mouse goes away.
*
* @returns {MediaQueryList | null}
*/
function finePointerQuery() {
if (typeof matchMedia !== 'function') return null;
return matchMedia('(pointer: fine)');
}
/**
* @param {HTMLElement} target
* @param {BlendCursorOptions} [options]
* @returns {BlendCursorInstance}
*/
function createBlendCursor(target, options = {}) {
if (mounted.has(target)) {
throw new Error(
'createBlendCursor: this element already has an instance — destroy it first'
);
}
const {
size = 24,
hoverSize = 64,
ease = 0.18,
mode = 'invert',
color = 'var(--vc-accent)',
blendMode = 'difference',
hoverSelector = 'a, button, [role="button"], [data-cursor="hover"]',
nativeSelector = 'input, textarea, select, [contenteditable="true"]'
} = options;
/** @type {Array<() => void>} */
const cleanups = [];
mounted.add(target);
cleanups.push(() => mounted.delete(target));
/* ── DOM ───────────────────────────────────────────────────────────────
Two elements, because they animate on different clocks. The outer one is
moved every frame by JavaScript and must never carry a transition. The
inner one swells on hover through a CSS transition, and must never be
written to per frame. Putting both transforms on one element would mean a
transition fighting a sixty-times-a-second rewrite.
aria-hidden, and pointer-events: none. It is decoration that must never
intercept the click it is pointing at. */
const root = el('div', { class: BASE, 'aria-hidden': 'true' });
const disc = el('div', { class: `${BASE}__disc ${BASE}__disc--${mode}` });
root.append(disc);
root.style.setProperty('--vc-bc-size', `${size}px`);
root.style.setProperty('--vc-bc-swell', String(size > 0 ? hoverSize / size : 1));
if (mode === 'blend') {
disc.style.setProperty('--vc-bc-color', color);
disc.style.setProperty('--vc-bc-blend', blendMode);
}
/* ── State ─────────────────────────────────────────────────────────────── */
let pointerX = 0;
let pointerY = 0;
let x = 0;
let y = 0;
/** Written to the DOM last, so a frame that changes nothing writes nothing. */
let wroteX = Number.NaN;
let wroteY = Number.NaN;
/** False until the pointer has been seen once, so the disc never flies in. */
let placed = false;
let reduced = prefersReducedMotion();
let attached = false;
/** @type {(() => void) | null} Live rAF subscription. */
let frameHandle = null;
/** @type {Array<() => void>} Bound only while the disc is attached. */
let bindings = [];
/**
* @returns {void}
*/
function write() {
const nextX = Math.round(x * 100) / 100;
const nextY = Math.round(y * 100) / 100;
if (nextX === wroteX && nextY === wroteY) return;
wroteX = nextX;
wroteY = nextY;
root.style.transform = `translate3d(${nextX}px, ${nextY}px, 0)`;
}
/* ── Frames ────────────────────────────────────────────────────────────── */
/**
* Ease toward the pointer, then let go of the frame.
*
* The easing is expressed per 60fps frame and then corrected for the frame
* actually delivered, so the disc trails by the same distance on a 120Hz
* display as on a 60Hz one. A raw `x += (target - x) * ease` would close the
* gap twice as fast on the faster screen and feel like a different component.
*
* @param {number} _time Unused: the shared loop passes it first.
* @param {number} delta
* @returns {void}
*/
function frame(_time, delta) {
const steps = Math.min(delta, MAX_STEP) / STEP;
const k = steps <= 0 ? ease : 1 - (1 - ease) ** steps;
x += (pointerX - x) * k;
y += (pointerY - y) * k;
if (Math.abs(pointerX - x) < REST && Math.abs(pointerY - y) < REST) {
/* Land exactly, so a stray hundredth of a pixel cannot hold the loop
open forever. */
x = pointerX;
y = pointerY;
write();
sleep();
return;
}
write();
}
/**
* @returns {void}
*/
function wake() {
if (frameHandle || reduced || !attached) return;
frameHandle = onFrame(frame);
}
/**
* @returns {void}
*/
function sleep() {
if (!frameHandle) return;
frameHandle();
frameHandle = null;
}
cleanups.push(sleep);
/* ── Pointer ───────────────────────────────────────────────────────────── */
/**
* @param {PointerEvent} event
* @returns {void}
*/
function onMove(event) {
pointerX = event.clientX;
pointerY = event.clientY;
/* First sighting, or no easing wanted: go straight there. Easing in from
the top-left corner on the first move is the classic tell that a custom
cursor was bolted on. */
if (!placed || reduced || ease >= 1) {
placed = true;
x = pointerX;
y = pointerY;
write();
root.classList.add(`${BASE}--visible`);
return;
}
root.classList.add(`${BASE}--visible`);
wake();
}
/**
* The disc follows the pointer, so when the pointer is gone the disc has
* nothing to say. Hidden rather than removed, so coming back is instant.
*
* @returns {void}
*/
function onLeave() {
root.classList.remove(`${BASE}--visible`);
sleep();
}
/**
* Hover and hand-back, from one delegated listener rather than a hit test
* per frame. `pointerover` fires on every boundary crossing inside the
* target, which is exactly when either answer can change.
*
* @param {PointerEvent} event
* @returns {void}
*/
function onOver(event) {
const node = event.target;
if (!(node instanceof Element)) return;
/* A disc sitting over a text field hides the caret and the I-beam that
says the field is editable. Hand the real cursor back and get out of
the way — the page is more important than the effect. */
const native = Boolean(nativeSelector) && node.closest(nativeSelector) !== null;
target.classList.toggle(HOST, !native);
root.classList.toggle(`${BASE}--yielded`, native);
const hovering = Boolean(hoverSelector) && node.closest(hoverSelector) !== null;
root.classList.toggle(`${BASE}--hover`, hovering);
}
/* ── Attach ────────────────────────────────────────────────────────────
Everything is put up and taken down together, because every reason to
stop — no fine pointer, destroyed — is a reason to stop all of it. The
native cursor is hidden here and nowhere else, so there is no path where
the page ends up with neither cursor. */
/**
* @returns {void}
*/
function attach() {
if (attached) return;
attached = true;
placed = false;
target.append(root);
target.classList.add(HOST);
bindings = [
on(target, 'pointermove', /** @type {(e: Event) => void} */ (onMove), {
passive: true
}),
on(target, 'pointerover', /** @type {(e: Event) => void} */ (onOver), {
passive: true
}),
on(target, 'pointerleave', onLeave),
on(window, 'blur', onLeave)
];
}
/**
* @returns {void}
*/
function detach() {
if (!attached) return;
attached = false;
sleep();
for (const off of bindings) off();
bindings = [];
root.classList.remove(`${BASE}--visible`, `${BASE}--hover`, `${BASE}--yielded`);
root.remove();
target.classList.remove(HOST);
wroteX = Number.NaN;
wroteY = Number.NaN;
}
cleanups.push(detach);
/* ── Fine pointer ─────────────────────────────────────────────────────
A finger has no hover state and no position between taps, so on a touch
screen this component has nothing to draw and no native cursor worth
hiding. It puts nothing in the DOM at all until there is a pointer that
can hover, and takes it all down again if that pointer goes away. */
const fine = finePointerQuery();
/**
* @returns {void}
*/
function applyPointerType() {
if (fine === null || fine.matches) attach();
else detach();
}
if (fine) {
const listener = () => applyPointerType();
fine.addEventListener('change', listener);
cleanups.push(() => fine.removeEventListener('change', listener));
}
/* ── Reduced motion ───────────────────────────────────────────────────
The lag is the motion, not the disc. So the disc stays — it is a cursor,
and taking it away would leave the page without the one thing the pointer
needs — and it simply stops trailing: it is written straight to the
pointer on every move, and no frame is ever taken. */
cleanups.push(
onReducedMotionChange((next) => {
reduced = next;
if (!reduced) return;
sleep();
x = pointerX;
y = pointerY;
write();
})
);
applyPointerType();
return {
destroy() {
while (cleanups.length > 0) cleanups.pop()?.();
}
};
}
/* ── exports ─────────────────────────────────────────────────────────── */
var VC = (window.VC = window.VC || {});
VC.createBlendCursor = createBlendCursor;
/* Every export in the graph, for checklist assertions from the demo page. */
VC.__all = {
el: el,
svgEl: svgEl,
on: on,
prefersReducedMotion: prefersReducedMotion,
onReducedMotionChange: onReducedMotionChange,
onFrame: onFrame,
frameSubscriberCount: frameSubscriberCount,
createBlendCursor: createBlendCursor
};
})();
View source
/* GENERATED — run pnpm build:components. Source: packages/registry/blend-cursor/blend-cursor.css */
/**
* Blend Cursor
*
* Reads tokens, never defines them. No global rules — every selector is scoped
* under .vc-blend-cursor, and the one rule that reaches outside it is scoped to
* .vc-blend-cursor-host, a class the factory adds and removes.
*
* Nothing here hides the native cursor except that host class, and the factory
* only ever adds it while the disc is actually on screen. A stylesheet that
* hid the cursor on its own would leave a page whose script failed to load
* with no cursor at all.
*/
.vc-blend-cursor-host {
cursor: none;
}
/**
* The outer element carries position only, rewritten every frame from
* JavaScript, and must never have a transition on transform — a transition
* here would be a second animation fighting the easing.
*
* Centred by margin rather than by a translate(-50%, -50%) baked into the
* transform, so the per-frame write stays a plain translate3d.
*/
.vc-blend-cursor {
position: fixed;
top: 0;
left: 0;
z-index: var(--vc-z-tooltip);
width: var(--vc-bc-size, 24px);
height: var(--vc-bc-size, 24px);
margin: calc(var(--vc-bc-size, 24px) / -2);
pointer-events: none;
opacity: 0;
transition: opacity var(--vc-dur-fast) var(--vc-ease-out);
}
.vc-blend-cursor--visible {
opacity: 1;
}
/* Over a text field the real cursor is back, so this one gets out of the way. */
.vc-blend-cursor--yielded {
opacity: 0;
}
/**
* The inner element carries size and appearance, and swells on hover through a
* transition. Separating the two transforms is what lets a CSS transition and
* a per-frame write coexist on the same cursor.
*/
.vc-blend-cursor__disc {
width: 100%;
height: 100%;
border-radius: var(--vc-radius-full);
transform: scale(1);
transition: transform var(--vc-dur-base) var(--vc-ease-spring);
}
.vc-blend-cursor--hover .vc-blend-cursor__disc {
transform: scale(var(--vc-bc-swell, 2.5));
}
/**
* Inverting the backdrop needs no colour at all, which is the whole reason it
* is the default. Every fill that could be named instead collapses in one
* theme or the other: measured against the token set, --vc-bg is invisible in
* dark and --vc-ink is invisible in light, because `difference` against a
* backdrop equal to the fill is black.
*/
.vc-blend-cursor__disc--invert {
backdrop-filter: invert(1);
-webkit-backdrop-filter: invert(1);
}
/**
* The named-colour alternative. --vc-accent is the default because it is the
* one colour token that is mid-tone in both themes by construction, so the
* difference against the page is large either way.
*/
.vc-blend-cursor__disc--blend {
background: var(--vc-bc-color, var(--vc-accent));
mix-blend-mode: var(--vc-bc-blend, difference);
}
/**
* Belt and braces. The factory already stops easing and writes the disc
* straight to the pointer under reduced motion; this covers the swell, which
* is a CSS transition the factory has no hand in.
*/
@media (prefers-reduced-motion: reduce) {
.vc-blend-cursor,
.vc-blend-cursor__disc {
transition: none;
}
}