Pulse Ring
Two 50 px rings expand from 25 to 75 px in 2 s (ease-out), fade from opacity 1 to 0 and loop — ::after delayed by exactly 1 s. Pure CSS, no script.
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. Loaders has 29 effects, including 4 free. Explore the category →
3 usage examples



How it works
The effect relies on two pseudo-elements (::before and ::after) of the .loader-pulse container (50 × 50 px, position: relative). Each is absolutely positioned at inset: 0 — it occupies the same 50 × 50 px square as the container — with border: 3px solid #6366f1 and border-radius: 50%, making it a ring. No child HTML elements are needed; no JavaScript is loaded.
The @keyframes pulse animation moves transform from scale(.5) to scale(1.5) and opacity from 1 to 0, over 2 s ease-out, looping infinitely. The ring's visible diameter goes from 25 px (50 × 0.5) to 75 px (50 × 1.5); since scale() also affects the stroke, the apparent border width goes from 1.5 px at the start to 4.5 px at the end — the ring thickens slightly as it expands. The ease-out curve produces a fast initial expansion that slows toward the end: the ring bursts from the center, then floats before fading. ::after has animation-delay: 1s, exactly half the period: at any moment, one ring is born while the other fades — one new start per second, two alternating cycles without interruption.
The code contains no var(--…) references (verified: zero occurrences) and no scripts. The inset property requires Chrome 87+, Firefox 87+, or Safari 14.1+. The name @keyframes pulse is not prefixed: if the buyer's page already defines an animation with that name, the ring will adopt the page's animation — rename it if this collision is possible. The sold HTML includes the site's div.demo-preview; the buyer only needs <div class="loader-pulse"></div> and the CSS without that wrapper.
Accessibility
- prefers-reduced-motion not handled — measured with Playwright:
animationPlayState: "running"under emulatedprefers-reduced-motion: reduce(Chromium). Both rings animate regardless of the OS preference. Fix: add@media (prefers-reduced-motion: reduce) { .loader-pulse::before, .loader-pulse::after { animation: none; } }. - No accessibility attributes: no
role="status", noaria-busy, noaria-live, no alternative text. Screen readers do not announce the loading state. For a functional indicator, addrole="status"to.loader-pulseand a visually-hidden<span class="sr-only">Loading…</span>inside it. - The animation is purely visual and conveys no information through color alone: the ring is either visible or gone. If the loading state must be communicated, a text message must accompany the component — the effect alone is not sufficient.
- The name
@keyframes pulseis not prefixed: a naming collision on the host page may cause the ring to adopt a foreign animation and the visual behavior is no longer guaranteed. Rename toloader-pulse-animor similar if multiple 'pulse' animations coexist.
Browser compatibility
Pure CSS: requires @keyframes, transform: scale(), opacity, inset, and border-radius: 50%. No JS dependencies.
Without inset (Chrome < 87, Safari < 14.1): pseudo-elements no longer position inside the container — replace with top: 0; right: 0; bottom: 0; left: 0 for broader support. Without CSS animations, both pseudo-elements remain frozen at scale(.5) — two small static arcs, no error, page intact.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="loader-pulse"></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 |
|---|---|---|
Container size (CSS — width / height of .loader-pulse) |
50 × 50 px | Starting diameter (× 0.5) and ending diameter (× 1.5) of the rings. 24 px = button indicator; 80–100 px = full-screen centering. |
Color (CSS — border: 3px solid #6366f1) |
#6366f1 (indigo) | Color of both rings. Changing the hex value is enough; rgba lets you adjust the initial opacity independently of the animation. |
Stroke width (CSS — border: 3px solid …) |
3 px | Nominal stroke width; scale() multiplies it: visually 1.5 px at the start, 4.5 px at the end. Raise to 5 px for more prominent rings on large formats. |
Cycle duration (CSS — animation: 2s …) |
2 s | Duration of one full scale(0.5) → scale(1.5) pass. 1 s = fast pulse; 3–4 s = slow breathing. If you change this value, update the second ring's delay to half of the new duration. |
Second ring offset (CSS — animation-delay: 1s on ::after) |
1 s (half-period) | Phase shift between the two rings. At half-period, cycles alternate perfectly — one start per second. 0.5 s (quarter-period): rings overlap more, creating a 'double beat' effect. |
Starting scale (CSS — scale(.5) in @keyframes pulse) |
0.5 — ring at 25 px diameter on a 50 px container | scale(0): the ring starts from a geometric point (shock-wave effect). scale(0.8): short, subtle expansion, already close to full size. |
Timing curve (CSS — ease-out) |
ease-out | Fast initial expansion, slowing at the end. linear: steady mechanical expansion. ease-in-out: soft start and end, more symmetric motion — closer to a sonar ping. |
FAQ
requestAnimationFrame, computes a simulated beat interval (500 + Math.sin(time × 0.001) × 100 ms, yielding 1.7 to 2.5 pulses per second), applies linear interpolation on a 120 px container, and shares its rAF loop with other visualizers in the same IIFE. fx-0423 is a loading indicator: 2 CSS pseudo-elements, no JavaScript, fixed 2 s ease-out duration, exact half-period phase offset. The two effects have no functional overlap.scale(0), the ring would emerge from a geometric point — a 'shockwave' or 'water drop' effect. At scale(0.5), it starts at 25 px in diameter: the eye sees a ring expanding and fading, not a circle being born. This makes the pulse softer and more readable at small sizes (24 px container). For the shockwave look, replace scale(.5) with scale(0) — the stroke will then be 0 px at the start and 4.5 px at the end..loader-pulse { --pr-dur: 2s; } and replace the values with animation: var(--pr-dur) ease-out … and animation-delay: calc(var(--pr-dur) / 2). Both rings stay automatically at the half-period whatever the duration, and multiple instances can coexist at different speeds by overriding --pr-dur on each container.