A customizable React cursor with an optional name chip. Built with Motion and GSAP.
Cursor
Move your mouse around the preview area to see the cursor in action.
import { Cursor, CursorName } from "@/components/cursor";
export default function Page() {
return (
<html>
<body>
<Cursor
className="fill-accent"
hideBrowserCursor={hideBrowserCursor}
>
{withChip ? (
<CursorName className="bg-accent text-background">
{chipText}
</CursorName>
) : null}
</Cursor>
</body>
</html>
);
}Install the following dependencies:
npm install motionbun add motiondeno add motionpnpm add motionyarn add motionCopy and paste the following code into your project.
"use client";
import {
type MotionProps,
motion,
useReducedMotion,
useSpring,
} from "motion/react";
import {
type ComponentProps,
type ReactNode,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
const SPRING = {
damping: 10,
mass: 0.1,
stiffness: 150,
type: "spring",
} as const;
interface GenericNamedCursorProps {
children?: ReactNode;
className?: string;
}
interface ControlledNamedCursorProps extends GenericNamedCursorProps {
controlled: true;
x: number;
y: number;
}
interface UncontrolledNamedCursorProps extends GenericNamedCursorProps {
controlled?: false;
hideBrowserCursor?: boolean;
relativeToParent?: boolean;
}
type NamedCursorProps =
| ControlledNamedCursorProps
| UncontrolledNamedCursorProps;
const cursorPresence = {
hidden: { opacity: 0 },
shown: ({
visible,
reduceMotion,
}: {
visible: boolean;
reduceMotion: boolean;
}) => ({
opacity: visible ? 1 : 0,
transition: {
duration: reduceMotion ? 0 : visible ? 0.28 : 0.18,
ease: [0.22, 1, 0.36, 1] as const,
},
}),
};
const CursorName = ({ className, ...props }: ComponentProps<"span">) => (
<span
className={cn(
"squircle absolute top-3 left-3 whitespace-nowrap rounded-xl px-2 py-1 text-background text-xs shadow shadow-secondary/20",
className
)}
{...props}
/>
);
/**
* Cursor component that can be used to show a cursor on the screen.
* By default, the cursor transition is the following:
*
* ```tsx
* {
* damping: 10,
* mass: 0.1,
* stiffness: 150
* }
* ```
*
* @param props
* @returns
*/
const Cursor = (props: NamedCursorProps & Pick<MotionProps, "transition">) => {
const isControlled = props.controlled === true;
const hideBrowserCursor = !isControlled && Boolean(props.hideBrowserCursor);
const relativeToParent = !isControlled && Boolean(props.relativeToParent);
const x = isControlled ? props.x : 0;
const y = isControlled ? props.y : 0;
const reduceMotion = useReducedMotion() ?? false;
const rootRef = useRef<HTMLDivElement>(null);
const revealedRef = useRef(isControlled);
const [visible, setVisible] = useState(isControlled);
const id = useId();
const HIDE_NATIVE_CURSOR_STYLE_ID = useMemo(
() => `${id}-cursor-hide-native`,
[id]
);
const HIDE_NATIVE_CURSOR_ATTR = useMemo(
() => `${id}-data-cursor-parent`,
[id]
);
const HIDE_NATIVE_CURSOR_CSS_GLOBAL = useMemo(
() =>
"html, body, html *, html *::before, html *::after { cursor: none !important; }",
[]
);
const HIDE_NATIVE_CURSOR_CSS_SCOPED = useMemo(
() =>
`[${HIDE_NATIVE_CURSOR_ATTR}], [${HIDE_NATIVE_CURSOR_ATTR}] *, [${HIDE_NATIVE_CURSOR_ATTR}] *::before, [${HIDE_NATIVE_CURSOR_ATTR}] *::after { cursor: none !important; }`,
[HIDE_NATIVE_CURSOR_ATTR]
);
const cursorX = useSpring(x, props.transition ?? SPRING);
const cursorY = useSpring(y, props.transition ?? SPRING);
useEffect(() => {
if (!hideBrowserCursor) {
return;
}
const parent = relativeToParent ? rootRef.current?.parentElement : null;
if (relativeToParent && !parent) {
return;
}
const existing = document.getElementById(HIDE_NATIVE_CURSOR_STYLE_ID);
const style = existing ?? document.createElement("style");
style.id = HIDE_NATIVE_CURSOR_STYLE_ID;
style.textContent = parent
? HIDE_NATIVE_CURSOR_CSS_SCOPED
: HIDE_NATIVE_CURSOR_CSS_GLOBAL;
if (!existing) {
document.head.appendChild(style);
}
parent?.setAttribute(HIDE_NATIVE_CURSOR_ATTR, "");
return () => {
parent?.removeAttribute(HIDE_NATIVE_CURSOR_ATTR);
style.remove();
};
}, [
HIDE_NATIVE_CURSOR_ATTR,
HIDE_NATIVE_CURSOR_CSS_GLOBAL,
HIDE_NATIVE_CURSOR_CSS_SCOPED,
HIDE_NATIVE_CURSOR_STYLE_ID,
hideBrowserCursor,
relativeToParent,
]);
useEffect(() => {
if (isControlled) {
cursorX.set(x);
cursorY.set(y);
revealedRef.current = true;
setVisible(true);
return;
}
const parent = relativeToParent ? rootRef.current?.parentElement : null;
if (relativeToParent && !parent) {
throw new Error("[Cursor Component] Parent element not found");
}
const showAt = (event: MouseEvent, jump: boolean) => {
if (jump || !revealedRef.current) {
cursorX.jump(event.clientX);
cursorY.jump(event.clientY);
revealedRef.current = true;
} else {
cursorX.set(event.clientX);
cursorY.set(event.clientY);
}
setVisible(true);
};
const hide = () => {
setVisible(false);
};
const isMouseLike = (event: Event): event is MouseEvent => {
if (event instanceof PointerEvent) {
return event.pointerType === "mouse" || event.pointerType === "pen";
}
return event instanceof MouseEvent;
};
const onMove = (event: Event) => {
if (!isMouseLike(event)) {
return;
}
showAt(event, false);
};
const onEnter = (event: Event) => {
if (!isMouseLike(event)) {
return;
}
showAt(event, true);
};
// A native drag (images, links) replaces pointer events with drag events,
// so the cursor would freeze until release. Cancel it unless the page
// opted into HTML drag-and-drop or the user is dragging text in a field.
const onDragStart = (event: DragEvent) => {
const { target } = event;
if (!(target instanceof Element)) {
return;
}
const keepsNativeDrag = target.closest(
"input, textarea, [contenteditable='true'], [draggable='true']"
);
if (keepsNativeDrag) {
return;
}
event.preventDefault();
};
const target: HTMLElement | Window = parent ?? window;
if (parent) {
parent.addEventListener("pointerenter", onEnter);
parent.addEventListener("pointerleave", hide);
}
target.addEventListener("pointermove", onMove);
target.addEventListener("dragstart", onDragStart as EventListener);
return () => {
if (parent) {
parent.removeEventListener("pointerenter", onEnter);
parent.removeEventListener("pointerleave", hide);
}
target.removeEventListener("pointermove", onMove);
target.removeEventListener("dragstart", onDragStart as EventListener);
};
}, [isControlled, x, y, cursorX, cursorY, relativeToParent]);
return (
<motion.div
animate="shown"
className="pointer-events-none fixed top-0 left-0 z-9999 size-0 overflow-visible"
custom={{ reduceMotion, visible }}
initial="hidden"
ref={rootRef}
style={{ x: cursorX, y: cursorY }}
variants={cursorPresence}
>
<svg
aria-hidden
className={cn(
"size-5 -translate-x-1 -translate-y-1 -rotate-12",
props.className
)}
fill="currentColor"
overflow="visible"
viewBox="0 0 24 24"
>
<path
d="M4.86 1.3C4.4 1.45 4.1 1.64 3.77 2 3.42 2.36 3.21 2.79 3.13 3.28 3.09 3.55 3.16 5.36 3.47 11.8 3.68 16.29 3.89 20.22 3.92 20.51 4.01 21.44 4.37 22 5.11 22.37 5.98 22.8 6.96 22.64 7.62 21.97 7.73 21.85 8.46 20.9 9.25 19.86 10.03 18.82 10.82 17.81 11 17.63 11.38 17.24 11.98 16.84 12.47 16.63 13.22 16.33 13.46 16.32 16.35 16.32 19.17 16.32 19.3 16.3 19.8 16.05 20.15 15.87 20.52 15.47 20.73 15.04 20.9 14.7 20.91 14.63 20.91 14.17 20.91 13.56 20.77 13.16 20.43 12.77 20.25 12.57 9.51 3.65 7.35 1.91 6.72 1.41 6.32 1.24 5.68 1.21 5.3 1.2 5.11 1.22 4.86 1.3Z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{props.children}
</motion.div>
);
};
export { Cursor, CursorName };You can place it anywhere in the DOM.
<Cursor className="fill-red-500" hideBrowserCursor />The cursor will be relative to its parent element.
<Markee className="w-full md:w-3/4">
<Cursor className="fill-accent" hideBrowserCursor relativeToParent />
<MarkeeFade className="from-background" position="left" />
<MarkeeContent>
{BADGES.map((badge) => (
<Fragment key={badge.key}>
<MarkeeItem>{badge}</MarkeeItem>
<MarkeeSpacer className="w-2 md:w-4" />
</Fragment>
))}
</MarkeeContent>
<MarkeeFade className="from-background" position="right" />
</Markee><Cursor className="fill-accent" hideBrowserCursor>
<CursorName className="bg-accent text-background">
{chipText}
</CursorName>
</Cursor>You can customize the animation options of the cursor.
<Cursor
animationOptions={{
bounce: 0,
damping: 10,
duration: 0.3,
mass: 0.5,
stiffness: 100,
}}
/>You can pass the controlled prop, to make the cursor controlled by external x/y values, for example in a live page with multiple cursors.
<Cursor
controlled
x={otherUserCursor.x}
y={otherUserCursor.y}
/>You can also mix and duplicate the cursors to create a nice interaction.
Racing on a speedway demands precision and nerve, pushing every corner to the absolute limit.
<body>
<Cursor className="fill-green-500" hideBrowserCursor />
<div className="not-prose relative w-max">
<SplayCard />
<Cursor
transition={{
bounce: 0,
damping: 10,
duration: 0.3,
mass: 0.5,
stiffness: 100,
}}
className="fill-[#8b5cf6]"
layoutId="startupper-cursor"
relativeToParent
/>
<Cursor
transition={{
bounce: 0,
damping: 10,
duration: 0.15,
mass: 0.1,
stiffness: 200,
}}
className="fill-[#06b6d4]"
relativeToParent
>
<CursorName className="bg-[#06b6d4]">Startupper</CursorName>
</Cursor>
</div>
</body>Follows the pointer, or tracks external coordinates when controlled is set. className is applied to the cursor SVG.
| Prop | Type | Default | Description |
|---|---|---|---|
transition | Transition | { damping: 10, mass: 0.1, stiffness: 150, type: "spring" } | Spring used to follow the pointer or the controlled coordinates. |
children | ReactNode | — | Content rendered beside the cursor, such as CursorName. |
className | string | — | Classes applied to the cursor SVG. Use fill-* to set the cursor color. |
controlled | true | — | Drives the cursor from x and y instead of the pointer. Requires both. |
x | number | — | Horizontal position in pixels. Required when controlled is set. |
y | number | — | Vertical position in pixels. Required when controlled is set. |
hideBrowserCursor | boolean | false | Hides the native cursor. Available only when controlled is omitted. |
relativeToParent | boolean | false | Tracks the pointer only while it is inside the parent element. Available only when controlled is omitted. |
Label chip rendered as a child of Cursor. Accepts all standard span props.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Label shown beside the cursor. |
className | string | — | Additional classes merged onto the default chip styles. |