Cursors✨ Premium

Cursor Expand

A fixed 22 px disc that follows the pointer at 15% of the gap per frame and opens into a translucent ring depending on the targeted element — 64 px over a link, 96 px over a button — in 250 ms. Its position is initialized on entering the zone, and the targeted state is read from the target's data-expand attribute.

JSInteractive

You'll get access to the interactive demo with a free account.

This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Cursors has 26 effects, including 1 free. Explore the category →

3 usage examples

Pricing card — monthly/yearly toggle and buy button — Cursor Expand example 1

① Pricing card — monthly/yearly toggle and buy button

WhenSaaS plan card: two billing toggle links, a price, a short list, an action button.
WhyThe ring tells two kinds of target apart at a glance: 64 px on the toggle (a setting), 96 px on the button (the commitment). The cursor sets a hierarchy without adding a word.
SettingsButton in #4f46e5 → #7c3aed (6.3:1), links #a5b4fc (8.7:1), .cursor-active a, .cursor-active button { cursor: none } for a single cursor.
End of article — post navigation and subscribe — Cursor Expand example 2

② End of article — post navigation and subscribe

WhenFooter of a blog post: links to the previous and next articles, a newsletter sign-up button.
WhyOnce reading is done, the cursor guides what comes next: the ring opens on the navigation links and wider on the button, the main editorial action.
SettingsWhite 18 px disc and translucent white rings (rgba(255,255,255,.14), #fafaf9 border) on a dark paper background, shadows removed for an editorial look.
Landing header — menu and call to action — Cursor Expand example 3

③ Landing header — menu and call to action

WhenTop band of a product page: four menu links, a headline, a “Get started” button.
WhyOn a menu, the 64 px ring circles exactly one label and confirms the target before the click; on the button, the 96 px opening flags the action the page is waiting for.
SettingsReduced sizes 18 / 56 / 80 px for compact menu labels, factor 0.25 (catch-up in ~25 frames), 1.5px border.

How it works

The #zone-expand area holds an .expand-btn with data-expand="button" and an .expand-link with data-expand="link". The cursor is #cursorExpand (.cursor-expand): position: fixed, 22 × 22 px, #6366f1, box-shadow: 0 0 0 4px rgba(99,102,241,.25), border-radius: 50%, pointer-events: none, z-index: 9999, display: none and transform: translate(-50%, -50%) — centered on left/top by the CSS, with no half-size subtraction in the JS. Its transitions: width and height over 250 ms cubic-bezier(.2,.8,.2,1), background, border-color and box-shadow over 250 ms.

mouseenter(e) initializes the position on the pointerexpandX = currentExpandX = e.clientX, same for Y — then adds cursor-active to the zone (the cursor: none !important rule is shipped) and shows the disc. Measured: at frame 0 the disc is already under the pointer (30, 30 for a pointer at 30, 30), no fly-in from the corner as in Dot Follower. mouseleave hides it and resets className = 'cursor-expand', which purges the state. mousemove stores the position, walks up from e.target to the nearest button or a (e.target.closest('button, a'), falling back to e.target itself), reads its data-expand attribute, resets the base class then adds on-button or on-link.

Both states are pure CSS. .on-link: 64 px, background rgba(99,102,241,.12), border: 2px solid #a5b4fc, box-shadow: 0 0 24px rgba(99,102,241,.45). .on-button: 96 px, background rgba(99,102,241,.22), border #c7d2fe, shadow 0 0 32px rgba(99,102,241,.55) (measured values). The solid disc becomes a translucent ring circling the target. Border width is not in the transition list: it jumps from 0 to 2 px, only its color animates.

Movement goes through a permanent requestAnimationFrame loop: currentExpandX += (expandX − currentExpandX) × 0.15, then left/top. Measured at 120 Hz after a 255 px jump: 113 px of gap at frame 5, 50 at frame 10, 10 at frame 20, under 1 px at frame 39 (≈ 0.32 s; twice that in milliseconds at 60 Hz). No CSS transition on left/top, so the smoothing is the loop's alone. The loop also runs outside hover (121 rAF calls/s measured, disc hidden).

Accessibility

  • Two cursors on screen over the targets: the zone gets cursor: none, but browser stylesheets set button { cursor: default } and a { cursor: pointer }, and a declared value beats an inherited one. Measured: default on the button, pointer on the link while the ring is displayed — the system arrow or hand reappears at the ring's center. Add .cursor-active a, .cursor-active button { cursor: none }.
  • Button label contrast: 13.3 px white (a button's default size, none declared) on the #6366f1 → #8b5cf6 gradient = 4.5:1 then 4.2:1 — below 4.5:1 on the violet half. Switch to #4f46e5 → #7c3aed (6.3:1 and 5.7:1). Link #6366f1 on #1a1a24 = 3.9:1: use #a5b4fc (8.7:1) or #818cf8 (5.8:1). The resting ring measures 3.9:1 against the zone, above the 3:1 component threshold.
  • Touch: measured with touch emulation, a tap inside the zone shows the disc frozen and leaves cursor-active set even after a tap outside the zone. Gate the initialization on matchMedia('(hover: hover) and (pointer: fine)').matches. Button and link remain usable by finger.
  • Keyboard: Tab does reach the button and the link (native elements, outline intact), but the ring never reacts to focus — it only listens to mousemove. Give the targets a :focus-visible state that echoes the idea (for instance outline: 2px solid #c7d2fe; outline-offset: 6px) so keyboard users get their equivalent.
  • prefers-reduced-motion not handled: the 15%-per-frame glide and the 250 ms opening are interaction animations. Under @media (prefers-reduced-motion: reduce), remove the transitions on .cursor-expand and set the factor to 1 (glued disc, instant state change). The div is empty: add aria-hidden="true".

Browser compatibility

Requires ES2015 (const/let, arrow functions), classList, Element.closest, getAttribute, requestAnimationFrame, the zone's CSS variables and a CSS transition on width/height/box-shadow. Zero dependencies.

Chrome 49+✓ Full
Firefox 45+✓ Full
Safari 10+✓ Full
Edge 15+✓ Full
Mobile iOS✓ Inactive (disc frozen on tap)
Android Chrome✓ Inactive (disc frozen on tap)

Without JavaScript, the disc stays display: none, the system arrow is kept and both button and link work normally. Without CSS transitions, the ring changes size abruptly when passing over a target.

The code

HTML structure to paste into your page (CSS + JS available with a premium account):

index.html — structure
<div class="cursor-zone" id="zone-expand">
  <div class="expand-zone-content">
    <button class="expand-btn" data-expand="button">Button</button>
    <a href="#" class="expand-link" data-expand="link" onclick="return false;">Clickable link</a>
  </div>
  <span class="info-text">Cursor that grows</span>
</div>
<!-- This div IS the cursor: the script moves it and shows it.
     Without it the script bails out and the effect renders nothing. -->
<div class="cursor-expand" id="cursorExpand"></div>
🔒 Unlock the full code — from €2.99 the first month

Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Smoothing factor (JS — (expandX - currentExpandX) * 0.15) 0.15 Share of the gap closed per frame. 0.3 = nearly glued disc; 0.08 = long trail. Counted in frames, so twice as slow in milliseconds at 60 Hz.
Sizes (CSS — .cursor-expand 22px, .on-link 64px, .on-button 96px) 22 / 64 / 96 px Diameters of the three states. The translate(-50%, -50%) keeps the center in place whatever the size: no offset to adjust in the JS.
Targeted attributes (HTML — data-expand="button|link"; JS — expandType) button, link Add a value (data-expand="drag") and its else if branch setting on-drag, then the matching CSS rule: one state per kind of target.
Target lookup (JS — e.target.closest('button, a')) closest('button, a') || e.target A span or svg inside the button walks up to the button: the on-button state holds over all of its content. The selector only knows button and a — to type a div or label carrying data-expand, widen it to closest('[data-expand]').
Transitions (CSS — .cursor-expand transition) 250 ms, cubic-bezier(.2,.8,.2,1) Duration and curve of the opening. Add border-width .25s to also animate the border's appearance, which currently jumps from 0 to 2 px.
State colors (CSS — .on-link / .on-button background, border, box-shadow) translucent indigo, #a5b4fc / #c7d2fe borders Ring styling. mix-blend-mode: difference with a white disc inverts whatever it hovers and stays readable on any background.
Initial position (JS — mouseenter) e.clientX / e.clientY Already in place: the disc appears under the pointer. Don't delete those two lines, or it will start from (0, 0) on every first entry.

FAQ

Because cursor is an inherited property and browsers themselves declare cursor: default on button and cursor: pointer on a: a value declared on the element beats the none inherited from the zone, even with !important on the zone. Measured: default on the button, pointer on the link. Fix it with .cursor-active a, .cursor-active button { cursor: none } or, more broadly, .cursor-active * { cursor: none !important }.
Yes for a button or an a: e.target is the deepest element under the pointer (a span, an svg), but the code walks up with closest('button, a') before reading data-expand — the state set on the button holds over all of its content. However, closest only looks for those two tags: over a div or label carrying data-expand, a hovered child yields e.target itself, with no attribute, and the ring falls back to 22 px. Replace the selector with closest('[data-expand]') in that case; the read that follows is unchanged.
Right before </body>, outside any container. position: fixed with left = clientX takes the window as its reference; an ancestor with transform, filter, perspective, will-change: transform or contain: paint becomes the containing block and shifts the disc by that ancestor's position. The shipped code places the div inside the demo box: move it to the root when integrating.