Custom Cursor
A pointer replacement scoped to one container — a spring-driven dot that opens into a difference-blended disc over anything interactive, hides the native arrow only while you are inside the box, and never draws on a coarse pointer or under reduced motion.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/custom-cursor.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CustomCursor" component: a container that
replaces the pointer inside itself. Only dependency is a `cn` class merger — the
spring, the capability gates and the blend discipline are the product.
Contract
- export const CustomCursor = React.forwardRef<HTMLDivElement, CustomCursorProps>,
className merged with cn(), every remaining prop spread on the root, children
rendered untouched as the first child.
size?: number (14) — diameter of the resting dot in px.
hoverSize?: number (56) — diameter of the disc over an interactive target.
stiffness?: number (700) — spring constant per second squared.
damping?: number (42) — spring damping; 2*sqrt(stiffness) is critical.
blend?: "difference" | "exclusion" | "normal" ("difference") — blend mode of
the grown disc. "normal" paints a themed ring instead of inverting.
interactiveSelector?: string — what closest() is tested against. Default:
a[href], button, [role="button"], [role="link"], input, select, textarea,
summary, [data-cursor="interactive"].
enabled?: boolean (true) — false renders the children and nothing else.
- Clamp every numeric prop through a helper that also rejects NaN: one non-finite
value would write "translate3d(NaNpx, …)" for the rest of the session.
Behavior
- Capability gates come BEFORE any pixels, and both are live. Read
"(hover: hover) and (pointer: fine)" and "(prefers-reduced-motion: reduce)"
through useSyncExternalStore with matchMedia change listeners, and a server
snapshot of false for both — so the server renders the plain container and the
first client snapshot decides. Drawn = enabled AND fine pointer AND motion
allowed. When it is false there is no overlay, no listener, no cursor:none:
the section still works, it simply keeps the system arrow. A mouse plugged
into a tablet, or Reduce Motion switched off in system settings, must start
the cursor mid-session with no reload.
- The spring is a real one, integrated in fixed 1/120s substeps so 60Hz and
144Hz produce the same path: v += (-k*(x - target) - c*v)*h; x += v*h. Clamp
the accumulated frame time to 100 ms, so a tab switch does not fast-forward
the spring through a thousand steps. The lag you are choosing is
damping * speed / stiffness — at the defaults, ~36 px behind a 600 px/s
sweep. Raise stiffness (or lower damping) to tighten it; damping below about
0.7 * 2*sqrt(stiffness) starts overshooting visibly on every stop.
- The loop parks itself once the dot has arrived (gap < 0.05 px, speed < 2 px/s)
AND the grow has finished, and is woken by pointermove, a hover crossing,
scroll or resize. A still hand costs zero frames.
- Entering is a discontinuity: on the first pointer sample inside the box, PLACE
the dot (and snap the sizes) instead of gliding it in from where it was left
last time — otherwise the cursor draws a stroke across the box that the hand
never made, or replays the shrink of a button you left from. That first MOVE,
not pointerenter, is also what hides the native arrow: hiding it before a
position is known would leave a frame with no cursor at all.
- Hover targets are found on pointerover, not pointermove: pointerover fires
once per element crossed, so closest() runs a handful of times per second
instead of a hundred. Ignore a match that is NOT inside this container —
closest() happily climbs out of it, and a whole card wrapped in a link would
otherwise make every pixel "hot". Wrap it in try/catch — a bad selector from
user land must fail once, not throw on every crossing — and reset that flag
when the prop changes. The hover state lives in the loop's closure, NOT in React state:
crossing a button costs zero renders. The only thing the pointer tells React
is enter/leave.
- Over an interactive target the dot scales to 0 while the disc scales to 1,
smoothed with alpha = 1 - e^(-dt/90ms) on the same clock. Holding the primary
button scales whichever one is showing to 0.82. Do NOT use a CSS transition
for this: the transform is rewritten every frame, and a transition on it would
ease the position too and double-lag the whole cursor.
- Scroll and resize re-measure the box, and when it moves the dot is shifted by
exactly the same delta. The hand did not move, so the dot must not either —
spring toward the recomputed target instead and the dot slides away every time
the page scrolls. Listen with { passive: true }, and for scroll also
{ capture: true }, which catches every nested scroller in one listener; a
ResizeObserver on the root covers layout changes. Both only set a dirty flag
and wake the loop, so the work happens once per frame, never per event.
Re-measure on every ENTRY as well: a container moves without resizing and
without a scroll all the time — a lazy image, a webfont swap, a dismissed
banner above it — and neither the observer nor the scroll listener says a
word, so a stale box would place the dot outside the overlay (clipped) at the
same moment the native arrow is hidden, leaving no cursor at all.
- Measure the PADDING box, not the border box: getBoundingClientRect() returns
the border box while the inset-0 overlay is positioned inside the padding box,
so store rect.left + root.clientLeft and rect.top + root.clientTop —
clientLeft/clientTop are exactly the border widths. Skip this and the dot sits
one border width down-right of the real hand; with border-8 that is an
8 px error, and at the container edge the dot is clipped away entirely.
- Touch is refused twice over: coarse pointers never render the overlay at all,
and on a hybrid device every handler returns early for pointerType "touch", so
a finger cannot summon a dot or hide the arrow. Nothing calls preventDefault
and the overlay is pointer-events-none, so scrolling and text selection are
exactly as they were.
- Hiding the native cursor is scoped to an attribute this component owns:
data-zyeon-cursor="hidden" on the root while the pointer is inside, plus one
hoisted stylesheet <style href precedence> with
[data-zyeon-cursor="hidden"], [data-zyeon-cursor="hidden"] * { cursor: none
!important }. The !important earns its place: descendants carry their own
cursor at equal specificity (cursor-pointer on buttons, text on inputs) and
one surviving arrow ruins the illusion. Because nothing global is ever
written, unmounting mid-hover — a route change on click, say — gives the
cursor back by itself. Never set document.body.style.cursor.
- Accessibility: the overlay is aria-hidden and pointer-events-none, and there is
deliberately no live region — the dot mirrors a pointer the user is already
holding, so announcing it would be pure noise. Focus deliberately does NOT
drive it either: a keyboard user has no pointer, and jumping a "cursor" to the
focused element would draw a lie. Keep the elements' own :hover and
:focus-visible styling; this component replaces the arrow, never the focus
ring.
- Cleanup: the rAF, all six pointer listeners, the scroll and resize listeners
and the ResizeObserver are released on unmount and whenever the capability
gates flip, and the inside flag is reset so the attribute (and the hidden
cursor) goes with them.
Rendering & styling
- Root: relative only. Overlay: absolute inset-0 overflow-hidden
pointer-events-none, holding two absolutely positioned spans sized in px with
negative margins of half their size, so scale() grows them about the hand.
- THE BLEND TRAP, read this before styling: mix-blend-mode blends with the
backdrop of the nearest ancestor stacking context, so any z-index, transform,
filter, opacity < 1, will-change or isolate between the disc and the page
content leaves it nothing to invert. That is why the layer takes no z-index
(being the last positioned child is what puts it on top), why the position is
written straight onto the blended span instead of onto a transformed wrapper,
and why the fade uses each span's own opacity. If content inside your
container outranks the layer, lower that content rather than raising this.
- The resting dot is bg-primary — a single semantic token that is near-black on
a light theme and near-white on a dark one, and follows any re-theme for free.
- The disc is bg-background dark:bg-foreground. difference needs a NEAR-WHITE
source to read as an inversion (result = |backdrop - source|), and no single
token is near-white in both themes: background is in a light one, foreground
is in a dark one. Swap that pair if your background is not near-white.
blend="normal" instead paints border-2 border-primary bg-primary/10.
- Semantic tokens only, no hex anywhere: bg-primary, bg-background,
dark:bg-foreground, border-primary. Decorative colour in a demo comes from
var(--chart-1..5).
Customization levers
- Feel: stiffness and damping are one decision. Keep damping near
2*sqrt(stiffness) for a clean stop, drop to ~0.7 of it for a springy overshoot,
and remember lag = damping * speed / stiffness. Tight and precise:
stiffness 1600, damping 70. Lazy and playful: stiffness 140, damping 16.
- Shape: size and hoverSize are the whole visual budget — 8/40 reads as a
precise instrument, 18/90 as a big soft lens. Give the disc a border and a
transparent background for a ring, or set border-radius to 0 for a square
reticle.
- Targets: interactiveSelector is the contract with your markup. Narrow it to
[data-cursor="interactive"] so only the things you have blessed react, or
widen it with your own components' selectors. Add a data attribute read on
the hovered element to drive per-target variants (a bigger disc over media,
a label over a video).
- Blend: difference is the classic invert; exclusion is softer; normal is the
honest choice over photography, where an inversion can land on a mid-tone
that reads as neither light nor dark.
- Reach: enabled={false} is the off switch for a video player, a canvas with its
own crosshair, or a route where the flourish is wrong. Wrap only the sections
that deserve it — the native cursor returning between them is a feature, not
a gap.
- Extras that fit this skeleton without touching the loop: a second, slower
node for a trailing ring; a text label inside the disc sourced from
data-cursor-label; a magnetic snap that eases the target toward the centre of
the hovered element's box.Concepts
- Capability gate before pixels — a drawn pointer is only meaningful where a hovering, fine pointer exists, so
(hover: hover) and (pointer: fine)andprefers-reduced-motionare read first and the whole overlay is simply not rendered when either says no. Both are subscribed throughmatchMediachange events, so plugging a mouse into a tablet or toggling Reduce Motion flips the section mid-session; the server snapshot is "no cursor", so nothing browser-only is touched during render. - Lag is a budget, not a bug — for a spring chasing a hand at constant speed the steady-state error settles at
damping × speed ÷ stiffness. That single formula is the whole feel dial: ~36 px behind a 600 px/s sweep at the defaults, 26 px at stiffness 1600 / damping 70, 68 px at 140 / 16. Damping below roughly 0.7 of critical (2·√stiffness) buys less lag at the price of a visible wobble on every stop. - The stacking-context trap —
mix-blend-modeblends with the backdrop of the nearest ancestor stacking context, so az-index,transform,filter,opacity < 1orisolateanywhere between the disc and your content silently leaves the disc nothing to invert. The layer therefore takes no z-index (being the last positioned child is what stacks it), the per-frame transform is written onto the blended span itself rather than a wrapper, and the fade rides each span's own opacity. - Pin to the hand, not to the box — when the page scrolls under a stationary pointer the container moves but the hand does not, so the box is re-measured and the dot is shifted by exactly that delta instead of springing toward the recomputed target. Scroll and resize only raise a dirty flag and wake the loop, which is what makes them rAF-throttled rather than per-event, and entering raises the same flag: a lazy image or a webfont swap moves the container without resizing it and without a scroll, and a stale box would place the dot outside the overlay in the same frame that hides the arrow. What is stored is the padding box (
rect.left + clientLeft), because that is the origin theinset-0overlay actually resolves against — measure the border box instead and the dot rides one border width off the hand. - Zero-render hover — the interactive test runs on
pointerover(once per element crossed, not once per pixel) and the result lives in the animation loop's closure, so sweeping across a row of buttons costs no React renders at all. Enter and leave are the only two things the pointer is allowed to tell React, because they are the only two the markup has to change for. - Scoped
cursor: none, nothing to restore — the arrow is hidden by one hoisted stylesheet keyed to adata-zyeon-cursorattribute this component sets on its own root while the pointer is inside, never by writingdocument.body.style. Unmounting mid-hover — a click that changes route, for instance — takes the attribute with it, so no code path exists that can leave a page without a cursor.
Density Toggle
A compact / comfortable / spacious switch that publishes seven --density-* CSS variables to a whole subtree, with a 24px target floor the compact step cannot cross.
Cursor Trail
A canvas wake that follows the pointer — frame-sampled positions drawn as a tapering ribbon, an even bead chain or drifting sparks, dissolving on their own lifetime and never mounted on touch or under reduced motion.