Cursors✨ Premium

Gravity Well Cursor

Sixty dots of 3 to 8 px, hues from 220° to 300°, each pulled back to its home position at 3% per frame; within a radius of 30% of the zone around the pointer, a push of up to 3% per frame drives them apart. The code repels, it doesn't attract — and its requestAnimationFrame loop never stops after the first entry.

JSPhysicsrequestAnimationFrame

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

Threats don't get close — hero of a cybersecurity offer — Gravity Well Cursor example 1

① Threats don't get close — hero of a cybersecurity offer

WhenHome page of a security product: headline, subtitle, button, over a dot field filling the whole screen.
WhyA field that moves away from the pointer literally illustrates the message — whatever approaches is pushed back. Repulsion is the right metaphor here; an attraction would say the opposite. The text, above the particles, stays readable: they are 3 to 8 px.
SettingsField radius raised to 40, push 4, pull-back .02 for a slower return, hues narrowed to 200 + 40 × Math.random() (blues); the text at z-index: 1 above the dots.
Particle field — widget of a scientific dashboard — Gravity Well Cursor example 2

② Particle field — widget of a scientific dashboard

WhenDashboard or lab card: header, legend, and a simulation in the body of the card.
WhyIn an analysis interface, a field the pointer disturbs and that settles back within 1.6 s reads as a live simulation, without video. The native crosshair kept suits a tool. The permanent loop must be stopped at rest here: the card stays on screen for a long time.
Settings100 particles, sizes 2 + 3 × Math.random(), monochrome palette hsl(190, 80%, 60%); loop stopped 2 s after the pointer leaves (see FAQ).
Electro festival poster — reactive background — Gravity Well Cursor example 3

③ Electro festival poster — reactive background

WhenFull-frame festival visual: name, dates, headliners, over a background of colored dots.
WhyThe code's 220° to 300° hues — blue, violet, magenta — are those of electro and lasers; dots fleeing the hand give the poster a physical reaction without a visibly permanent animation. The original radial gradient serves as a stage backdrop.
Settings80 particles, sizes 3 + 7 × Math.random() with a doubled glow (0 0 3·size), radius 25 and push 5 for brief, lively escapes, pull-back .05.

How it works

For each [data-gravity-well] zone, the script creates 60 div.gravity-particle elements: diameter 3 + 5 × Math.random() (3 to 8 px), hue 220 + 80 × Math.random() applied as hsl(h, 70%, 65%) — from blue to magenta —, glow box-shadow: 0 0 2·size px hsla(h, 70%, 65%, .4), and a random home position written as left/top in percent of the zone. Each particle remembers homeX/homeY (home) and x/y (current position). The particle's CSS: position: absolute, round, transition: none. The zone: 100% × 300px, radial gradient #12122e → #0a0a1a, cursor: crosshair, position: relative, overflow: hidden. Until September 5, 2026, the sold HTML also contained 60 frozen particles: 120 dots, half of them motionless; only the script's 60 remain.

The d() loop, on requestAnimationFrame, handles each particle in two steps. First the push: the pointer is converted to percentages of the zone (a, n) on every mousemove, and the distance h between the particle and the pointer is computed in those units. If h < 30 and the pointer is inside the zone (a > −100), the particle receives a displacement of 3 × (1 − h / 30) units along the atan2 angle plus π — that is, away from the pointer: 3% of the zone per frame at contact, nothing at 30 units. Then the pull-back: x += .03 × (homeX − x), 3% of the remaining gap per frame, i.e. 95% of the return in about a hundred frames — roughly 1.6 s at 60 frames per second (measured: within 0.03 px of home two seconds after the pointer left).

Measured in Chromium with the pointer still at the center: 24 particles displaced by more than 2 px, the farthest by 61 px, and all of them move away from the pointer — it is a repulsion. The name and the former description promised an attraction; the catalog description has been corrected (“particles near the cursor are pushed away”), the code hasn't changed. A consequence of the percent units: the 30-unit field measures 30% of the width by 30% of the height, an ellipse of 223 × 90 px in a 744 × 300 px zone — two and a half times wider than tall. On a square zone, the field would be round.

The life cycle is the weak point. mouseenter starts the loop once (flag o); mouseleave merely sends the pointer to −1000, which cuts the push and lets the particles return — but nothing ever stops the loop. Measured: a hand-forced position is rewritten 120 ms after the pointer left, and would still be an hour later. Each frame costs 60 left/top writes (hence a layout pass, despite the will-change: transform declared on elements whose transform never changes) and one getBoundingClientRect() call whose result is unused.

Accessibility

  • prefers-reduced-motion not handled: sixty dots move away from the pointer and a loop runs permanently, regardless of the preference. No CSS rule can stop it (positions written inline, transition: none): gate the whole initialization on !matchMedia('(prefers-reduced-motion: reduce)').matches, or create the particles without starting the loop — a motionless star field is an honest fallback.
  • The effect is mouse-only: without mousemove, the particles stay at their home position. The native pointer stays visible (cursor: crosshair, not cursor: none) and the zone is a non-focusable div: keep it decorative, no information must depend on the field.
  • Contrast: the particles are decorative graphical elements (3:1 threshold). On the #12122e → #0a0a1a gradient, the least visible hue (around 260°) measures 4.0:1 and the most visible 6.4:1 — all pass. On a light page they don't: 2.9:1 at minimum on white; lower the lightness from 65% to 55% in the hsl() string. The code contains no text.
  • Screen readers: 60 empty divs, silent, created once on load — the accessibility tree is not touched afterwards, only styles change. Still set aria-hidden="true" on the [data-gravity-well] zone.
  • Integration: the zone must keep position: relative (anchor for the percentages), overflow: hidden (particles pushed toward an edge leave it) and an explicit height — 300px in the sold code. Since positions are in percent, the field follows the zone's proportions (an ellipse on a wide zone). Plan for stopping the loop: in mouseleave, after 2 s, or as soon as every particle is within 0.1% of its home, set the flag to false and exit d().

Browser compatibility

ES5 code (var, function): it requires requestAnimationFrame, getBoundingClientRect, hsl()/hsla() colors and box-shadow. Zero dependencies.

Chrome 24+✓ Full
Firefox 23+✓ Full
Safari 6.1+✓ Full
Edge 12+✓ Full
Mobile iOS✓ Static (motionless dots)
Android Chrome✓ Static (motionless dots)

Without JavaScript, the zone is a plain radial gradient: no particles, since the script creates them. Without requestAnimationFrame (IE 9), the 60 dots are created at their home position and never move.

The code

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

index.html — structure
<div class="gravity-zone" data-gravity-well></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
Particle count (JS — h < 60) 60 Dots created on load, each with its update in the loop. Cost is linear: 60 position writes per frame. Beyond 150, move positions to transform: translate() to avoid layout.
Field radius (JS — h < 30 and 1 − h / 30) 30 units (30% of the width × 30% of the height) Reach of the repulsion, in percent of each dimension: an ellipse on a wide zone. Change both occurrences together. 15 = narrow hole under the pointer; 50 = almost the whole zone reacts.
Push strength (JS — 3 × (1 − h / 30)) 3% of the zone per frame at contact Maximum displacement per frame, tapering to the field's edge. 1: dots part gently and the pull-back keeps them near home; 6: they are flung far then return slowly.
Pull-back to home (JS — .03 × (homeX − x)) .03 per frame Fraction of the gap covered on each frame: 95% of the return in ~100 frames (1.6 s). .1 = return in 0.5 s, “stiffer” field; .01 = dots that drift for several seconds.
Sizes (JS — 3 + 5 × Math.random()) 3 to 8 px Dot diameter; the glow follows (0 0 2·size). Dots of 1 to 3 px give a starry sky; 6 to 14 px, bubbles.
Colors (JS — 220 + 80 × Math.random(), hsl(h, 70%, 65%)) blue → magenta, 70% / 65% Hue range with fixed saturation/lightness. hsl(h, 70%, 55%) to hold 3:1 on a light background; a constant hue for a monochrome field; the glow reuses the same hue at 40% opacity.
Stopping the loop (JS — to add) never (permanent loop) In d(), exit without requesting a frame when the pointer is gone and every particle is within 0.1 unit of home, and set o back to false so the next mouseenter restarts it. The field becomes free at rest.

FAQ

It repels. The displacement is applied along Math.atan2(dy, dx) + Math.PI: the angle toward the pointer, turned by 180°. Measured with the pointer still at the center, 24 particles move, the farthest by 61 px, and all end up farther from the pointer than they started. The catalog description has been brought in line with the code (“pushed away”); the name, for its part, inherits the original intent. For a real attraction, remove the + Math.PI — but without damping the dots rush at the pointer, pass through it and oscillate around it: add a minimum distance (h > 3) or lower the push to 1 so they cluster without jittering.
Because all distances are computed in percentages of the zone, not in pixels. The pointer is converted to (clientX − left) / width × 100 and (clientY − top) / height × 100, the particles live in left/top in %, and the radius of 30 applies to those units: 30% of 744 px horizontally (223 px), 30% of 300 px vertically (90 px). The field is therefore an ellipse with the zone's proportions. That is what makes the code size-independent — but if you want a round field, convert to pixels before measuring the distance, or give the zone a square format.
Yes, indefinitely. mouseenter starts d() once and sets the o flag to true; mouseleave only moves the virtual pointer to −1000, which cuts the push. The loop keeps pulling back particles already at home and rewriting 60 positions per frame — measured: a forced position is rewritten 120 ms after leaving, and would still be hours later. On a page that stays open, that is permanent, useless work. The fix is three lines: in d(), if the pointer is at −1000 and every particle is within 0.1 unit of home, snap it into place, set o = false and don't request another frame; the next mouseenter will restart the loop.