Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

/**
 * Two capability gates, both live. A drawn pointer only makes sense where a
 * hovering, fine pointer exists at all, and it steps aside entirely when the
 * visitor asked for less motion — a springy dot chasing the hand is exactly
 * the kind of thing that setting is for.
 */
const FINE_POINTER = "(hover: hover) and (pointer: fine)"
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/custom-cursor.json

Prompt

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) and prefers-reduced-motion are read first and the whole overlay is simply not rendered when either says no. Both are subscribed through matchMedia change 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 trapmix-blend-mode blends with the backdrop of the nearest ancestor stacking context, so a z-index, transform, filter, opacity < 1 or isolate anywhere 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 the inset-0 overlay 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 a data-zyeon-cursor attribute this component sets on its own root while the pointer is inside, never by writing document.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.

On This Page