Cursors✨ Premium

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

JSTransformPhysics

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

Project gallery — tiles hovered by a disc — Elastic Cursor example 1

① Project gallery — tiles hovered by a disc

WhenGrid of six studio tiles, each with a title, on a dark background filling the whole zone.
WhyThe disc stretching from one tile to the next gives the portfolio a signature without touching the images; the 15% trail draws the eye's path. Tiles stay clickable, since the discs are pointer-events: none.
SettingsWhite → light-gray gradient disc and white trail at .35 — the tiles are colored, the code's violet would blend into the first one's indigo —, stretch cap .8 (min(.04 × v, .8)) for a more discreet drop over visuals.
Interaction lab — agency hero — Elastic Cursor example 2

② Interaction lab — agency hero

WhenHero of an agency or creative studio: title, subtitle and button on a night gradient, the zone covering the whole home screen.
WhyReplacing the arrow with a living disc from the start announces the craft without a word; the bounce on stop (dip to 0.91 then back within 130 ms) is noticed at every pause of the hand. Text and button stay readable and clickable under the disc.
SettingsCode settings kept (.15, .04, cap 1.2), cursor: none removed above the navigation to give the arrow back on menu links.
Air hockey table — mouse-driven puck — Elastic Cursor example 3

③ Air hockey table — mouse-driven puck

WhenMini-game or waiting screen: a rink with a center line, a center circle, two goals and a score.
WhyThe disc that elongates along the strike and bounces on stop is already a puck; the translucent trail acts as a speed effect. The rink provides visual landmarks that make the stretch readable.
Settings32 px off-white disc, 24 px trail, spring stiffness .3 and damping .5 for a more pronounced bounce (deeper dip, two oscillations).

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 remove cursor: none from the zone, otherwise the user loses their pointer with nothing in its place.
  • cursor: none removes the native pointer across the whole zone: the disc is the only position cue, and it only exists during hover, after the first mousemove. Without JavaScript, or before it runs, the zone is a hole with no pointer — apply cursor: none only under a class set by the script (.js .elastic-zone). Interactive elements placed inside the zone remain clickable (the discs are pointer-events: none) but are aimed at with a 24 px disc, not an arrow.
  • Contrast: the #a78bfa → #6366f1 disc 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. The rgba(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, and cursor: none changes nothing. But some mobile browsers emit a mouseenter on tap without ever emitting a mouseleave: the requestAnimationFrame loop starts and never stops. Gate the initialization on matchMedia('(hover: hover) and (pointer: fine)'). Screen readers: two empty divs, silent; set aria-hidden="true" on the zone.
  • Integration: the zone must stay position: relative with an explicit height (300px in the sold code; a percentage without a sized parent renders 0 px) and directly contain the two divs — the script looks them up with querySelector inside 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 the opacity: 0 of 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.

Chrome 26+✓ Full
Firefox 16+✓ Full
Safari 9+✓ Full
Edge 12+✓ Full
Mobile iOS✓ Inert (no pointer)
Android Chrome✓ Inert (no pointer)

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):

index.html — structure
<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>
🔒 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
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

Nothing: the .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).
Through a damped spring on the stretch, added to the code on September 5, 2026. On each frame, the stretch 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.
In three cases, all tied to the 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.