Elastic Cursor
A 24 px disc glued to the pointer, stretched by a damped spring according to speed — up to scaleX 2.2, oriented by atan2 — and a 16 px trail following it at 15% per frame; when you stop, the disc dips to 0.91 and settles within 130 ms. The zone hides the native pointer (cursor: none).
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 .elastic-zone (100% × 300px, radial gradient #1a1a3e → #0f0f25, position: relative, cursor: none) holds two absolute, round children with pointer-events: none and transform: translate(−50%, −50%): .elastic-cursor-el, the 24 px disc in a #a78bfa → #6366f1 gradient (z-index: 2), and .elastic-cursor-trail, the 16 px trail in rgba(139,92,246,.3) (z-index: 1). Three listeners on the zone: mouseenter raises a flag and starts the requestAnimationFrame loop; mouseleave lowers it — the loop stops on the next frame — and sets both elements to opacity: 0; mousemove records the pointer position relative to the zone and sets opacity: 1 back.
On each frame the loop does four things. The trail catches up with the pointer by interpolation: l += .15 × (r − l), i.e. 15% of the remaining gap per frame — 95% of the way in about twenty frames, roughly 0.3 s (measured: within 1 px after 284 ms). The disc itself is placed exactly under the pointer. Speed is the pointer's displacement since the previous frame, in px per frame; it sets a stretch target g = 1 + min(.04 × speed, 1.2): 1 at rest, 2.2 from 30 px per frame. The angle q = atan2(dy, dx) is only updated when speed exceeds 0.5 px per frame: at rest, the disc keeps its last direction, and that is the axis along which it bounces.
The spring — added on September 5, 2026 to honor the effect's name — ties the current stretch v to its target: w += (g − v) × .2 (stiffness), w *= .6 (damping), v += w. The disc receives rotate(q) scaleX(v) scaleY(1 / (.5 + .5 × v)): at v = 2.2, the height drops to 0.625 and the disc becomes a drop elongated along the motion. Measured in Chromium on a fast horizontal sweep: scaleX 2.28 at its peak (the spring overshoots the target), scaleY 0.61, rotation 0°; vertical sweep, rotation 90°.
When you stop, the target falls back to 1 and the spring shoots through it: measured, scaleX 2.08 → 1.62 → 1.22 → 1.00 at 58 ms, dip to 0.91 at 83 ms, 0.95 → 0.98 → 1.00 at 133 ms, then stable within ±0.02 — the promised bounce, in a little over a tenth of a second. Both discs are at opacity: 0 in the shipped CSS: before the first hover, nothing is visible — without that initial state, they showed up in the zone's top-left corner, at −4/−4 px and 0/0 px, opacity 1 (measured). One limit of the code: although will-change: transform is declared, left/top are rewritten on every frame, which triggers layout — the transform alone would have sufficed.
Accessibility
- prefers-reduced-motion not handled: stretch, bounce and trail play regardless of the preference. Don't attach the listeners when
matchMedia('(prefers-reduced-motion: reduce)').matches— and in that case also removecursor: nonefrom the zone, otherwise the user loses their pointer with nothing in its place. cursor: noneremoves the native pointer across the whole zone: the disc is the only position cue, and it only exists during hover, after the firstmousemove. Without JavaScript, or before it runs, the zone is a hole with no pointer — applycursor: noneonly under a class set by the script (.js .elastic-zone). Interactive elements placed inside the zone remain clickable (the discs arepointer-events: none) but are aimed at with a 24 px disc, not an arrow.- Contrast: the
#a78bfa → #6366f1disc measures 6.1:1 and 4.2:1 on the zone's gradient, above the 3:1 threshold for a graphical indicator — it stays findable. Thergba(139,92,246,.3)trail sits at 1.5:1, purely decorative. The shipped hint text,rgba(255,255,255,.15), is at 1.6:1: raise it to.5(5:1) if it must be read. - Touch: no
mousemove, hence no disc, andcursor: nonechanges nothing. But some mobile browsers emit amouseenteron tap without ever emitting amouseleave: therequestAnimationFrameloop starts and never stops. Gate the initialization onmatchMedia('(hover: hover) and (pointer: fine)'). Screen readers: two emptydivs, silent; setaria-hidden="true"on the zone. - Integration: the zone must stay
position: relativewith an explicit height (300pxin the sold code; a percentage without a sized parent renders 0 px) and directly contain the twodivs — the script looks them up withquerySelectorinside the zone and does nothing if one is missing. The reset on leave is done (opacity 0, loop stopped, native pointer restored outside the zone); the initial state is covered by theopacity: 0of both elements in the CSS.
Browser compatibility
ES5 code (var, function): it requires requestAnimationFrame, getBoundingClientRect, 2D transforms and linear-gradient. cursor: none has been recognized everywhere since 2012. Zero dependencies.
Without JavaScript, the zone hides the native pointer (cursor: none) and the two discs stay invisible (opacity: 0): the user has no cursor inside the zone. Reserve cursor: none for a class added by the script. Without requestAnimationFrame (IE 9), the loop never starts and the same hole appears.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="elastic-zone" data-elastic-cursor>
<div class="elastic-cursor-el"></div>
<div class="elastic-cursor-trail"></div>
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;color:rgba(255,255,255,0.15);font-size:0.8rem;">Move the cursor to see the elasticity</div>
</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 |
|---|---|---|
Trail follow (JS — l += .15 × (r − l)) |
.15 per frame | Fraction of the gap closed on each frame. .05 = distant, slow trail (95% of the way in 1 s); .4 = almost glued to the disc. Applies to both x and y. |
Speed sensitivity (JS — .04 × speed, cap 1.2) |
+0.04 per px/frame, max +1.2 (scaleX 2.2) | The coefficient sets how fast the drop elongates (.08: twice as early), the cap the maximum stretch. min(.02 × v, .6) gives a disc that barely deforms. |
Spring (JS — w += (g − v) × .2; w *= .6) |
stiffness .2, damping .6 | Stiffness = speed of convergence toward the target; damping = fraction of the velocity kept on each frame. .3 / .5: deeper, shorter bounce; .1 / .8: slow, visible oscillations; .2 / 0: no bounce at all. |
Rotation threshold (JS — t > .5) |
.5 px/frame | Below it, the angle isn't updated: the disc keeps the axis of its last motion and bounces along it. Raise to 2 to ignore the hand's micro-tremors. |
Sizes (CSS — .elastic-cursor-el 24 px, .elastic-cursor-trail 16 px) |
24 px / 16 px | Diameters at rest; the stretch multiplies them (24 px becomes 53 × 15 px at scaleX 2.2). The translate(−50%, −50%) keeps the center under the pointer whatever the size. |
Colors (CSS — #a78bfa → #6366f1 gradient, rgba(139,92,246,.3) trail) |
violet → indigo, 30% violet trail | The disc must stay at 3:1 or more on your background — it is the only pointer. A more opaque trail (.6, 2.3:1) becomes a second visible dot rather than a shadow. |
cursor: none (CSS — .elastic-zone) |
native pointer hidden | Essential for the disc to replace the arrow, dangerous without JavaScript. Move it under a class set by the script (.js .elastic-zone { cursor: none }) and don't inherit it on links if you want to keep the arrow over them. |
FAQ
.elastic-cursor-el and .elastic-cursor-trail rules set opacity: 0. Absolutely positioned with no left/top, the two divs do sit at the zone's origin, shifted half a diameter outward by the translate(−50%, −50%) (the disc at −4/−4 px, the trail at 0/0 px), but invisible. The script writes opacity = '1' inline on the first mousemove — the inline write beats the rule — and '0' on mouseleave. Remove those two declarations and the discs show up in the corner from page load, at opacity 1 (measured).v is pulled toward its target g (1 at rest, up to 2.2 at speed): w += (g − v) × .2 adds a fifth of the gap to the “stretch velocity”, w *= .6 dissipates 40% of it, v += w applies the result. When the hand stops, v falls from 2.2 toward 1, overshoots (measured: dip to 0.91 at 83 ms) and comes back (1.00 at 133 ms, stable afterwards). The .2 stiffness sets the return speed, the .6 damping the depth of the dip: .3 / .5 for a crisper bounce, .1 / .8 for slow oscillations, damping 0 to remove the bounce.cursor: none set in CSS on the zone. Without JavaScript: the rule applies, no disc follows. Before the first mousemove: the arrow is already hidden, the disc still invisible. Under reduced motion, if you disable the effect on the JS side without touching the CSS. The remedy is the same: declare cursor: none only under a class the script adds to html (.js .elastic-zone), after checking matchMedia('(hover: hover) and (pointer: fine)') and the absence of a reduced-motion preference. Outside the zone, the native pointer always returns — the rule applies to the zone only.