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.
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
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, notcursor: none) and the zone is a non-focusablediv: keep it decorative, no information must depend on the field. - Contrast: the particles are decorative graphical elements (3:1 threshold). On the
#12122e → #0a0a1agradient, 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 from65%to55%in thehsl()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 setaria-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 —300pxin 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: inmouseleave, after 2 s, or as soon as every particle is within 0.1% of its home, set the flag to false and exitd().
Browser compatibility
ES5 code (var, function): it requires requestAnimationFrame, getBoundingClientRect, hsl()/hsla() colors and box-shadow. Zero dependencies.
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):
<div class="gravity-zone" data-gravity-well></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 |
|---|---|---|
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
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.(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.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.