L2

·

Pointer · state

Custom cursor system

Most custom cursors are a coloured circle that lags the pointer, and they make a site feel worse than the native arrow. Three decisions turn it into something worth shipping. Different speeds per layer — a near-instant dot, a ring at 0.55s, an image preview at 0.85s — because a single lagging blob reads as latency while a spread reads as weight. A state machine keyed on an attribute, so adding a new hover behaviour is one attribute in the Designer, not a new listener. And mix-blend-mode: difference, which means you never write a single line of code to handle light sections.

Open the live demo →

How to build

Script only

Free plan gets you part of the way

The panel covers some of this. The rest needs the script, which needs a paid plan — build the panel half now, come back for the rest.

How the two routes work →

Plugins

quickTo · gsap core

Build time

1 hour for all four states.

No-code?

No. Webflow's Mouse Move interactions can move an element with the pointer, but there is no easing model, no per-layer speed, and no way to express states — you would build four separate interactions that fight each other.

01

Webflow structure

element · class · attribute

<b>Div</b> <i>cursor_component</i> ← fixed, top 0 left 0, z 400, pointer-events none <b>Div</b> <i>cursor_ring</i> ← 44px circle, margin -22px, grid centre <b>Text</b> <i>cursor_label</i> ← opacity 0 <b>Div</b> <i>cursor_dot</i> ← 6px dot, margin -3px <b>Div</b> <i>cursor_preview</i> ← fixed, z 399, 260×320, opacity 0 <b>Image</b> ← src swapped per hover <i>then, anywhere on the page:</i> <b>Link</b> <u>data-cursor="link"</u> <b>Div</b> <i>work_tile</i> <u>data-cursor="view" data-cursor-label="View series"</u> <b>Div</b> <i>archive_row</i> <u>data-cursor="media" data-cursor-media="/img/x.jpg"</u>

02

Styles to set in the Designer

cursor_component | <code>position: fixed; top: 0; left: 0; z-index: 400; pointer-events: none; opacity: 0; mix-blend-mode: difference</code>. Top-left plus a negative margin, never <code>transform: translate(-50%,-50%)</code> — GSAP owns the transform. cursor_ring / cursor_dot | Absolutely positioned at 0,0 with <code>margin: -half</code> to centre them. That is how you centre something whose transform is already spoken for. body (or the section) | <code>cursor: none</code> — but add it with a JS-applied class, so a visitor whose script fails still has a pointer. cursor_preview | <code>overflow: hidden</code>, fixed size, <code>opacity: 0</code>, <code>transform: scale(0.92)</code> as the resting state.

03

Building it with the Interactions panel

no code, free plan

What the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.

See “The script” below for the exact code.

04

Custom attributes

Element settings → Custom attributes

Links, buttons | data-cursor | link | Ring grows to ~1.9×, dot vanishes. The general-purpose interactive state. Project tiles | data-cursor | view | Ring grows to 2.35× and a label fades in inside it. Tiles | data-cursor-label | View series | Whatever the ring should say. Per-element, no code change. List rows | data-cursor | media | Ring shrinks to a dot and the preview panel fades in — the Osmo image-preview follower. List rows | data-cursor-media | /img/x.jpg | The image the preview loads on hover.

05

CSS

Page settings → Inside <head> tag

Inside <head> tag

Copy

06

The script

Page settings → Before </body> tag

Before </body> tag

Copy

<!-- Page settings → Before </body> tag --> <script> document.addEventListener("DOMContentLoaded", () => { // A custom cursor on a touch device is a bug. On a hybrid laptop it is worse. if (!matchMedia("(hover: hover) and (pointer: fine)").matches) return; if (matchMedia("(prefers-reduced-motion: reduce)").matches) return; const cursor = document.querySelector(".cursor_component"); const ring = cursor.querySelector(".cursor_ring"); const dot = cursor.querySelector(".cursor_dot"); const label = cursor.querySelector(".cursor_label"); const preview = document.querySelector(".cursor_preview"); const pvImg = preview.querySelector("img"); document.documentElement.classList.add("has-cursor"); // this is what sets cursor:none // THREE speeds. This is the whole reason it feels good. const dotX = gsap.quickTo(dot, "x", { duration: 0.12, ease: "power3" }); const dotY = gsap.quickTo(dot, "y", { duration: 0.12, ease: "power3" }); const ringX = gsap.quickTo(ring, "x", { duration: 0.55, ease: "power3" }); const ringY = gsap.quickTo(ring, "y", { duration: 0.55, ease: "power3" }); const pvX = gsap.quickTo(preview, "x", { duration: 0.85, ease: "power3" }); const pvY = gsap.quickTo(preview, "y", { duration: 0.85, ease: "power3" }); gsap.set(preview, { scale: 0.92 }); addEventListener("mousemove", e => { dotX(e.clientX); dotY(e.clientY); ringX(e.clientX); ringY(e.clientY); pvX(e.clientX); pvY(e.clientY); }); // Reveal on the first move, NOT on mouseenter. If the pointer is already // over the page when the script runs — a reload, a mid-page landing — // mouseenter never fires and the cursor stays invisible. let shown = false; document.addEventListener("mousemove", () => { if (shown) return; shown = true; gsap.to(cursor, { opacity: 1, duration: 0.25 }); }); document.addEventListener("mouseleave", () => { shown = false; gsap.to([cursor, preview], { opacity: 0, duration: 0.25 }); }); // ── the state machine ─────────────────────────────────────────────────── const STATES = { default: () => { gsap.to(ring, { scale: 1, duration: 0.4, ease: "power3.out" }); gsap.to(dot, { scale: 1, opacity: 1, duration: 0.3 }); gsap.to(label, { opacity: 0, duration: 0.2 }); gsap.to(preview, { opacity: 0, scale: 0.92, duration: 0.35, ease: "power3.out" }); }, link: () => { gsap.to(ring, { scale: 1.9, duration: 0.45, ease: "power3.out" }); gsap.to(dot, { scale: 0, opacity: 0, duration: 0.3 }); gsap.to(label, { opacity: 0, duration: 0.2 }); }, view: () => { gsap.to(ring, { scale: 2.35, duration: 0.5, ease: "power3.out" }); gsap.to(dot, { scale: 0, opacity: 0, duration: 0.3 }); gsap.to(label, { opacity: 1, duration: 0.3, delay: 0.05 }); }, media: () => { gsap.to(ring, { scale: 0.35, duration: 0.45, ease: "power3.out" }); gsap.to(dot, { scale: 0, opacity: 0, duration: 0.3 }); gsap.to(label, { opacity: 0, duration: 0.2 }); gsap.to(preview, { opacity: 1, scale: 1, duration: 0.5, ease: "expo.out" }); } }; // Adding a fifth state later is: write it here, type the attribute in the // Designer. No new listeners, no new elements. document.querySelectorAll("[data-cursor]").forEach(el => { const state = el.dataset.cursor || "default"; el.addEventListener("mouseenter", () => { if (state === "media" && el.dataset.cursorMedia) pvImg.src = el.dataset.cursorMedia; if (state === "view") label.textContent = el.dataset.cursorLabel || "View"; (STATES[state] || STATES.default)(); }); el.addEventListener("mouseleave", () => STATES.default()); }); }); </script>

07

Gotchas that will cost you an hour

  1. Never centre with transform: translate(-50%, -50%). GSAP writes x/y into the same transform property and will wipe your centring the first time the mouse moves. Use top: 0; left: 0 plus a negative margin.
  2. Apply cursor: none from JavaScript, not from the stylesheet. If the script fails to load and CSS already hid the pointer, the visitor has no cursor at all on your client's site.
  3. Gate on (hover: hover) and (pointer: fine). Not on viewport width. A touchscreen laptop is wide and touch — width alone gives it a cursor stuck in the corner.
  4. Reveal on mousemove, never on mouseenter. If the pointer is already inside the window when the script initialises — a reload, a back-navigation, a landing mid-page — mouseenter has already happened and will not fire again. The cursor stays invisible until the visitor leaves the window and comes back, and it looks like the feature is broken.
  5. pointer-events: none on every cursor layer. Forget it on the preview panel and it swallows the clicks on whatever it is floating over — a bug that only appears once real content is behind it.
  6. mix-blend-mode: difference is fragile around backdrop-filter. A blurred sticky nav creates its own stacking context; the cursor will pass behind it. Either raise the cursor's z-index above the nav or accept it.
  7. Preload the preview images. Setting img.src on hover means the first hover of each row shows an empty box. Render them all in a hidden container, or set loading="eager".
08

Push it further

  • Add a drag state with left/right arrows and wire it to the slider from the inertia slider — that pairing is most of what people mean by “Awwwards feel”.
  • Feed pointer velocity into a skewX and a slight scaleX on the ring so it stretches when flicked. Clamp it hard, around 12 degrees.
  • Swap the preview img for a muted looping video — same state, same attribute, and it immediately reads as a much more expensive site.

Webflow Motion Lab — GSAP 3.15, all plugins free.

20

/ 30