Cursor Ripple Field
At most one ring every 120 ms: a div with a 2 px border, hue drawn between 220° and 280°, growing from 0 to 150 px in 1 s — width and scale animated together, hence a slow start — while its opacity fades out, then removing itself on animationend.
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
Every [data-ripple-field] gets a mousemove listener guarded by a time lock: a boolean t flips to true on the first emission and a 120 ms setTimeout releases it. Any movement during that delay is ignored, whatever the mouse polling rate: output is capped at 1000 / 120 ≈ 8 rings per second. Measured in Chromium: 9 rings created over 1.29 s of continuous motion, at most 7 present at the same instant.
For each accepted emission the script creates a div.alchemy-cursor-ripple, positions it with left/top at the pointer coordinates relative to the zone (getBoundingClientRect) and gives it a border color of hsla(220 + 60 × Math.random(), 70%, 65%, .6) — a hue between blue (220°) and violet (280°), at 60% opacity. The div is appended to the zone and self-destructs: an animationend listener calls remove(). There is no requestAnimationFrame loop and no cleanup timer: the end of the CSS animation is the only clock.
The motion is entirely CSS: .alchemy-cursor-ripple is position: absolute, border: 2px solid, round, pointer-events: none, and plays cursorRippleExpand 1s ease-out forwards. The keyframes animate two quantities at once: width/height from 0 to 150 px and transform: translate(−50%, −50%) scale(0 → 1). The visible diameter is their product: measured, 1 px at 50 ms, 14 px at 200 ms, 70 px at 500 ms, 150 px at 1 s — the ring starts very slowly then opens fast, whereas a single quantity in ease-out would do the opposite. Opacity goes from 1 to 0 on the same curve (0.69 at 200 ms, 0.32 at 500 ms). The translate(−50%, −50%) centers the ring on the emission point.
The .ripple-field-zone is 100% × 300px, with a linear-gradient(135deg, #0f0f2e, #1a1a3e), cursor: pointer, position: relative (anchor for left/top) and overflow: hidden, which clips rings born near the edges. The sold CSS is limited to that zone, .alchemy-cursor-ripple and its cursorRippleExpand keyframes: nothing from another effect.
Accessibility
- prefers-reduced-motion not handled: rings open regardless of the system preference. Add
@media (prefers-reduced-motion: reduce) { .alchemy-cursor-ripple { animation: none; display: none } }— careful, without an animation theanimationendevent never fires and the divs would no longer be removed, hence thedisplay: none; better still, gate the listener onmatchMedia('(prefers-reduced-motion: reduce)').matchesso nothing is created. - The effect is mouse-only: no ring from the keyboard, and on touch at most one per tap (a single emulated
mousemove). The zone is a non-focusablediv;cursor: pointerpromises a click that doesn't exist in the code — replace it withdefaultif the zone isn't clickable, or wire a real action. - Contrast: the rings are decorative graphical elements. At 60% opacity on the gradient, the least visible hue measures 2.3:1 and the most visible 2.9:1, below the 3:1 threshold for UI components (WCAG 1.4.11) — fine for an ornament, not if the ring must signal something: raise the alpha to
.9(≥ 3.5:1). The shipped hint text,rgba(255,255,255,.15), sits at 1.5:1: raise it to.5(5.3:1) if it must be read. - Screen readers: the divs are empty, hence silent, but up to 8 insertions and removals per second churn the accessibility tree. Put
aria-hidden="true"on the[data-ripple-field]zone. The native pointer stays displayed (nocursor: none): nothing is taken away from the user. - Integration: the zone must keep
position: relative(otherwise the rings position themselves against the nearest positioned ancestor) and an explicit height — the sold code sets300px; a%value inside a parent without a height renders 0 px. Keepoverflow: hidden, which clips the 150 px rings born at the edge. No reset on exit: each ring removes itself after one second.
Browser compatibility
ES5 code (var, function): it requires getBoundingClientRect, setTimeout, the animationend event, a CSS animation on width/height, transform and opacity, and hsla() colors. Zero dependencies.
Without JavaScript, the zone keeps its gradient and hint text: no ring, nothing broken. Without CSS animations (very old browsers), a ring would be created at 0 px, invisible, and never removed for lack of animationend — one empty div per emission, with no visible consequence.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="ripple-field-zone" data-ripple-field>
<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 create ripples</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 |
|---|---|---|
Lock (JS — setTimeout(..., 120)) |
120 ms | Minimum interval between two rings, hence the maximum rate (1000 / 120 ≈ 8 per second). 60 doubles the density; 250 gives isolated rings, readable one by one. The number of simultaneous rings equals duration / lock. |
Hue (JS — 60 × Math.random() + 220) |
220° to 280° (blue → violet) | HSL hue range drawn per ring; saturation 70% and lightness 65% are fixed in the hsla() string. 0 + 40 × Math.random() = reds and oranges; a constant = uniform rings. |
Border alpha (JS — , 0.6)) |
.6 | Starting opacity of the border color, then multiplied by the animated opacity. .9 makes the ring crisp (≥ 3.5:1 on the background); .35 melts it into the gradient. |
Final size (CSS — @keyframes cursorRippleExpand, 100%) |
150 px | Diameter reached at the end. Change width and height together. Since scale also goes from 0 to 1, the ring stays small for a long time: halfway through it measures only 70 px out of 150. |
Duration (CSS — animation: cursorRippleExpand 1s) |
1s ease-out | Ring lifetime and opening speed. Removal of the div follows automatically (animationend). .6s = nervous drops; 2s = slow waves, up to 16 rings present at the default lock. |
Thickness (CSS — border: 2px solid) |
2 px | Stroke of the ring. Note that scale(0 → 1) acts on the whole element, so the border looks thinner at birth and reaches 2 px only at the end. 3px for a ring that stays visible from the start. |
Progressive start (CSS — scale(0) at 0%) |
scale(0) + width 0 | Replace scale(0) with scale(1) at 0% to animate the width only: the ring then opens fast at first and slows down, the classic ease-out curve, and the border keeps its thickness from the first instant to the last. |
FAQ
width/height from 0 to 150 px) and its scale (from 0 to 1). The displayed diameter is the product of both; early on the curve, two small values multiplied give almost nothing — measured, 1 px at 50 ms and 14 px at 200 ms, then 70 px at 500 ms and 150 px at 1 s. It is a propagation that accelerates, unlike a real drop. For a fast opening that slows down, set scale(1) from the 0% frame and let the width alone carry the animation.animationend and removes itself when the one-second animation ends; there is no timer and no loop. At the maximum rate, 8 rings per second for 1 s = at most 8 divs present, 7 measured. The weakness of this choice: if the animation doesn't play — animation: none set by a reduced-motion rule, or a browser without CSS animations — the event never fires and the divs pile up, invisible but real. In that case, create no ring at all (a matchMedia condition around the listener) rather than hiding the ones that exist.querySelectorAll('[data-ripple-field]') and gives each one its own lock and its own rings. The whole page is possible by putting the attribute on a position: relative, overflow: hidden container that wraps it — the rings' left/top are relative to that container, not to the viewport: if it scrolls, rings born at the top stay at the top. For rings fixed on screen you would switch .alchemy-cursor-ripple to position: fixed and use clientX/clientY without subtracting the zone's rectangle.