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



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 pointer — expandX = 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 setbutton { cursor: default }anda { cursor: pointer }, and a declared value beats an inherited one. Measured:defaulton the button,pointeron 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 → #8b5cf6gradient = 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#6366f1on#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-activeset even after a tap outside the zone. Gate the initialization onmatchMedia('(hover: hover) and (pointer: fine)').matches. Button and link remain usable by finger. - Keyboard:
Tabdoes reach the button and the link (native elements,outlineintact), but the ring never reacts to focus — it only listens tomousemove. Give the targets a:focus-visiblestate that echoes the idea (for instanceoutline: 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-expandand set the factor to 1 (glued disc, instant state change). Thedivis empty: addaria-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.
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):
<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>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
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
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 }.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.</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.