A custom cursor for one area that glides after the pointer on a spring, leans into the direction of travel, shrinks on press and grows a label over elements marked with data-cursor-label.
Move around. Hover a tile to see its label.
import { SmoothCursor } from "@/components/ballmac/smooth-cursor"
const cards = [
{ name: "Aurora", from: "var(--chart-1)", to: "var(--chart-4)" },
{ name: "Lagoon", from: "var(--chart-2)", to: "var(--chart-1)" },
{ name: "Ember", from: "var(--chart-3)", to: "var(--chart-5)" },
]
export default function SmoothCursorDemo() {
return (
<SmoothCursor className="w-full max-w-xl rounded-2xl border bg-card p-5">
<p className="text-sm text-muted-foreground">Move around. Hover a tile to see its label.</p>
<div className="mt-3 grid grid-cols-3 gap-3">
{cards.map((c) => (
<div
key={c.name}
data-cursor-label={`View ${c.name}`}
className="aspect-[4/5] rounded-xl border"
style={{ background: `linear-gradient(160deg, ${c.from}, ${c.to})` }}
/>
))}
</div>
<input aria-label="Your email" placeholder="Text fields keep the normal cursor" className="mt-4 h-9 w-full rounded-md border bg-background px-3 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" />
</SmoothCursor>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/smooth-cursorInstall the dependencies.
$ pnpm add motion@^12Copy the source into your project.
components/ballmac/smooth-cursor.tsx// Ballmac UI: Smooth Cursor. https://ui.ballmac.com/components/smooth-cursor "use client" import * as React from "react" import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring } from "motion/react" import { cn } from "@/lib/utils" type SmoothCursorProps = Omit<React.ComponentProps<"div">, "children"> & { /** The area the custom cursor lives in. Outside it, the normal cursor returns. */ children: React.ReactNode /** Your own cursor graphic. The default is an arrow that leans into the direction you are moving. */ cursor?: React.ReactNode /** Hide the system cursor inside the area. Text fields always keep theirs. */ hideNative?: boolean /** Stiffness of the follow spring. Lower is floatier. */ stiffness?: number } const DefaultArrow = () => ( <svg width="26" height="26" viewBox="0 0 26 26" fill="none" className="drop-shadow-[0_2px_4px_rgb(0_0_0/0.3)]"> <path d="M4 3.2c0-.9 1-1.4 1.7-.8l17 14.2c.8.7.4 2-.7 2.1l-7.2.7-3.3 6.4c-.5.9-1.8.8-2.1-.2L4 3.2Z" className="fill-foreground stroke-background" strokeWidth="1.6" strokeLinejoin="round" /> </svg> ) /** * Replaces the pointer with a cursor that glides after the real one. Add data-cursor-label="View" to any * element and the cursor grows a label while it is over it. Mouse and pen only; touch is left alone. */ function SmoothCursor({ children, cursor, hideNative = true, stiffness = 420, className, onPointerMove, onPointerLeave, onPointerDown, onPointerUp, ...props }: SmoothCursorProps) { const reduce = useReducedMotion() const x = useMotionValue(-100) const y = useMotionValue(-100) const sx = useSpring(x, { stiffness, damping: 34, mass: 0.6 }) const sy = useSpring(y, { stiffness, damping: 34, mass: 0.6 }) const turn = useSpring(0, { stiffness: 200, damping: 22 }) const [visible, setVisible] = React.useState(false) const [pressed, setPressed] = React.useState(false) const [label, setLabel] = React.useState<string | null>(null) const [overField, setOverField] = React.useState(false) const last = React.useRef<{ x: number; y: number } | null>(null) const settle = React.useRef(0) React.useEffect(() => () => window.clearTimeout(settle.current), []) return ( <div data-slot="smooth-cursor" onPointerMove={(e) => { onPointerMove?.(e) if (e.pointerType === "touch") return const box = e.currentTarget.getBoundingClientRect() const px = e.clientX - box.left const py = e.clientY - box.top x.set(px) y.set(py) if (reduce) { sx.jump(px) sy.jump(py) } // Lean the arrow into the direction of travel, then let it settle upright when the pointer stops. const prev = last.current if (prev && !reduce) { turn.set(Math.max(-28, Math.min(28, (px - prev.x) * 1.4))) window.clearTimeout(settle.current) settle.current = window.setTimeout(() => turn.set(0), 110) } last.current = { x: px, y: py } const target = e.target as HTMLElement setLabel(target.closest<HTMLElement>("[data-cursor-label]")?.dataset.cursorLabel ?? null) setOverField(!!target.closest("input, textarea, select, [contenteditable=true], [data-cursor=native]")) setVisible(true) }} onPointerLeave={(e) => { onPointerLeave?.(e) setVisible(false) setPressed(false) last.current = null }} onPointerDown={(e) => { onPointerDown?.(e) if (e.pointerType !== "touch") setPressed(true) }} onPointerUp={(e) => { onPointerUp?.(e) setPressed(false) }} className={cn( "relative", hideNative && "[@media(pointer:fine)]:[&_*]:cursor-none [@media(pointer:fine)]:[&_input]:cursor-text [@media(pointer:fine)]:[&_textarea]:cursor-text [@media(pointer:fine)]:[&_[data-cursor=native]]:cursor-auto", className )} {...props} > {children} <AnimatePresence> {visible && !overField && ( <motion.div aria-hidden="true" className="pointer-events-none absolute top-0 start-0 z-50 hidden [@media(pointer:fine)]:block" style={{ x: sx, y: sy }} initial={{ opacity: 0, scale: 0.6 }} animate={{ opacity: 1, scale: pressed ? 0.82 : label ? 1.1 : 1 }} exit={{ opacity: 0, scale: 0.6 }} transition={{ type: "spring", stiffness: 500, damping: 30 }} > <motion.div style={{ rotate: reduce ? 0 : turn, originX: 0.15, originY: 0.1 }}>{cursor ?? <DefaultArrow />}</motion.div> {label && ( <motion.span initial={{ opacity: 0, x: -4 }} animate={{ opacity: 1, x: 0 }} className="absolute top-5 start-6 rounded-full bg-foreground px-2.5 py-1 text-xs font-medium whitespace-nowrap text-background shadow-md" > {label} </motion.span> )} </motion.div> )} </AnimatePresence> </div> ) } export { SmoothCursor, type SmoothCursorProps }Update the import paths to match your project setup.
Usage
import { SmoothCursor } from "@/components/ballmac/smooth-cursor"The full example is in the Code tab above.
Examples
Custom cursor graphic
A floaty ring instead of an arrow.
import { SmoothCursor } from "@/components/ballmac/smooth-cursor"
export default function SmoothCursorCustom() {
return (
<SmoothCursor
stiffness={260}
cursor={<span className="block size-8 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-foreground bg-foreground/10 backdrop-blur-sm" />}
className="flex h-56 w-full max-w-md items-center justify-center rounded-2xl border bg-card"
>
<p className="pointer-events-none text-center text-sm text-muted-foreground">A floaty ring instead of an arrow.</p>
</SmoothCursor>
)
}API reference
| Prop | Type | Default |
|---|---|---|
children*The area the custom cursor lives in. Outside it, the normal cursor returns. | React.ReactNode | — |
cursorYour own cursor graphic. The default is an arrow that leans into the direction you are moving. | React.ReactNode | — |
hideNativeHide the system cursor inside the area. Text fields always keep theirs. | boolean | true |
stiffnessStiffness of the follow spring. Lower is floatier. | number | 420 |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Keyboard | Unaffected: focus rings and tab order do not change |
| Screen readers | The cursor is aria-hidden |
| Reduced motion | The cursor follows the pointer with no spring or lean |
Use with AI
<SmoothCursor cursor hideNative stiffness>area</SmoothCursor>. It only covers its own area, ignores touch, leaves text fields on the normal cursor and sets no global styles. data-cursor-label='View' on any child adds a label. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Smooth Cursor (@ballmac/smooth-cursor) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Portfolios and galleries
- Product tours with hover storytelling
Not for
- Form-heavy or data-dense screens
- Touch-first products
Registry JSON: https://ui.ballmac.com/r/smooth-cursor.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- shadcn/utils
Pairs well with
Magnetic Button
The Ballmac Button with a magnetic pull: it drifts toward a nearby mouse pointer on a spring, and its label moves a little further for depth. Takes every Button prop.
Tilt Card
A card that tilts in 3D toward the pointer with spring smoothing and a moving glare. TiltCardLayer children float at their own depth for parallax. Keyboard focus shows a gentle tilt.
Animated Beam
An SVG beam that connects two elements with a curved path and sends a glowing gradient pulse along it. Follows layout changes, pauses off-screen, static under reduced motion.
Animated Grid
A decorative hairline grid background where a few random cells softly light up and fade, like instrument lights. SVG, hydration-safe, static under reduced motion.