Pulse Ring
Five concentric CSS rings expand and fade to a simulated beat — a pulsing visual indicator in pure CSS and JS, no canvas needed.
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
A pool of five div.pulse-ring elements is created dynamically on init (in addition to the five already in the source HTML). Each ring is a pure CSS circle: border: 3px solid #6366f1 on a transparent background, border-radius: 50%, opacity: 0 by default. Beat detection (detectBeat) computes a variable interval: 500 + Math.sin(time × 0.001) × 100 ms, producing ~1.7 to 2.5 beats per second — equivalent to a simulated tempo of 100 to 150 BPM.
On each detected beat, the first inactive ring in the pool is assigned active = true and a timestamped startTime. Every requestAnimationFrame tick, the function computes a linear progress value: progress = (time − startTime) / 1000 over a one-second window. Opacity drops from 1 to 0 and scale rises from 1× to 2× — driven entirely by style.opacity and style.transform written each frame. No CSS @keyframes are used.
The interpolation is strictly linear: no ease-in or ease-out. The starting diameter is set by width: 120px; height: 120px on .pulse-ring-container, each ring beginning at inset: 0 (120 × 120 px) and expanding to 240 × 240 px at progress = 1 before resetting.
The animation runs in a shared requestAnimationFrame loop alongside up to nine other audio visualizers in the same IIFE (frequency bars, circular visualizer, waveform, particle burst…). All share the same global lastBeat variable, keeping every visualizer synchronized to the same simulated tempo.
Accessibility
- prefers-reduced-motion absent from the code — the IIFE checks neither the CSS media query nor
window.matchMedia: the animation runs regardless of OS preference. For production use, add@media (prefers-reduced-motion: reduce) { .pulse-ring { display: none; } }or readmatchMediato stop the rAF. - No
aria-*attributes on the container or rings — the effect is invisible to screen readers. Addaria-hidden="true"on.pulse-ring-containerif the effect is purely decorative. - No keyboard interaction — the effect is passive and decorative, which is appropriate for an audio indicator. If the container receives a
tabindex, remove it or hide the element from assistive technology. - Ring contrast (#6366f1 on transparent): a decorative non-text element, so WCAG 1.4.3 does not apply. If the ring serves as a functional indicator (active/inactive signal), WCAG 1.4.11 (ratio ≥ 3:1 between ring and actual background) applies.
- No
roleoraria-labelon the container — add these if the animation conveys meaningful information (e.g. beat detected, signal active).
Browser compatibility
Requires requestAnimationFrame, document.createElement, Array.prototype.find, and the CSS inset property — covered by all modern browsers.
Without <code>requestAnimationFrame</code> (very old browsers), the loop never starts and rings stay invisible (<code>opacity: 0</code>) — no fatal JS error, the page remains functional. <code>inset</code> requires Chrome 87+, Firefox 87+, Safari 14.1+; <code>transform: scale()</code> is universal.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="pulse-ring-container" id="pulseRingContainer">
<div class="pulse-ring"></div>
<div class="pulse-ring"></div>
<div class="pulse-ring"></div>
<div class="pulse-ring"></div>
<div class="pulse-ring"></div>
<!-- JS adds 5 more rings on init (only those are animated) -->
</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 |
|---|---|---|
| border: 3px solid #6366f1 (CSS .pulse-ring) | 3px solid #6366f1 | Ring color and width. Swap the hex value for your brand color; increase to 4–6 px for a bolder stroke. |
| width / height (CSS .pulse-ring-container) | 120px × 120px | Ring starting diameter. Go up to 180–240 px for hero use; shrink to 60–80 px for a compact indicator inside a card. |
| elapsed / 1000 (JS animatePulseRing) | 1000 ms | Duration of each ring animation. Drop to 600 ms for a snappier beat; raise to 1 500–2 000 ms for a slow, hypnotic pulse. |
| scale(${1 + progress}) (JS animatePulseRing) | 2× final scale (delta 1) | Maximum ring expansion. Replace with scale(${1 + 2 * progress}) to triple the final size; 1 + 0.5 * progress for a subtler expand. |
| 500 + Math.sin(time * 0.001) * 100 (JS detectBeat) | 500 ± 100 ms | Beat interval. Lower the center to 350 for ~170 BPM; raise to 800 for a slow pace (~75 BPM). Replace the whole expression with a constant for a steady tempo. |
| for (let i = 0; i < 5; i++) (JS pool init) | 5 rings | Pool size. Raise to 8–10 for more simultaneous overlap; drop to 3 for a minimal look. |
FAQ
getElementById(‘pulseRingContainer’) and a rings array closed in the IIFE — only one instance is handled by default. For multiple instances, extract the logic into a factory function that takes an element, creates its own ring pool, and shares the requestAnimationFrame loop: beat detection via lastBeat remains shared, keeping all instances synchronized to the same tempo.animatePulseRing writes style.opacity and style.transform directly onto each ring every requestAnimationFrame tick — no @keyframes, no CSS animation property, no transition. This gives full control over timing and easing, at the cost of a hard dependency on the rAF loop.detectBeat with a function that analyzes energy from an AudioContext stream. Connect your source to an AnalyserNode, compute the RMS of time-domain data each frame, and trigger a beat when RMS exceeds a threshold. The lastBeat variable can remain to prevent triggers that are too close together.