Attraction Field
80 particles attracted toward the cursor with F = min(2, 50/d) — links between neighbours within 80 px, 0.96 damping per frame, toroidal wrapping.
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. Atmosphere has 57 effects, including 10 free. Explore the category →
3 usage examples



How it works
An IIFE initialises a full-width canvas via setupCanvas('psAttraction') (getElementById lookup). A pool of 80 particles is created at startup with random positions, zero velocity, and radii between 1 and 3 px. The cursor is tracked via a mousemove listener on the canvas, with CSS-to-canvas coordinate conversion through getBoundingClientRect(). A non-cancellable requestAnimationFrame runs continuously — there is no IntersectionObserver and no off-screen pause.
On each frame, each particle computes the vector to the cursor (dx, dy) and the distance d. The attraction force is F = min(2, 50/d) — increasing at close range, capped at 2 to prevent numerical instability. The applied acceleration is F × 0.02, a maximum of 0.04 px/frame². Velocity is damped each frame by a factor of 0.96 (natural stop in ~25 frames if force ceases). Position is updated, then toroidally wrapped: a particle leaving one edge reappears at the opposite edge.
Rendering starts with a full #0a0a0f fill (fillRect of the entire canvas, not a clearRect). Each particle is drawn as a circle rgba(139, 92, 246, 0.4 + r/5) — small particles (r ≈ 1) have 0.6 opacity; large ones (r ≈ 3) reach 1.0. A double loop then checks all pairs: if the distance between two particles is below 80 px, a 0.5 px line is drawn with opacity 0.15 × (1 − d/80) — connections fade out gradually with distance.
Accessibility
- prefers-reduced-motion absent: the code does not check this media preference — the animation runs in a perpetual loop regardless of the system setting. Add a
matchMedia('(prefers-reduced-motion: reduce)')guard manually for accessible builds. - The
<canvas>lacksaria-hidden="true"in the supplied structure — add it so screen readers skip the canvas element. - No
role="img"oraria-labelon the.demo-previewcontainer — the effect has no semantic description. - Attraction is triggered solely by
mousemove— no touch (touchmove) or keyboard equivalent. On mobile, particles drift toward the default centre position. - Particle contrast:
#8b5cf6purple on#0a0a0freaches ~4.7:1 at full opacity (WCAG AA for graphical elements); links at alpha 0.15 fall below the threshold — they are decorative, not informational.
Browser compatibility
Requires Canvas 2D Context and requestAnimationFrame — supported in all modern browsers. Zero external dependencies.
If <code>canvas</code> is not supported, the container stays empty (no fatal JS error). If <code>getElementById('psAttraction')</code> finds no canvas, the IIFE returns immediately without starting a loop.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="demo-preview">
<div class="ps-canvas-wrap" role="img" aria-label="Purple particles attracted toward the cursor with links between close neighbours">
<canvas class="ps-canvas" id="psAttraction" aria-hidden="true"></canvas>
<span class="ps-hint" aria-hidden="true">Move the mouse</span>
<!-- Optional: content overlay (scrim + title + CTA) -->
<!-- <div class="ps-overlay"> ... </div> -->
</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 |
|---|---|---|
| Particle count [JS: < 80] | 80 | Spawn loop: for (let e = 0; e < 80; e++). 40–60 is more appropriate for an embedded component; exceeding 120 strains mobile browsers. |
| Particle radius [JS: 2 × rand + 1] | 1–3 px | Controls size and opacity (opacity = 0.4 + r/5). Replace with rand + 0.5 for finer particles; 3 × rand + 2 for large, bright ones. |
| Attraction constant [JS: 50] | 50 | Numerator in min(2, 50/d). Increase to extend the attraction range; decrease to concentrate it near the cursor. |
| Acceleration factor [JS: 0.02] | 0.02 | Multiplied by force each frame: dx/d × force × 0.02. 0.02 gives fluid attraction; 0.05 makes it snappy and unstable. |
| Damping [JS: 0.96] | 0.96 | Per-frame deceleration: vx *= .96. 0.96 = natural stop in ~25 frames. 0.99 = long coasting trail, 0.90 = abrupt stop. |
| Link threshold [JS: 80] | 80 px | Maximum distance between two particles to draw a link: if (i < 80). Increase for a denser graph; reducing to 50 thins it out. |
| Particle colour [JS: 139,92,246] | purple #8b5cf6 | RGB values in rgba(139,92,246,...) (particles and links). Replace the three integers with your brand colour's RGB components. |
FAQ
IntersectionObserver. The requestAnimationFrame loop runs continuously even when the canvas is outside the viewport. To optimise performance, wrap the loop in an IntersectionObserver that stops the RAF when the intersection ratio drops to zero and restarts it when the canvas re-enters.mousemove. On mobile, particles drift toward the default centre position. To support touch, add a touchmove listener on the canvas that updates the tracking variables with evt.touches[0].clientX and clientY, converted via getBoundingClientRect().'psAttraction'. In React, mount the canvas via a ref inside useEffect, extract the particle logic into an initAttraction(canvas) function that takes the canvas element directly (not an id), and cancel the requestAnimationFrame in the cleanup return. In Vue, the same pattern applies via onMounted / onUnmounted.