cursorkit

documentation

cursorkit

Spring-powered custom cursor effects for React and Next.js. Variant stack, canvas plugins, magnetic effects, idle detection — zero runtime dependencies.

v1.1.0MITTypeScriptReact 18+SSR-safe

Installation

Install from npm. The only peer dependencies are react and react-dom ≥ 18. No additional runtime libraries required.

npm

npm install @izhann/cursorkit

yarn

yarn add @izhann/cursorkit

pnpm

pnpm add @izhann/cursorkit

Next.js Setup

In the Next.js App Router, place CursorProvider and Cursor inside a Client Component wrapper so they have access to browser APIs. The cleanest pattern is a dedicated Providers component referenced from your root layout.

app/providers.tsx

"use client"

import { CursorProvider, Cursor } from "@izhann/cursorkit"

export function Providers({ children }) {
  return (
    <CursorProvider>
      <Cursor zIndex={9999} elongate />
      {children}
    </CursorProvider>
  )
}

app/layout.tsx

import { Providers } from "./providers"

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}
All package exports already include "use client" in the bundle. Importing them from a Server Component is safe — Next.js treats them as Client Component boundaries automatically. You only need your own "use client" when you use hooks from this package (like useCursorIdle or useMagnetic) in your own components.

Quick Start

The minimum setup: CursorProvider wraps your app, Cursor renders the animated cursor element, and CursorTarget adds interactivity to individual elements.

Full example

import {
  CursorProvider, Cursor, CursorTarget,
  CursorTrail, CursorClickEffect,
} from "@izhann/cursorkit"

const config = {
  default: { width: 12, height: 12, shape: "circle", color: "#fff" },
  link:    { width: 40, height: 40, borderColor: "rgba(255,255,255,0.6)", borderWidth: 1.5 },
  button:  { width: 52, height: 52, label: "click", color: "transparent", borderColor: "#fff", borderWidth: 1 },
}

export default function App() {
  return (
    <CursorProvider config={config}>
      <Cursor zIndex={9999} elongate />
      <CursorClickEffect type="ripple" />
      <CursorTrail type="dots" color="rgba(255,255,255,0.4)" />

      <CursorTarget as="a" href="/about" variant="link">About</CursorTarget>
      <CursorTarget as="button" variant="button">Click me</CursorTarget>
    </CursorProvider>
  )
}

TypeScript

The package ships its own declaration files. Import types directly from the package root:

import type {
  CursorConfig,    // shape of the config object
  CursorVariant,   // shape of a single variant definition
  CursorState,     // variant + resolved props (what Cursor reads)
  VelocityState,   // returned by useVelocityCursor
  MousePosition,   // { x: number; y: number }
} from "@izhann/cursorkit"

Generic type parameters are supported on hooks that return refs:

const ref     = useMagnetic<HTMLButtonElement>()
const zoneRef = useCursorZone<HTMLSectionElement>({ variant: "view" })

core

CursorProvider

The context root. Must wrap the entire app (or any subtree that uses cursor effects). Manages the variant stack and distributes cursor state to all descendants. Mouse tracking is handled internally via a singleton — zero 60fps re-renders on consumers.

<CursorProvider
  config={config}       // variant definitions (see CursorConfig below)
  hideNativeCursor      // hides OS cursor; true by default
>
  <Cursor />
  {children}
</CursorProvider>
proptypedefaultdescription
configCursorConfigbuilt-inVariant definitions. Each key is a variant name. The default key is required.
hideNativeCursorbooleantrueInjects cursor:none!important on the body while mounted.

CursorConfig — variant definitions

A CursorConfig object maps variant names to visual and physics properties. The default key is required and acts as the fallback resting state.

const config: CursorConfig = {
  // Required — resting state
  default: {
    width: 12, height: 12,
    shape: "circle",           // "circle" | "square" | "custom"
    color: "rgba(255,255,255,0.9)",
    opacity: 1,
    transition: { stiffness: 900, damping: 88, mass: 0.8 },
  },
  // Named variants — activated by CursorTarget / useCursor
  link: {
    width: 44, height: 44,
    color: "transparent",
    borderColor: "rgba(255,255,255,0.65)",
    borderWidth: 1.5,
    label: "view",
    fontSize: "10px", labelColor: "rgba(255,255,255,0.7)",
  },
  button: {
    width: 60, height: 60,
    shape: "square", borderRadius: 8,
    color: "rgba(255,255,255,0.1)",
    mixBlendMode: "difference",  // CSS mix-blend-mode
  },
  custom: {
    shape: "custom",
    customElement: <MySVGIcon />, // replaces the default circle/square
  },
}

Variant stack

CursorProvider maintains a stack of active variants. CursorTarget pushes a variant on mouseenter and pops it on mouseleave, so nested targets compose correctly. You can control the stack imperatively:

// Via useCursorVariant hook (recommended)
const { setVariant, resetVariant, setGlobalVariant, clearGlobalVariant } = useCursorVariant()

// Via context directly
const { setCursorVariant, resetCursorToDefault } = useCursorContext()

setCursorVariant("link")            // push "link" (default mode)
setCursorVariant("",     "pop")    // remove the top of the stack
setCursorVariant("link", "reset") // clear stack, start fresh with "link"
resetCursorToDefault()              // reset stack to ["default"]

// Global variant — overrides all hover targets
setGlobalVariant("drag")  // lock cursor to "drag" during a drag interaction
clearGlobalVariant()       // release; stack takes over again

Cursor

The visible cursor element. Reads variant state from CursorProvider and animates position via a RAF loop using Velocity Verlet spring integration — never triggers a React re-render during animation. Place it once anywhere inside CursorProvider.

<Cursor
  zIndex={9999}
  elongate         // stretches cursor along velocity vector while moving
/>
proptypedefaultdescription
zIndexnumber9999CSS z-index of the cursor overlay.
elongatebooleanfalseSpring-smoothed stretch along the velocity vector while moving.

CursorTarget

A polymorphic wrapper that pushes a cursor variant on mouseenter and pops it on mouseleave. Also handles focus/blur for keyboard accessibility. All extra props are forwarded to the underlying element unchanged.

<CursorTarget
  as="a"              // any HTML tag or component — defaults to "div"
  href="..."
  variant="link"     // key in your CursorConfig
  onEnter={() => {}}  // fires after variant is pushed
  onLeave={() => {}}  // fires after variant is popped
>
  Hover me
</CursorTarget>
proptypedefaultdescription
asElementType"div"HTML tag or component to render as.
variantstringVariant name from CursorConfig to push on hover/focus.
onEnter() => voidCalled when the cursor enters the element.
onLeave() => voidCalled when the cursor leaves the element.

useCursor

Returns event handler props to manually wire variant behavior to any element. Useful when you can't use CursorTarget — for example, a component that already manages its own event handlers.

const handlers = useCursor("link")
// → { onMouseEnter, onMouseLeave, onFocus, onBlur, "data-cursor-target": true }

<a href="..." {...handlers}>Link</a>

useCursorVariant

Provides imperative control over the variant stack and global variant override. Useful for triggering cursor changes in response to application events (drag start, modal open, etc.).

const {
  setVariant,         // (variant: string) => void — push onto stack
  resetVariant,       // () => void — clear stack back to "default"
  setGlobalVariant,   // (variant: string) => void — override all hover targets
  clearGlobalVariant, // () => void — release global override
} = useCursorVariant()

// Example: lock cursor to "drag" while dragging a card
const onDragStart = () => setGlobalVariant("drag")
const onDragEnd   = () => clearGlobalVariant()

plugins

Plugins mount a canvas or DOM overlay above the page and respond to mouse and click events. Drop any plugin anywhere inside CursorProvider — they are independent of each other and can be combined freely. All plugins use the shared mouseStore singleton so no extra window listeners are created.

CursorClickEffect

Spawns an animated canvas effect on every mouse click. Also responds to emitCursorEvent("click") for programmatic triggers. The RAF loop is idle between clicks — zero CPU cost when nothing is animating.

<CursorClickEffect
  type="ripple"
  size={50}
  duration={600}
  color="rgba(255,255,255,0.8)"
  strokeWidth={2}
  particleCount={12}
  palette={["#ff6b6b", "#ffd93d", "#6bcb77"]}
  zIndex={9997}
/>
proptypedefaultdescription
typestring"ripple"Effect type — see table below.
sizenumber50Maximum diameter of the effect in px.
durationnumber600Animation duration in milliseconds.
colorstring"rgba(255,255,255,0.8)"Effect colour. Ignored for confetti — use palette instead.
strokeWidthnumber2Ring / particle stroke width in px.
particleCountnumber12Number of particles or rays emitted per click.
palettestring[]built-inArray of hex/rgba colours for the confetti type.
zIndexnumber9997CSS z-index of the canvas overlay.

Available effect types

proptypedefaultdescription
rippleExpanding ring that fades out — the classic click ripple.
burstParticles radiate outward from the click point.
sparkleStar-ray lines shoot outward from the click point.
shockwaveTwo concentric rings with a stagger — a shockwave feel.
confettiColoured rectangles fly outward with gravity. Use the palette prop.
implodeParticles rush inward then explode outward — reversed burst.

CursorTrail

Renders a trail behind the cursor on a full-screen canvas. Uses a dirty-flag pattern — the RAF loop only runs one frame per mouse movement, then suspends. The canvas retains its last drawn state while idle, so no CPU is wasted.

<CursorTrail
  type="dots"
  color="rgba(255,255,255,0.6)"
  size={8}        // dot diameter / stroke width in px
  count={12}      // number of positions to keep in the trail
  fadeTime={1800} // ink / spark: ms before fade-out
  spread={60}     // spark: initial spread velocity in px/s
  zIndex={9998}
/>
proptypedefaultdescription
typestring"dots"Trail style — see table below.
colorstring"rgba(255,255,255,0.6)"Trail colour.
sizenumber8Dot diameter or stroke width in px.
countnumber12Number of positions in the trail (dots / line / glow).
fadeTimenumber1800Ink and spark: ms before strokes or particles fade out.
spreadnumber60Spark: initial particle spread speed in px/s.
zIndexnumber9998CSS z-index of the canvas overlay.

Available trail types

proptypedefaultdescription
dotsFading circles spaced along the trail. count controls trail length.
lineTapered Bézier brushstroke that follows the cursor.
glowSoft radial-gradient halos along the trail path.
inkA continuous drawn stroke that fades over fadeTime ms.
sparkEach cursor position spawns a drifting particle with gravity.

CursorSpotlight

A full-screen DOM overlay that follows the cursor with a radial-gradient light effect. Useful for dark hero sections. Set a bright color to illuminate content around the cursor, or a dark color with low opacity for a vignette that clears on hover.

<CursorSpotlight
  color="rgba(255,255,255,0.07)"
  size={400}
  blend="normal"   // CSS mix-blend-mode
  zIndex={0}
/>
proptypedefaultdescription
colorstring"rgba(255,255,255,0.06)"Spotlight fill colour. Use rgba for transparency.
sizenumber400Diameter of the lit circle in px.
blendstring"normal"CSS mix-blend-mode applied to the overlay.
zIndexnumber0CSS z-index. Use 0 to stay behind content.

CursorReveal

A wrapper component that hides children behind a dark overlay and cuts a circular hole following the cursor — revealing the content beneath. Unlike other plugins, this is not standalone: it requires children and wraps them.

<CursorReveal
  size={180}
  overlay="rgba(9,9,11,0.92)"
  blur={4}
  style={{ borderRadius: "16px", overflow: "hidden" }}
>
  <img src="/photo.jpg" alt="hidden content" />
  <p>Move cursor over this area to reveal it.</p>
</CursorReveal>
proptypedefaultdescription
childrenReactNodeContent to wrap. The overlay sits on top of it.
sizenumber180Diameter of the circular reveal hole in px.
overlaystring"rgba(9,9,11,0.92)"Colour of the overlay that hides the content.
blurnumber0Backdrop blur (px) applied to the hidden area. 0 = off.
classNamestringCSS class forwarded to the container div.
styleCSSPropertiesInline styles forwarded to the container div.
CursorReveal uses CSS mask-image. For best results, give the container a defined height and overflow: hidden so the overlay fills it completely.

CursorDraw

Freehand drawing canvas. Hold the mouse button down to draw strokes. Strokes persist until unmount, or fade over fadeTime ms when set. The RAF loop only runs while drawing or while strokes are actively fading.

<CursorDraw
  color="rgba(255,255,255,0.85)"
  width={2}
  fadeTime={3000}   // 0 = strokes are permanent until unmount
  enabled={true}    // false = pause drawing, keep existing strokes
  onStroke={(pts) => console.log(pts)}
  zIndex={9996}
/>
proptypedefaultdescription
colorstring"rgba(255,255,255,0.85)"Ink colour.
widthnumber2Stroke width in px.
fadeTimenumber0ms before a completed stroke fades. 0 = permanent.
enabledbooleantrueWhen false, drawing is paused but existing strokes are preserved.
onStrokefunctionCalled with the array of {x, y} points after each stroke completes.
zIndexnumber9996CSS z-index of the canvas overlay.

CursorLens

Wraps children and shows a circular magnifying-glass lens that follows the cursor over the wrapped area. The lens renders a true zoomed copy of the children via DOM duplication and CSS scale — no canvas, no screenshots, no blur artifacts.

<CursorLens
  size={140}   // lens circle diameter in px
  scale={2.2} // zoom factor inside the lens
  border="1.5px solid rgba(255,255,255,0.25)"
  zIndex={9995}
>
  <img src="/photo.jpg" alt="..." />
</CursorLens>
proptypedefaultdescription
childrenReactNodeContent to magnify. Renders twice: normally + scaled inside the lens.
sizenumber140Lens circle diameter in px.
scalenumber2.2Zoom factor. 2 = 2× magnification.
borderstring"1.5px solid rgba(255,255,255,0.25)"CSS border of the lens frame.
zIndexnumber9995CSS z-index of the lens overlay.

hooks

useMagnetic

Attaches a magnetic pull to an element: when the cursor comes within distancepx of the element's center it shifts toward the cursor. Uses mouseStore directly — zero React re-renders in the mouse-tracking path.

const ref = useMagnetic({ strength: 0.35, distance: 80 })

<button ref={ref}>Hover me</button>
proptypedefaultdescription
strengthnumber0.35Pull intensity: 0 = no pull, 1 = element snaps to cursor center.
distancenumber80Activation radius in px from the element's center.

Returns a RefObject to attach to the target element. The hook writes transform and transition directly on el.style — no wrapper div or extra markup is needed.

useCursorIdle

Automatically switches to a cursor variant after the cursor has been still for timeout ms. Reverts when movement resumes. This hook does not return anything — it manages the variant stack internally.

// Place inside any component inside CursorProvider
useCursorIdle({
  timeout: 2000,       // ms of stillness before idle fires
  variant: "spot",    // variant to push when idle
  onIdle:  () => console.log("cursor is idle"),
  onWake:  () => console.log("cursor moved again"),
})
proptypedefaultdescription
timeoutnumber2000ms of no cursor movement before idle triggers.
variantstring"spot"Variant name pushed onto the stack when idle.
onIdle() => voidCallback fired when idle state begins.
onWake() => voidCallback fired when cursor movement resumes.

useCursorZone

Returns a ref that, when attached to any element, pushes a variant onto the stack when that element scrolls into view (via IntersectionObserver) and pops it when it scrolls out. Zero configuration beyond the variant name.

const ref = useCursorZone({
  variant:   "view",  // variant to activate when element is in view
  threshold: 0.5,     // 0–1: fraction of element visible to trigger
})

<section ref={ref}>
  Scrolling this into view activates the "view" cursor variant.
</section>
proptypedefaultdescription
variantstringVariant to push when the element enters the viewport.
thresholdnumber0.5Fraction of the element that must be visible to trigger (0–1).

useVelocityCursor

Tracks cursor speed and direction with an exponential moving average (α = 0.12). The RAF loop suspends after ~3 s of near-zero velocity and wakes automatically on the next mouse move.

const { speed, angle, vx, vy, isMoving } = useVelocityCursor({
  idleThreshold: 50, // px/s below which isMoving becomes false
})

// speed    — smoothed magnitude in px/s
// angle    — direction in degrees (0 = right, 90 = down)
// vx / vy  — signed component velocities in px/s
// isMoving — true when speed > idleThreshold
proptypedefaultdescription
idleThresholdnumber50Speed (px/s) below which isMoving becomes false.

useMousePosition

Returns a live { x, y } mouse position, RAF-throttled to update once per animation frame. Uses the shared mouseStore singleton — no extra mousemove listener is added to the window.

const { x, y } = useMousePosition()

<p>Cursor is at {x}, {y}</p>
Each component calling useMousePosition() re-renders ~60fps. For animation loops or canvas drawing where you need position without React re-renders, read mouseStore.x / mouseStore.y directly in a useEffect or RAF callback instead.

utilities

themes

Ready-made CursorConfig presets. Pass directly to CursorProvider. Each theme defines variants for default, link, button, text, view, drag, close, spot, invert, and square.

import { themes } from "@izhann/cursorkit"

// themes.minimal      — hairline borders, tiny dot, designed to disappear
// themes.neon         — emissive cyan/magenta on dark backgrounds
// themes.glassmorphism — frosted glass, translucent fills, slow spring
// themes.brutalist    — hard edges, solid fills, zero rounding, high contrast

<CursorProvider config={themes.neon}>
  ...
</CursorProvider>

Themes are plain objects — spread and override individual variants to customise:

const myConfig = {
  ...themes.minimal,
  link: {
    ...themes.minimal.link,
    borderColor: "#6366f1",  // override one property
  },
}

mouseStore

A module-level singleton that holds the last known cursor position and notifies subscribers via a single shared mousemove listener. All plugins and hooks use it internally. Exported for advanced use cases — custom canvas animations, non-React effects, etc.

import { mouseStore } from "@izhann/cursorkit"

// Read position synchronously (no re-render)
console.log(mouseStore.x, mouseStore.y)

// Subscribe to updates (e.g. inside useEffect)
useEffect(() => {
  return mouseStore.subscribe((x, y) => {
    myCanvas.draw(x, y)   // runs on every mousemove
  })
}, [])
proptypedefaultdescription
xnumber0Last known clientX. Returns 0 before any mouse move.
ynumber0Last known clientY. Returns 0 before any mouse move.
subscribe(fn) => () => voidSubscribe to position updates. Returns an unsubscribe function.

emitCursorEvent

Programmatically triggers a cursor click effect at any screen position without requiring actual user input. Any mounted CursorClickEffect reacts to it automatically via a cursor:click CustomEvent on window.

import { emitCursorEvent } from "@izhann/cursorkit"

// Fire at screen center (default when x/y omitted)
emitCursorEvent("click")

// Fire at specific coordinates
emitCursorEvent("click", { x: 400, y: 300 })

// Fire at a button's center
const rect = btnRef.current.getBoundingClientRect()
emitCursorEvent("click", {
  x: rect.left + rect.width  / 2,
  y: rect.top  + rect.height / 2,
})

reference

TypeScript types

All types are exported from the package root and available in dist/ declarations:

import type {
  CursorVariant,  // shape of one variant definition
  CursorConfig,   // { default: CursorVariant; [name: string]: CursorVariant }
  CursorState,    // CursorVariant & { variant: string } — what Cursor reads
  VelocityState,  // { speed, angle, vx, vy, isMoving }
  MousePosition,  // { x: number; y: number }
} from "@izhann/cursorkit"

CursorVariant fields

proptypedefaultdescription
widthnumber24Width in px.
heightnumber24Height in px.
scalenumber1CSS scale applied on top of width/height.
shapestring"circle""circle" | "square" | "custom"
borderRadiusnumberExplicit border-radius in px (overrides shape).
colorstring"rgba(255,255,255,0.8)"Fill colour.
opacitynumber1CSS opacity (0–1).
borderColorstring"transparent"Border colour.
borderWidthnumber0Border width in px.
borderStylestring"solid"CSS border-style.
mixBlendModestringCSS mix-blend-mode — e.g. "difference" for invert effect.
labelstring | ReactNodeText or element rendered centred inside the cursor.
fontSizestring | number"12px"Font size for the label.
fontWeightstring | number"bold"Font weight for the label.
labelColorstring"#000"Label text colour.
customElementReactNodeReplaces the entire cursor element when shape is "custom".
innerElementsarrayArray of { element, transition } for layered elements with independent springs.
transitionobjectSpring: { stiffness, damping, mass, delay }.

SSR & React Server Components

The package is fully SSR-safe. All browser API usage (window, document, requestAnimationFrame) is guarded by typeof window !== "undefined" checks or deferred to useEffect. The mouseStore singleton lazy-initializes its listener on first subscribe, not at module load.

Both ESM and CJS outputs include "use client" at the top of the bundle. Importing from a Next.js Server Component is safe — Next.js automatically creates the correct client boundary at the import site.

Place CursorProvider inside a "use client" wrapper component (like the Providers pattern in the Next.js Setup section) rather than directly in a Server Component, so the React context is available to all children.

Performance

The package is designed to produce zero React re-renders during cursor animation:

@izhann/cursorkit · v1.2.1 · MIT← Back to showcase