@izhann/cursorkit
v1.2.2
Published
Interactive cursor effects for React and Next.js applications — zero runtime dependencies
Maintainers
Readme
@izhann/cursorkit
Spring-powered custom cursor effects for React and Next.js. Zero runtime dependencies.
- Velocity Verlet spring physics with sub-step integration
- Variant stack — push/pop variants on hover, reset, or global override
- 7 canvas plugins (trail, click effects, spotlight, reveal, draw, lens)
- Magnetic pull, idle detection, scroll-zone switching, velocity tracking
- 4 built-in themes:
minimal,neon,glassmorphism,brutalist - Full TypeScript support
- Works in Next.js App Router (SSR safe — all components are
"use client")
Installation
npm install @izhann/cursorkit
# or
yarn add @izhann/cursorkit
# or
pnpm add @izhann/cursorkitNo other dependencies required.
Quick Start
import { CursorProvider, Cursor } from "@izhann/cursorkit"
export default function App() {
return (
<CursorProvider>
<Cursor />
{/* your app */}
</CursorProvider>
)
}Next.js (App Router)
Put CursorProvider and Cursor in a client component — typically your root layout wrapper:
// components/CursorWrapper.tsx
"use client"
import { CursorProvider, Cursor } from "@izhann/cursorkit"
export function CursorWrapper({ children }: { children: React.ReactNode }) {
return (
<CursorProvider>
<Cursor />
{children}
</CursorProvider>
)
}// app/layout.tsx (Server Component — no "use client")
import { CursorWrapper } from "@/components/CursorWrapper"
export default function RootLayout({ children }) {
return (
<html>
<body>
<CursorWrapper>{children}</CursorWrapper>
</body>
</html>
)
}Core API
<CursorProvider>
Provides cursor context to your entire app. Must wrap <Cursor> and any component that uses cursor hooks.
| Prop | Type | Default | Description |
|---|---|---|---|
| config | CursorConfig | see below | Map of variant names to CursorVariant objects |
| hideNativeCursor | boolean | true | Injects cursor: none globally |
const config = {
default: { width: 20, height: 20, shape: "circle", color: "white" },
link: { width: 40, height: 40, color: "transparent", borderColor: "white", borderWidth: 1 },
button: { width: 50, height: 50, label: "click", fontSize: "10px", labelColor: "#000" },
}
<CursorProvider config={config} hideNativeCursor>
<Cursor />
</CursorProvider><Cursor>
Renders the animated cursor element. Place once inside CursorProvider.
| Prop | Type | Default | Description |
|---|---|---|---|
| zIndex | number | 9999 | CSS z-index |
| showOnTouch | boolean | false | Render on touch/pointer-coarse devices |
| trailLength | number | 10 | History length used for inner-element parallax |
| elongate | boolean | false | Squash-and-stretch in direction of travel (circles only) |
<CursorTarget>
Wraps any element. On hover, pushes a variant onto the cursor stack; on leave, pops it.
| Prop | Type | Required | Description |
|---|---|---|---|
| variant | string | Yes | Variant key defined in your CursorProvider config |
| as | ElementType | No | Renders as this element type (default "div") |
| onEnter | () => void | No | Called on mouseenter |
| onLeave | () => void | No | Called on mouseleave |
import { CursorTarget } from "@izhann/cursorkit"
<CursorTarget as="a" variant="link" href="/about">
About
</CursorTarget>
<CursorTarget as="button" variant="button" onClick={handleClick}>
Click me
</CursorTarget>CursorVariant Type
Every key in your config object is a CursorVariant. All fields are optional.
interface CursorVariant {
// Size & shape
width?: number // px
height?: number // px
scale?: number // multiplier applied via CSS transform
shape?: "circle" | "square" | "custom"
borderRadius?: number // px — for square shapes
// Appearance
color?: string // background color
opacity?: number // 0–1
borderColor?: string
borderWidth?: number // px
borderStyle?: string
mixBlendMode?: string // CSS mix-blend-mode
// Label
label?: string | ReactNode
fontSize?: string | number
fontWeight?: string | number
labelColor?: string
// Custom content
customElement?: ReactNode // rendered when shape="custom"
innerElements?: Array<{
element: ReactNode
transition?: { stiffness?: number; damping?: number; mass?: number }
}>
// Spring physics
transition?: {
stiffness?: number // default 800 (higher = snappier)
damping?: number // default 80 (higher = less bounce)
mass?: number // default 1 (higher = more inertia)
}
}Example — invert blend mode:
invert: {
width: 50, height: 50,
shape: "circle",
color: "white",
mixBlendMode: "difference",
}Themes
Four ready-made configs. Pass directly to CursorProvider:
import { CursorProvider, Cursor, themes } from "@izhann/cursorkit"
<CursorProvider config={themes.neon}>
<Cursor />
</CursorProvider>Available themes: "minimal" · "neon" · "glassmorphism" · "brutalist"
Each theme ships with variants: default, link, button, text, view, drag, close, spot, and more.
Plugins
All plugins are canvas- or DOM-based, position: fixed, and render independently of the cursor's spring animation.
<CursorTrail>
Draws a trail behind the cursor.
| Prop | Type | Default | Description |
|---|---|---|---|
| type | "dots" \| "line" \| "glow" \| "ink" \| "spark" | "dots" | Trail style |
| count | number | 12 | Number of trail points (dots/line/glow) |
| color | string | "rgba(255,255,255,0.6)" | Trail color |
| size | number | 8 | Dot diameter / line width in px |
| zIndex | number | 9998 | CSS z-index |
| fadeTime | number | 1800 | ms before ink fades (ink/spark only) |
| spread | number | 60 | Initial particle velocity for spark type |
<CursorTrail type="ink" color="rgba(99,102,241,0.8)" size={3} />
<CursorTrail type="spark" color="#f472b6" spread={80} />
<CursorTrail type="glow" color="rgba(56,189,248,0.5)" size={12} /><CursorClickEffect>
Plays a canvas animation on every click.
| Prop | Type | Default | Description |
|---|---|---|---|
| type | "ripple" \| "burst" \| "sparkle" \| "shockwave" \| "confetti" \| "implode" | "ripple" | Effect style |
| size | number | 50 | Maximum diameter in px |
| duration | number | 600 | Animation duration in ms |
| color | string | "rgba(255,255,255,0.8)" | Effect color |
| strokeWidth | number | 2 | Line/particle width in px |
| particleCount | number | 12 | Number of particles or rays |
| palette | string[] | built-in | Color palette for confetti type |
| zIndex | number | 9997 | CSS z-index |
<CursorClickEffect type="confetti" size={80} duration={900} />
<CursorClickEffect type="shockwave" color="rgba(99,102,241,0.9)" />Also triggered programmatically via emitCursorEvent("click", { x, y }).
<CursorSpotlight>
A radial gradient spotlight that follows the cursor — useful for dark UIs.
| Prop | Type | Default | Description |
|---|---|---|---|
| color | string | "rgba(255,255,255,0.06)" | Spotlight fill color |
| size | number | 400 | Diameter in px |
| zIndex | number | 0 | CSS z-index |
| blend | string | "normal" | CSS mix-blend-mode |
<CursorSpotlight color="rgba(139,92,246,0.12)" size={500} /><CursorReveal>
Wraps content with a dark overlay. Moving the cursor cuts a circular hole, revealing what's underneath.
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Content to reveal |
| size | number | 180 | Diameter of the reveal circle in px |
| overlay | string | "rgba(9,9,11,0.92)" | Color of the overlay |
| blur | number | 0 | Backdrop blur in px applied to hidden areas |
| className | string | — | Applied to the outer container |
| style | CSSProperties | — | Applied to the outer container |
<CursorReveal size={220} overlay="rgba(0,0,0,0.88)" blur={4}>
<img src="/hero.jpg" alt="Hero" />
</CursorReveal><CursorDraw>
Freehand drawing on a fixed canvas overlay.
| Prop | Type | Default | Description |
|---|---|---|---|
| color | string | "rgba(255,255,255,0.85)" | Ink color |
| width | number | 2 | Stroke width in px |
| eraseWidth | number | 20 | Eraser diameter in px |
| fadeTime | number | 0 | ms before strokes fade (0 = permanent) |
| enabled | boolean | true | Pause drawing without unmounting |
| zIndex | number | 9996 | CSS z-index |
| onStroke | (points: {x,y}[]) => void | — | Called after each draw stroke |
Controls:
- Left-click drag — draw
- Right-click drag — erase (eraser circle indicator shown while erasing)
- Double-click — clear all strokes
Strokes are stored in page coordinates and scroll-anchored — drawings stay attached to page content, not the viewport.
<CursorDraw color="#f472b6" width={3} eraseWidth={30} />
<CursorDraw color="white" width={1.5} fadeTime={3000} /><CursorLens>
Wraps content and shows a circular magnifying-glass lens that follows the cursor.
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Content to magnify |
| size | number | 140 | Lens diameter in px |
| scale | number | 2.2 | Zoom factor |
| border | string | "1.5px solid rgba(255,255,255,0.25)" | Lens frame border |
| zIndex | number | 9995 | CSS z-index |
<CursorLens scale={2.5}>
<img src="/map.png" alt="Map" style={{ width: "100%" }} />
</CursorLens>useMagnetic()
Attaches a magnetic pull to any element — when the cursor comes within range, the element shifts toward it.
| Option | Type | Default | Description |
|---|---|---|---|
| strength | number | 0.35 | Pull strength (0 = none, 1 = full snap) |
| distance | number | 80 | Trigger radius in px |
import { useMagnetic } from "@izhann/cursorkit"
function MagneticButton() {
const ref = useMagnetic<HTMLButtonElement>({ strength: 0.4, distance: 100 })
return <button ref={ref}>Hover me</button>
}Hooks
useCursorVariant()
Programmatically push/pop/reset the cursor variant from inside any component.
import { useCursorVariant } from "@izhann/cursorkit"
function MyComponent() {
const { setCursorVariant, resetCursorToDefault, setGlobalCursorVariant } = useCursorVariant()
return (
<div
onMouseEnter={() => setCursorVariant("link", "push")}
onMouseLeave={() => setCursorVariant("", "pop")}
>
hover me
</div>
)
}Methods:
| Method | Description |
|---|---|
| setCursorVariant(variant, mode?) | mode: "push" (default), "pop", or "reset" |
| resetCursorToDefault() | Clears the stack back to "default" |
| setGlobalCursorVariant(variant) | Overrides the entire stack — useful for page-level states |
| clearGlobalCursorVariant() | Removes the global override |
useCursorIdle()
Switches to a variant after the cursor stops moving.
| Option | Type | Default | Description |
|---|---|---|---|
| timeout | number | 2000 | ms of inactivity before idle fires |
| variant | string | "spot" | Variant pushed when idle |
| onIdle | () => void | — | Called when idle starts |
| onWake | () => void | — | Called when movement resumes |
import { useCursorIdle } from "@izhann/cursorkit"
function App() {
useCursorIdle({ timeout: 3000, variant: "spot", onIdle: () => console.log("idle") })
return <div>...</div>
}useCursorZone()
Returns a ref that, when attached to an element, switches the cursor variant based on scroll visibility.
| Option | Type | Default | Description |
|---|---|---|---|
| variant | string | required | Variant to activate when element is in view |
| threshold | number | 0.5 | IntersectionObserver threshold (0–1) |
import { useCursorZone } from "@izhann/cursorkit"
function Section() {
const ref = useCursorZone<HTMLDivElement>({ variant: "drag", threshold: 0.4 })
return <div ref={ref}>...</div>
}useVelocityCursor()
Returns smoothed cursor velocity state, updated via RAF with exponential moving average. Self-suspends when idle.
| Option | Type | Default | Description |
|---|---|---|---|
| idleThreshold | number | 50 | px/s below which isMoving is false |
Returns VelocityState:
{
speed: number // smoothed speed in px/s
angle: number // direction in degrees (0=right, 90=down)
vx: number // horizontal component in px/s
vy: number // vertical component in px/s
isMoving: boolean // true when speed > idleThreshold
}import { useVelocityCursor } from "@izhann/cursorkit"
function SpeedDisplay() {
const { speed, isMoving } = useVelocityCursor({ idleThreshold: 30 })
return <div>{isMoving ? `${speed} px/s` : "idle"}</div>
}useMousePosition()
Returns the live cursor position, updated on every mousemove.
import { useMousePosition } from "@izhann/cursorkit"
function Coords() {
const { x, y } = useMousePosition()
return <div>{x}, {y}</div>
}useCursor()
Low-level hook used internally by <CursorTarget>. Returns event handlers to spread onto an element.
import { useCursor } from "@izhann/cursorkit"
function Custom() {
const handlers = useCursor({
variant: "link",
onEnter: () => console.log("in"),
onLeave: () => console.log("out"),
})
return <div {...handlers}>hover</div>
}Utilities
emitCursorEvent()
Programmatically triggers a CursorClickEffect at any screen position without a real mouse click.
import { emitCursorEvent } from "@izhann/cursorkit"
// Trigger at screen centre
emitCursorEvent("click", { x: window.innerWidth / 2, y: window.innerHeight / 2 })
// Trigger at an element's centre
const rect = buttonRef.current.getBoundingClientRect()
emitCursorEvent("click", { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 })mouseStore
Singleton that holds the current mouse position and dispatches to all subscribers via a single window.mousemove listener. Used internally by all plugins. Access it directly if you need cursor position outside React without triggering a re-render.
import { mouseStore } from "@izhann/cursorkit"
// Read current position
console.log(mouseStore.x, mouseStore.y)
// Subscribe to movement
const unsub = mouseStore.subscribe((x, y) => {
myCanvas.style.backgroundPosition = `${x}px ${y}px`
})
// Unsubscribe when done
unsub()Full Example
"use client"
import {
CursorProvider, Cursor, CursorTarget,
CursorTrail, CursorClickEffect, CursorSpotlight,
useMagnetic, themes,
} from "@izhann/cursorkit"
export default function App() {
const magnetRef = useMagnetic<HTMLButtonElement>({ strength: 0.4 })
return (
<CursorProvider config={themes.minimal}>
<Cursor elongate />
<CursorTrail type="dots" color="rgba(255,255,255,0.4)" />
<CursorClickEffect type="ripple" />
<CursorSpotlight size={500} color="rgba(255,255,255,0.05)" />
<main>
<CursorTarget as="a" variant="link" href="/">Home</CursorTarget>
<button ref={magnetRef}>Magnetic button</button>
</main>
</CursorProvider>
)
}License
MIT
