LAB/ Tanishk Interface Lab
InputstableReactPointer Eventsmix-blend-mode

Inverted Cursor

A difference-blended cursor disc with honest states and clean teardown.

Live preview

Studio index · 2026

Studio Vellum

Harbour Line type specimen

Type design2026specimen · view

Museum of Quiet Machines

Identity2025

Field Notes on Colour

Editorial system2025

Backlot scheduling tool

Product interface2024

Vellum keeps a working index of everything that leaves the studio: type specimens, identities, small internal tools. Select any of this line to copy it — the disc steps aside and the native I-beam takes over.

Specimen: view — hover the marked row

Sample stateLabelMagnetic targets

This preview is desktop-only — the disc follows your pointer

01What it does

One white disc painted with mix-blend-mode: difference, so it inverts whatever it crosses. It steps aside over selectable text, expands over interactive targets, and can carry a single label cut from its own surface. Fine pointers only, with a lifecycle that always hands the native cursor back — on unmount, on blur, on route change, on pointer-kind change.

In this previewA sample project index: hover rows, buttons and media to see the disc expand, and press to see the pressed state. The disc is live — move your pointer anywhere in the frame.

02Install

Registry install

Works with the shadcn CLI in any project that has a components.json. The component lands in components/lab/inverted-cursor/ with its stylesheet beside it.

npx shadcn@latest add <lab-origin>/r/inverted-cursor.json

No npm packages are installed — the component is self-contained. Prefer manual copying? Every file is below under Source.

  • components/lab/inverted-cursor/inverted-cursor.tsxcomponent
  • components/lab/inverted-cursor/inverted-cursor.cssstyles

Machine-readable item: /r/inverted-cursor.json ↗

03Usage

inverted-cursor.usage.tsx
import { InvertedCursor } from "@/components/lab/inverted-cursor/inverted-cursor";

// Mount once, near the root of the page:
export function Cursor() {
return <InvertedCursor label="VIEW" grow={2.6} magnetic />;
}

// Then mark your own surfaces:
// <a data-cursor="view">Case study</a>
// <button data-cursor="link">Save</button>

04Props

Props accepted by Inverted Cursor
PropTypeDefaultNotes
sizenumber22Rest diameter in pixels.
grownumber2.6Scale over links, buttons and marked surfaces.
labelstring"VIEW"Word carried by the disc on data-cursor="view" targets.
blendstring"difference"Any mix-blend-mode value; difference is the honest default.
magneticbooleanfalseTargets marked data-cursor-magnet drift a clamped few pixels toward the pointer.
respectReducedMotionbooleantrueRemoves easing and magnetism, keeping the position 1:1.

05Accessibility

  • Never engages on coarse pointers or keyboard-only sessions — the native cursor is untouched unless a fine pointer actually moves.
  • Over inputs, textareas and selectable prose the disc steps aside and the native I-beam returns.
  • The disc is aria-hidden and purely additive; nothing requires it to complete an action.
  • Labels are never detached from the disc: they are cut from the same surface, so no stale word can survive a teardown.

06Source

2 files, 458 lines. Nothing private, no configuration required.

components/lab/inverted-cursor/inverted-cursor.tsx— press enter to show or hide the source
tsx
"use client";

import { useEffect, useRef, type CSSProperties } from "react";
import "./inverted-cursor.css";

export type InvertedCursorProps = {
/** Rest diameter in pixels. */
size?: number;
/** Scale over links, buttons and marked surfaces. */
grow?: number;
/** Word carried by the disc on data-cursor="view" targets. */
label?: string;
/** Any mix-blend-mode value; difference is the honest default. */
blend?: string;
/** Targets marked data-cursor-magnet drift a clamped few pixels toward the pointer. */
magnetic?: boolean;
/** Removes easing and magnetism, keeping the position 1:1. */
respectReducedMotion?: boolean;
className?: string;
};

type CursorState = "rest" | "link" | "view" | "text";

/**
* Tag/attribute rules for the implicit states. An explicit data-cursor on
* the nearest marked ancestor always outranks these, so a row marked
* "view" keeps its label even when the pointer sits on the title inside it.
*/
const GENERIC_TARGETS = [
"a",
"button",
"[role='button']",
"label",
"select",
"input",
"textarea",
"[contenteditable]",
"p",
"li",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"td",
].join(",");

const PROSE_TAGS = /^(p|li|h[1-6]|td)$/;
const MAGNET_TARGETS = "[data-cursor-magnet]";

const EASE = 0.55; // catch-up per frame
const MAGNET_PULL = 0.2; // fraction of the offset a magnet takes
const MAGNET_MAX = 8; // hard clamp, in pixels
const SETTLE = 0.1; // below this the disc snaps and the loop stops

function resolveState(node: EventTarget | null): CursorState {
if (!(node instanceof Element)) return "rest";

const marked = node.closest("[data-cursor]");
const markedKind = marked?.getAttribute("data-cursor");
if (markedKind === "view" || markedKind === "link" || markedKind === "text") {
return markedKind;
}

const generic = node.closest(GENERIC_TARGETS);
if (!generic) return "rest";
if (generic.hasAttribute("contenteditable")) return "text";
const tag = generic.tagName.toLowerCase();
if (tag === "input" || tag === "textarea") return "text";
if (PROSE_TAGS.test(tag)) return "text";
return "link";
}

/**
* INVERTED CURSOR — a difference-blended disc with honest states.
*
* One fixed disc follows the pointer with a small per-frame easing. The
* disc's position travels on the independent `translate` property and its
* scale on `transform`, so the two never fight. It only engages on fine,
* hovering pointers and only after the pointer has actually moved; over
* inputs, editable regions and selectable prose it steps aside and hands
* the native I-beam back by removing the `data-lab-cursor` attribute the
* stylesheet keys on. Every exit path — leaving the window, blur, tab
* hide, media change, unmount — cancels the frame loop, restores the
* native cursor and resets every magnet transform.
*/
export function InvertedCursor({
size = 22,
grow = 2.6,
label = "VIEW",
blend = "difference",
magnetic = false,
respectReducedMotion = true,
className,
}: InvertedCursorProps) {
const discRef = useRef<HTMLDivElement>(null);
/* Live props are read through a ref so inline values never rebind the
pointer engine mid-frame. */
const propsRef = useRef({ grow, magnetic, respectReducedMotion });
/* The element currently pulled toward the disc, if any. */
const magnetRef = useRef<HTMLElement | null>(null);

useEffect(() => {
propsRef.current = { grow, magnetic, respectReducedMotion };
});

/* Turning magnetism off must never strand a drifted target. */
useEffect(() => {
if (magnetic) return;
const el = magnetRef.current;
if (el) {
el.style.transform = "";
magnetRef.current = null;
}
}, [magnetic]);

useEffect(() => {
const disc = discRef.current;
if (!disc) return;

const fineQuery = window.matchMedia("(pointer: fine)");
const hoverQuery = window.matchMedia("(hover: hover)");
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const root = document.documentElement;

let active = false; // disc owns the cursor
let pressed = false;
let state: CursorState = "rest";
let posX = 0;
let posY = 0;
let targetX = 0;
let targetY = 0;
let hasTarget = false;
let raf = 0;

const isReduced = (): boolean =>
propsRef.current.respectReducedMotion && motionQuery.matches;

const releaseMagnet = (): void => {
const el = magnetRef.current;
if (!el) return;
el.style.transform = "";
magnetRef.current = null;
};

const writePosition = (x: number, y: number): void => {
/* NaN must never reach the DOM as "NaNpx". */
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
disc.style.setProperty(
"translate",
`calc(${x}px - 50%) calc(${y}px - 50%)`,
);
};

const setState = (next: CursorState): void => {
if (next === state) return;
state = next;
disc.dataset.state = next;
if (next === "text") {
// Hand the native I-beam back while over a text surface.
delete root.dataset.labCursor;
} else if (active) {
root.dataset.labCursor = "custom";
}
};

const activate = (): void => {
if (active) return;
active = true;
disc.dataset.engaged = "true";
if (state !== "text") root.dataset.labCursor = "custom";
};

const deactivate = (): void => {
if (raf) {
window.cancelAnimationFrame(raf);
raf = 0;
}
hasTarget = false;
pressed = false;
disc.dataset.pressed = "false";
disc.dataset.engaged = "false";
delete root.dataset.labCursor;
releaseMagnet();
active = false;
};

const tick = (): void => {
raf = 0;
/* Hard guarantee: an orphaned frame can never re-show the disc. */
if (!active) return;
if (!hasTarget) return;
if (!Number.isFinite(targetX) || !Number.isFinite(targetY)) return;

const dx = targetX - posX;
const dy = targetY - posY;
if (Math.abs(dx) < SETTLE && Math.abs(dy) < SETTLE) {
posX = targetX;
posY = targetY;
writePosition(posX, posY);
return;
}
posX += dx * EASE;
posY += dy * EASE;
writePosition(posX, posY);
raf = window.requestAnimationFrame(tick);
};

const updateMagnet = (node: EventTarget | null): void => {
if (!propsRef.current.magnetic || isReduced()) {
releaseMagnet();
return;
}
const el =
node instanceof Element
? node.closest<HTMLElement>(MAGNET_TARGETS)
: null;
if (el !== magnetRef.current) releaseMagnet();
if (!el) return;
magnetRef.current = el;

const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const dx = posX - (rect.left + rect.width / 2);
const dy = posY - (rect.top + rect.height / 2);
const clamp = (value: number): number =>
Math.max(-MAGNET_MAX, Math.min(MAGNET_MAX, value * MAGNET_PULL));
el.style.transform = `translate(${clamp(dx).toFixed(2)}px, ${clamp(dy).toFixed(2)}px)`;
};

const onMove = (event: PointerEvent): void => {
if (!(fineQuery.matches && hoverQuery.matches)) return;
const x = event.clientX;
const y = event.clientY;
if (!Number.isFinite(x) || !Number.isFinite(y)) return;

const next = resolveState(event.target);
if (next === "text") {
if (active) {
// Keep the position knowledge so leaving text feels continuous,
// but hide the disc and let the I-beam through.
posX = x;
posY = y;
deactivate();
}
return;
}

setState(next);
if (!active) {
posX = x;
posY = y;
targetX = x;
targetY = y;
hasTarget = true;
activate();
writePosition(posX, posY);
updateMagnet(event.target);
return;
}

targetX = x;
targetY = y;
hasTarget = true;

if (isReduced()) {
posX = x;
posY = y;
writePosition(posX, posY);
updateMagnet(event.target);
return;
}
if (!raf) raf = window.requestAnimationFrame(tick);
updateMagnet(event.target);
};

const onDown = (event: PointerEvent): void => {
if (!active || event.button !== 0) return;
pressed = true;
disc.dataset.pressed = "true";
};

const onUp = (): void => {
if (!pressed) return;
pressed = false;
disc.dataset.pressed = "false";
};

const onExit = (): void => deactivate();

const onVisibility = (): void => {
if (document.hidden) deactivate();
};

const onMediaChange = (): void => {
if (!(fineQuery.matches && hoverQuery.matches)) deactivate();
};

const onMotionChange = (): void => {
if (!isReduced()) return;
releaseMagnet();
if (raf) {
window.cancelAnimationFrame(raf);
raf = 0;
}
if (active && hasTarget) {
posX = targetX;
posY = targetY;
writePosition(posX, posY);
}
};

window.addEventListener("pointermove", onMove, { passive: true });
window.addEventListener("pointerdown", onDown);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
window.addEventListener("blur", onExit);
document.addEventListener("mouseleave", onExit);
document.addEventListener("visibilitychange", onVisibility);
root.addEventListener("pointerleave", onExit);
fineQuery.addEventListener("change", onMediaChange);
hoverQuery.addEventListener("change", onMediaChange);
motionQuery.addEventListener("change", onMotionChange);

return () => {
if (raf) window.cancelAnimationFrame(raf);
raf = 0;
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerdown", onDown);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
window.removeEventListener("blur", onExit);
document.removeEventListener("mouseleave", onExit);
document.removeEventListener("visibilitychange", onVisibility);
root.removeEventListener("pointerleave", onExit);
fineQuery.removeEventListener("change", onMediaChange);
hoverQuery.removeEventListener("change", onMediaChange);
motionQuery.removeEventListener("change", onMotionChange);
delete root.dataset.labCursor;
releaseMagnet();
};
}, []);

return (
<div
ref={discRef}
className={`cur-disc${className ? ` ${className}` : ""}`}
aria-hidden="true"
role="presentation"
data-state="rest"
data-pressed="false"
data-engaged="false"
style={
{
"--cur-size": `${size}px`,
"--cur-grow": String(grow),
"--cursor-blend": blend,
} as CSSProperties
}
>
{label ? <span className="cur-disc__label">{label}</span> : null}
</div>
);
}
components/lab/inverted-cursor/inverted-cursor.css— press enter to show or hide the source
css
/* ============================================================
INVERTED CURSOR — self-contained styles.
Every token is scoped with a fallback, so the component works in
any codebase without the Lab's stylesheet.
============================================================ */

/*
* The native cursor disappears only while the component has engaged —
* a fine, hovering pointer has actually moved. Over text surfaces the
* component removes the attribute again, so the I-beam returns.
*/
html[data-lab-cursor="custom"],
html[data-lab-cursor="custom"] * {
cursor: none !important;
}

.cur-disc {
--cur-size: 22px;
--cur-grow: 2.6;
--cur-zoom: 1;
--cur-press: 1;
position: fixed;
left: 0;
top: 0;
z-index: 9999;
display: grid;
place-items: center;
width: var(--cur-size);
height: var(--cur-size);
border-radius: 50%;
background: #fff;
mix-blend-mode: var(--cursor-blend, difference);
pointer-events: none;
opacity: 0;
/* Position travels on `translate` (set from JS), scale on `transform`. */
transform: scale(calc(var(--cur-zoom) * var(--cur-press)));
transform-origin: center;
transition:
transform 200ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 140ms ease;
will-change: translate, transform;
}

.cur-disc[data-engaged="true"] {
opacity: 1;
}

.cur-disc[data-state="text"] {
opacity: 0;
}

.cur-disc[data-state="link"],
.cur-disc[data-state="view"] {
--cur-zoom: var(--cur-grow);
}

.cur-disc[data-pressed="true"] {
--cur-press: 0.84;
}

/*
* The label is white and difference-blends a second time against the
* disc's own white surface, so it reads as a cut-out: the word shows
* the page content un-inverted while the disc around it inverts. That
* keeps the word on the same surface — nothing can outlive teardown.
*/
.cur-disc__label {
font-family:
ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono",
Menlo, monospace;
font-size: calc(var(--cur-size) * 0.3);
font-weight: 600;
letter-spacing: 0.05em;
line-height: 1;
white-space: nowrap;
color: #fff;
mix-blend-mode: difference;
opacity: 0;
transition: opacity 120ms ease;
}

.cur-disc[data-state="view"] .cur-disc__label {
opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
.cur-disc,
.cur-disc__label {
transition: none;
}
}