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

OptionTypeDefaultDescription
type'basic' | 'text' | 'media''basic'Cursor variant. 'text' renders a label from an HTML attribute; 'media' renders an image or video
smoothnumberFollow lag. Lower = more delayed (fluid); higher = more responsive. Range 0–1
squeezeboolean | CursorSqueezeConfigfalseStretch the cursor element in the direction of movement
hideNativebooleanfalseHide the OS cursor on the timeline’s target element
defaultCursorStateVarsRequired. CSS properties for the cursor’s idle state
hoverCursorStateVarsCSS properties applied when the cursor enters a hover target
clickCursorStateVarsCSS properties applied while the mouse button is pressed
textRecord<string, string | number>CSS for the inner text node. Only used with type: 'text'
mediaRecord<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.

PropertyTypeDefaultDescription
targetsstring[]CSS selectors whose hover activates the hover state. Only valid on hover
durationnumber0.15Transition duration in seconds when entering this state
easestring'power3.inOut'Easing for the state transition
enabledbooleantrueToggle this state on or off
[css property]string | numberAny 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 directional stretching as the cursor moves. The element elongates in the direction of travel and snaps back when stationary.

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:

PropertyTypeDescription
minnumberMinimum scale factor on the compressed axis
maxnumberMaximum scale factor on the stretched axis
multipliernumberScales the squeeze intensity relative to cursor velocity
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 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.

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.

PresetInstancesDescription
basic2Semi-transparent 30px circle (snappy dot center). The circle grows and darkens on hover; the dot is always at pointer position.
empty1Minimal 5px dot with no hover or click states. Good starting point for fully custom cursors.
outline240px outline ring that expands to 80px on link hover, collapses to a filled dot on click. Paired with a 2px center dot.
multiOutline4Four concentric outline rings (40px, 30px, 20px, 10px) trailing at different speeds, all expanding to 80px on hover.
multiSolid7Seven solid circles of equal size trailing with staggered smooth values. The lead circle uses mix-blend-mode: difference on hover; trailing circles fade out.
outlineCross3Outline circle combined with a vertical line and a horizontal line forming a crosshair. All three scale on hover and react on click.
cross2Pure crosshair: a vertical bar and a horizontal bar with no circle, using squeeze: true.
differenceBlend2Two circles (40px and 10px) both styled white with mix-blend-mode: difference, creating a color-inversion effect over page content.
blurred2Frosted glass circle using backdrop-filter: blur(4px) paired with a solid black dot. The blurred circle expands on hover.
invert1Single circle using backdrop-filter: invert(100%) to invert page colors inside the cursor boundary. Scales up on hover.
radialGradient1Full-viewport element with a radial-gradient background that follows the mouse, creating a spotlight glow effect. Shrinks on link hover.
tooltip1Zero-size cursor that reads from mp-cursor-tooltip attributes and renders a floating label — no visible cursor element otherwise.
text1Invisible-by-default cursor that reveals a styled floating label on hover targets, reading text from mp-cursor-text or mp-cursor-tooltip.
media1Cursor 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:

ValueFeel
1.0Instant — snaps directly to pointer
0.4–0.8Responsive with slight lag
0.1–0.3Fluid, noticeable trailing effect
0.05–0.1Very 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.