Custom Cursor
Create custom cursor effects with presets, multiple instances, and state-based styling.
Chain .onCursor() to any timeline to replace the native OS cursor with a fully animated custom cursor element. The cursor follows mouse movement, transitions between states on hover and click, and supports multiple stacked instances.
Basic Usage
import { Motion } from "@motion.page/sdk";
Motion("cursor", "body", { duration: 0 }).onCursor({
smooth: 0.1,
hideNative: true,
default: {
width: 12,
height: 12,
borderRadius: "50%",
backgroundColor: "#fff",
},
hover: {
targets: ["a", "button", "[data-hover]"],
width: 40,
height: 40,
backgroundColor: "transparent",
border: "2px solid white",
duration: 0.2,
},
click: { scale: 0.8, duration: 0.1 },
}); The timeline target ("body") determines which element hides the native cursor when hideNative: true. The animation config ({ duration: 0 }) is a required placeholder — the cursor appearance is driven entirely by the .onCursor() config.
Options
| Option | Type | Default | Description |
|---|---|---|---|
type | 'basic' | 'text' | 'media' | 'basic' | Cursor variant. 'text' renders a label from an HTML attribute; 'media' renders an image or video |
smooth | number | — | Follow lag. Lower = more delayed (fluid); higher = more responsive. Range 0–1 |
squeeze | boolean | CursorSqueezeConfig | false | Stretch the cursor element in the direction of movement |
hideNative | boolean | false | Hide the OS cursor on the timeline’s target element |
default | CursorStateVars | — | Required. CSS properties for the cursor’s idle state |
hover | CursorStateVars | — | CSS properties applied when the cursor enters a hover target |
click | CursorStateVars | — | CSS properties applied while the mouse button is pressed |
text | Record<string, string | number> | — | CSS for the inner text node. Only used with type: 'text' |
media | Record<string, string | number> | — | CSS for the inner <img>/<video> node. Only used with type: 'media' |
State Configuration
Each state (default, hover, click) accepts a CursorStateVars object. This is a flat mix of layout options and any CSS properties you want applied.
| Property | Type | Default | Description |
|---|---|---|---|
targets | string[] | — | CSS selectors whose hover activates the hover state. Only valid on hover |
duration | number | 0.15 | Transition duration in seconds when entering this state |
ease | string | 'power3.inOut' | Easing for the state transition |
enabled | boolean | true | Toggle this state on or off |
[css property] | string | number | — | Any CSS property using camelCase (e.g. backgroundColor, borderRadius, width) |
CSS values follow the same camelCase convention as inline styles. Numbers without units default to px for dimension properties.
Motion("cursor", "body", { duration: 0 }).onCursor({
smooth: 0.08,
hideNative: true,
default: {
width: 14,
height: 14,
borderRadius: "50%",
backgroundColor: "rgba(255,255,255,0.9)",
},
hover: {
targets: ["a", "button", ".card"],
width: 48,
height: 48,
backgroundColor: "transparent",
border: "2px solid rgba(255,255,255,0.9)",
duration: 0.25,
ease: "power2.out",
},
click: {
scale: 0.75,
duration: 0.08,
ease: "power1.in",
},
}); Squeeze Effect
Set squeeze: true to enable directional stretching as the cursor moves. The element elongates in the direction of travel and snaps back when stationary.
Motion("cursor", "body", { duration: 0 }).onCursor({
smooth: 0.12,
hideNative: true,
squeeze: true,
default: { width: 16, height: 16, borderRadius: "50%", backgroundColor: "#6633EE" },
hover: { targets: ["a"], scale: 2, duration: 0.2 },
click: { scale: 0.8, duration: 0.1 },
}); Use CursorSqueezeConfig for precise control:
| Property | Type | Description |
|---|---|---|
min | number | Minimum scale factor on the compressed axis |
max | number | Maximum scale factor on the stretched axis |
multiplier | number | Scales the squeeze intensity relative to cursor velocity |
Motion("cursor", "body", { duration: 0 }).onCursor({
smooth: 0.1,
hideNative: true,
squeeze: { min: 0.7, max: 1.4, multiplier: 1.5 },
default: { width: 12, height: 12, borderRadius: "50%", backgroundColor: "#ff0066" },
}); Cursor Types
type: 'text' — Label Cursor
The text type adds an inner text node to the cursor. The text content is read from the mp-cursor-text or mp-cursor-tooltip HTML attribute on elements as the cursor hovers over them.
Motion("cursor-text", "body", { duration: 0 }).onCursor({
type: "text",
smooth: 0.08,
hideNative: true,
default: { width: 10, height: 10, borderRadius: "50%", backgroundColor: "#fff" },
hover: {
width: 80,
height: 80,
backgroundColor: "rgba(255,255,255,0.1)",
duration: 0.3,
},
text: {
fontSize: "11px",
fontWeight: "600",
color: "#fff",
letterSpacing: "0.05em",
},
}); Add the attribute to any element you want to trigger the label:
<a href="/work" mp-cursor-text="View">Our Work</a>
<button mp-cursor-tooltip="Submit">Send</button> Both mp-cursor-text and mp-cursor-tooltip are supported. The cursor transitions to the hover state as it enters the element and renders the label text.
type: 'media' — Image/Video Cursor
The media type adds an inner <img> or <video> element to the cursor. The media source is read from the mp-cursor-media attribute on hovered elements.
Motion("cursor-media", "body", { duration: 0 }).onCursor({
type: "media",
smooth: 0.12,
hideNative: true,
default: { width: 14, height: 14, borderRadius: "50%", backgroundColor: "#fff" },
hover: { width: 100, height: 70, borderRadius: "8px", duration: 0.3 },
media: { objectFit: "cover", borderRadius: "8px" },
}); <div class="project" mp-cursor-media="/img/project-1.jpg">Project One</div>
<div class="project" mp-cursor-media="https://cdn.example.com/clip.mp4">Project Two</div> Supported sources: relative paths, http://, https:// URLs. .mp4 and .webm are rendered as looping muted videos; all other URLs render as images. file:// and data: URLs are blocked.
Multiple Instances
Create multiple cursor elements by calling .onCursor() on separate timelines. Each instance is an independent DOM element with its own smooth value and state config. Use different smooth values to create a trailing effect.
import { Motion } from "@motion.page/sdk";
// Slow-following outer ring
Motion("cursor-ring", "body", { duration: 0 }).onCursor({
smooth: 0.4,
default: {
width: 40,
height: 40,
border: "1px solid black",
borderRadius: "50%",
backgroundColor: "transparent",
zIndex: 999998,
},
hover: {
targets: ["a", "button"],
width: 80,
height: 80,
duration: 0.3,
},
click: {
width: 10,
height: 10,
backgroundColor: "black",
duration: 0.15,
},
});
// Snappy center dot
Motion("cursor-dot", "body", { duration: 0 }).onCursor({
smooth: 0.42,
hideNative: true,
default: {
width: 2,
height: 2,
borderRadius: "50%",
backgroundColor: "black",
zIndex: 999999,
},
}); Set hideNative: true on only one instance — whichever instance represents the primary cursor is enough to hide the OS cursor. Set it on multiple instances and the effect is the same, but it’s redundant.
Use zIndex in each instance’s default state to control the stacking order of overlapping cursor elements.
Presets Reference
The following presets are available in the Builder UI. Each represents a pre-configured cursor setup you can use as a starting point.
| Preset | Instances | Description |
|---|---|---|
basic | 2 | Semi-transparent 30px circle (snappy dot center). The circle grows and darkens on hover; the dot is always at pointer position. |
empty | 1 | Minimal 5px dot with no hover or click states. Good starting point for fully custom cursors. |
outline | 2 | 40px outline ring that expands to 80px on link hover, collapses to a filled dot on click. Paired with a 2px center dot. |
multiOutline | 4 | Four concentric outline rings (40px, 30px, 20px, 10px) trailing at different speeds, all expanding to 80px on hover. |
multiSolid | 7 | Seven solid circles of equal size trailing with staggered smooth values. The lead circle uses mix-blend-mode: difference on hover; trailing circles fade out. |
outlineCross | 3 | Outline circle combined with a vertical line and a horizontal line forming a crosshair. All three scale on hover and react on click. |
cross | 2 | Pure crosshair: a vertical bar and a horizontal bar with no circle, using squeeze: true. |
differenceBlend | 2 | Two circles (40px and 10px) both styled white with mix-blend-mode: difference, creating a color-inversion effect over page content. |
blurred | 2 | Frosted glass circle using backdrop-filter: blur(4px) paired with a solid black dot. The blurred circle expands on hover. |
invert | 1 | Single circle using backdrop-filter: invert(100%) to invert page colors inside the cursor boundary. Scales up on hover. |
radialGradient | 1 | Full-viewport element with a radial-gradient background that follows the mouse, creating a spotlight glow effect. Shrinks on link hover. |
tooltip | 1 | Zero-size cursor that reads from mp-cursor-tooltip attributes and renders a floating label — no visible cursor element otherwise. |
text | 1 | Invisible-by-default cursor that reveals a styled floating label on hover targets, reading text from mp-cursor-text or mp-cursor-tooltip. |
media | 1 | Cursor that shows a circular image or video preview from mp-cursor-media attributes when hovering designated elements. |
Difference Blend Example
The differenceBlend style is a popular effect where white cursor elements invert page colors on contact, making the cursor visible on any background.
import { Motion } from "@motion.page/sdk";
// Large outer circle — slow follow
Motion("cursor-blend-outer", "body", { duration: 0 }).onCursor({
smooth: 0.4,
squeeze: true,
default: {
width: 40,
height: 40,
borderRadius: "50%",
backgroundColor: "white",
mixBlendMode: "difference",
zIndex: 999998,
},
hover: {
targets: ["a", "img"],
transform: "scale(2)",
duration: 0.6,
},
click: { transform: "scale(1.5)", duration: 0.1 },
});
// Small center dot — snappier follow
Motion("cursor-blend-dot", "body", { duration: 0 }).onCursor({
smooth: 0.42,
hideNative: true,
squeeze: true,
default: {
width: 10,
height: 10,
borderRadius: "50%",
backgroundColor: "white",
mixBlendMode: "difference",
zIndex: 999999,
},
hover: {
targets: ["a", "img"],
transform: "scale(0.25)",
duration: 0.6,
},
click: { transform: "scale(0.5)", duration: 0.1 },
}); Radial Gradient Spotlight
Use a full-viewport cursor element to create a spotlight that follows the mouse without replacing the visible cursor.
Motion("cursor-spotlight", "body", { duration: 0 }).onCursor({
smooth: 0.25,
default: {
width: "100vw",
height: "100vh",
opacity: 0.15,
background: "radial-gradient(circle at center, orange 0%, transparent 40%)",
transform: "scale(1)",
},
hover: {
targets: ["a"],
transform: "scale(0.5)",
duration: 0.5,
},
}); The cursor element covers the viewport and moves so that center aligns with the pointer. The smooth value controls how smoothly the gradient follows the mouse.
Tips
Always set duration: 0 on the animation config — the cursor appearance is driven by .onCursor() states, not by the timeline animation.
One hideNative is enough. With multiple instances, only one call needs hideNative: true. Typically set it on the instance with the highest zIndex.
Use zIndex in default state to control stacking when using multiple instances. Common values: 999999 for the front dot, 999998 for a trailing ring.
smooth range guide:
| Value | Feel |
|---|---|
1.0 | Instant — snaps directly to pointer |
0.4–0.8 | Responsive with slight lag |
0.1–0.3 | Fluid, noticeable trailing effect |
0.05–0.1 | Very smooth, significant delay |
Cursor is not visible on mobile — .onCursor() only activates on devices with a pointer (mouse/trackpad). It has no effect on touch-only devices.
targets on hover state only — the targets array is only valid inside the hover state. It is ignored in default and click.