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

typescript
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 Scale the cursor down based on follow distance/velocity
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.

typescript
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 velocity-based compression as the cursor moves. A cursor that is lagging farther behind the pointer scales down, then returns toward its resting scale as it catches up.

typescript
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 overall scale factor
max number Maximum overall scale factor
multiplier number Distance sensitivity. Lower values compress more aggressively
typescript
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.

typescript
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:

html
<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.

typescript
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" },
});
html
<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 pathnames are rendered as looping muted videos (including signed URLs with query strings); 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.

typescript
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.

difference-blend.ts
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.

typescript
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 touch-only devices.onCursor() only activates when (any-pointer: fine) matches (mouse/trackpad). It creates no cursor DOM, ticker work, or native-cursor CSS on touch-only devices; hybrid devices still activate when a fine pointer is available.

targets on hover state only — the targets array is only valid inside the hover state. It is ignored in default and click.