Pulse Button — Free HTML CSS JS Sonar Pulse Snippet

Pulse Button · Buttons · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Shape-matched rings
Pulses inherit the button border radius.
Staggered emitters
Two rings offset for a seamless ripple.
Scale-and-fade pulse
Rings grow and dissolve outward.
Live/stop toggle
Click flips state, label, and colors.
Off-screen pause
IntersectionObserver stops unseen pulses.
Crisp label
Text sits above the rings via z-index.
Pure-CSS animation
JS only handles discrete events.
Hover and press
Subtle lift and settle on interaction.

About this UI Snippet

Pulse Button — Sonar Rings That Draw the Eye to an Action

Screenshot of the Pulse Button snippet rendered live

The pulse button is the attention-grabbing call to action that emits expanding rings like sonar — perfect for "Go live," "Record," or any action you want users to notice. This snippet builds it with plain HTML, CSS, and a little vanilla JavaScript for a live/stop toggle and efficient off-screen pausing.

The expanding pulse rings

Behind the button label sit two ring elements that share the button shape (inset: 0, border-radius: inherit). The pbPulse keyframe scales each ring from 1 to 1.7 while fading its opacity from .55 to 0, so it grows outward and dissolves — the sonar ping. Because the rings inherit the button border radius, the pulse takes the button shape rather than a plain circle, which looks more integrated. The rings sit at z-index: 0 behind the label so the text stays crisp on top.

Staggering for a continuous ripple

A single ring would pulse, pause, then pulse again with a visible gap. Two rings with the second offset by animation-delay: 1s (half the 2-second loop) means a new ring is always emerging as the previous one finishes — so the effect is a continuous, overlapping ripple rather than a blinking single pulse. This stagger-by-half-the-duration is the standard trick for seamless looping emitters.

A meaningful toggle

The button is not just decorative — clicking it toggles a live state. When live, it shows "● Go live" with a red gradient and pulsing rings; clicking flips it to "■ Stop" with a muted gray gradient, removes the shadow, and pauses the rings via a .stopped class. This mirrors real broadcast or recording controls, where the pulse communicates an active state and stopping it calms the button. Wire the toggle to your actual start/stop logic and the visual state follows.

Pausing off-screen

Continuously scaling, fading rings cost compositing work, so an IntersectionObserver watches the button and pauses the ring animations whenever the button scrolls out of view (and only resumes them if it is both visible and live). With threshold: 0 it reacts as soon as any part leaves the viewport. This keeps a page with a persistent pulsing CTA from doing animation work nobody can see.

Why CSS rings over JavaScript

The pulse is pure CSS keyframes, so it runs on the compositor without any per-frame scripting — the JavaScript only flips classes and styles on discrete events (click, visibility change). That keeps the effect smooth and cheap even if several pulse buttons appear on a page.

Customizing it

Change the scale target and opacity curve for a larger or subtler pulse, retime the loop and adjust the second ring delay to match, add a third ring for a denser ripple, recolor the rings and gradients, or change the labels and states. Use it for notifications, record buttons, or live indicators. Pair it with a shiny text badge or a border beam card to coordinate attention cues.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSA red Go live button emits expanding sonar rings.
  2. 2
    Watch the rippleTwo staggered rings keep a continuous pulse going.
  3. 3
    Click the buttonIt toggles to a muted Stop state and the pulse halts.
  4. 4
    Click againIt returns to the live, pulsing state.
  5. 5
    Scroll it awayThe rings pause off-screen to save work.
  6. 6
    Recolor and retimeAdjust the ring scale, colors, and loop.

Real-world uses

Common Use Cases

Live and record buttons
Signal an active broadcast or recording.
Primary CTAs
Draw the eye next to a shiny text badge.
Notification prompts
Pair with a notification bell.
Urgent actions
Highlight a sticky promo bar button.
Status indicators
Show a live state on a status dashboard.
Pulse animation demos
A reference for staggered sonar rings.

Got questions?

Frequently Asked Questions

Two ring elements run the same pulse keyframe, but the second has an animation-delay of half the loop duration. That means a new ring is always emerging as the previous one finishes, so instead of one ring pulsing with a visible gap, the rings overlap into a continuous ripple. Staggering by half the duration is the standard trick for seamless emitters.

The rings use inset: 0 and border-radius: inherit, so they take the button rounded-rectangle shape rather than a plain circle. As the pbPulse keyframe scales and fades them, the pulse grows outward in the button shape, which looks more integrated than a generic circular ping.

Yes. Clicking toggles a live state: live shows a red Go live with pulsing rings, and stopped shows a muted Stop with the rings paused and the shadow removed via a class. It mirrors a broadcast or record control, and you can wire the toggle to your real start/stop logic so the visual state tracks it.

Yes. The pulse is pure CSS keyframes running on the compositor, and an IntersectionObserver pauses the ring animations whenever the button is off-screen, resuming only when it is both visible and live. So a persistent pulsing CTA does no animation work while it is scrolled out of view.

Render the button with its ring elements and keep the live state in component state, toggling a class and the label on click. Put the IntersectionObserver in a mount effect with cleanup on unmount. The pulse keyframes are pure CSS. In Tailwind, define the pulse animation in the config and apply it to the ring spans with a delayed variant for the second ring.

Pulse Button — Export as HTML, React, Vue, Angular & Tailwind

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Pulse Button</title>
  <style>
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a14;display:flex;justify-content:center;align-items:center;min-height:100vh}
    
    .pb-stage{padding:50px}
    .pb-btn{position:relative;border:none;border-radius:14px;padding:15px 30px;font-family:inherit;font-size:15px;font-weight:800;color:#fff;cursor:pointer;background:linear-gradient(120deg,#e11d48,#f43f5e);box-shadow:0 10px 30px -8px rgba(244,63,94,.6);transition:transform .15s}
    .pb-btn:hover{transform:translateY(-2px)}
    .pb-btn:active{transform:translateY(0)}
    .pb-label{position:relative;z-index:1}
    
    /* Two expanding rings emit from the button on a staggered loop, fading as they
       grow — a sonar pulse that signals "live" or "act now". */
    .pb-ring{position:absolute;inset:0;border-radius:inherit;background:#f43f5e;z-index:0;animation:pbPulse 2s ease-out infinite}
    .pb-ring2{animation-delay:1s}
    @keyframes pbPulse{0%{transform:scale(1);opacity:.55}100%{transform:scale(1.7);opacity:0}}
    
    .pb-btn.stopped .pb-ring{animation-play-state:paused;opacity:0}
  </style>
</head>
<body>
  <div class="pb-stage">
    <button type="button" class="pb-btn" id="pbBtn">
      <span class="pb-ring" aria-hidden="true"></span>
      <span class="pb-ring pb-ring2" aria-hidden="true"></span>
      <span class="pb-label">● Go live</span>
    </button>
  </div>
  <script>
    var btn = document.getElementById('pbBtn');
    var label = btn.querySelector('.pb-label');
    var live = true;
    
    // Toggle the live state: clicking stops the pulse and flips the label, like
    // starting/stopping a broadcast.
    btn.addEventListener('click', function () {
      live = !live;
      btn.classList.toggle('stopped', !live);
      label.textContent = live ? '● Go live' : '■ Stop';
      btn.style.background = live
        ? 'linear-gradient(120deg,#e11d48,#f43f5e)'
        : 'linear-gradient(120deg,#334155,#475569)';
      btn.style.boxShadow = live ? '0 10px 30px -8px rgba(244,63,94,.6)' : 'none';
    });
    
    // Pause rings when off-screen to avoid needless compositing.
    var io = new IntersectionObserver(function (entries) {
      entries.forEach(function (e) {
        btn.querySelectorAll('.pb-ring').forEach(function (r) {
          r.style.animationPlayState = (e.isIntersecting && live) ? 'running' : 'paused';
        });
      });
    }, { threshold: 0 });
    io.observe(btn);
  </script>
</body>
</html>
Tailwind
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Pulse Button</title>
  <!-- Tailwind CSS v3+ — arbitrary value classes (e.g. bg-[#0f172a]) are valid -->
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    @keyframes pbPulse{0%{transform:scale(1);opacity:.55}100%{transform:scale(1.7);opacity:0}}

    * {
      box-sizing:border-box;margin:0;padding:0
    }

    body {
      font-family:system-ui,-apple-system,sans-serif;background:#0a0a14;display:flex;justify-content:center;align-items:center;min-height:100vh
    }

    .pb-btn.stopped .pb-ring {
      animation-play-state:paused;opacity:0
    }
  </style>
</head>
<body>
  <div class="p-[50px]">
    <button type="button" class="pb-btn relative border-0 rounded-[14px] py-[15px] px-[30px] font-[inherit] text-[15px] font-extrabold text-[#fff] cursor-pointer [background:linear-gradient(120deg,#e11d48,#f43f5e)] shadow-[0_10px_30px_-8px_rgba(244,63,94,.6)] [transition:transform_.15s] hover:[transform:translateY(-2px)] active:[transform:translateY(0)]" id="pbBtn">
      <span class="pb-ring absolute inset-0 rounded-[inherit] bg-[#f43f5e] z-0 [animation:pbPulse_2s_ease-out_infinite]" aria-hidden="true"></span>
      <span class="pb-ring absolute inset-0 rounded-[inherit] bg-[#f43f5e] z-0 [animation:pbPulse_2s_ease-out_infinite] [animation-delay:1s]" aria-hidden="true"></span>
      <span class="pb-label relative z-[1]">● Go live</span>
    </button>
  </div>
  <script>
    var btn = document.getElementById('pbBtn');
    var label = btn.querySelector('.pb-label');
    var live = true;
    
    // Toggle the live state: clicking stops the pulse and flips the label, like
    // starting/stopping a broadcast.
    btn.addEventListener('click', function () {
      live = !live;
      btn.classList.toggle('stopped', !live);
      label.textContent = live ? '● Go live' : '■ Stop';
      btn.style.background = live
        ? 'linear-gradient(120deg,#e11d48,#f43f5e)'
        : 'linear-gradient(120deg,#334155,#475569)';
      btn.style.boxShadow = live ? '0 10px 30px -8px rgba(244,63,94,.6)' : 'none';
    });
    
    // Pause rings when off-screen to avoid needless compositing.
    var io = new IntersectionObserver(function (entries) {
      entries.forEach(function (e) {
        btn.querySelectorAll('.pb-ring').forEach(function (r) {
          r.style.animationPlayState = (e.isIntersecting && live) ? 'running' : 'paused';
        });
      });
    }, { threshold: 0 });
    io.observe(btn);
  </script>
</body>
</html>
React
import React, { useEffect } from 'react';

// CSS — optionally move to PulseButton.module.css
const css = `
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a14;display:flex;justify-content:center;align-items:center;min-height:100vh}

.pb-stage{padding:50px}
.pb-btn{position:relative;border:none;border-radius:14px;padding:15px 30px;font-family:inherit;font-size:15px;font-weight:800;color:#fff;cursor:pointer;background:linear-gradient(120deg,#e11d48,#f43f5e);box-shadow:0 10px 30px -8px rgba(244,63,94,.6);transition:transform .15s}
.pb-btn:hover{transform:translateY(-2px)}
.pb-btn:active{transform:translateY(0)}
.pb-label{position:relative;z-index:1}

/* Two expanding rings emit from the button on a staggered loop, fading as they
   grow — a sonar pulse that signals "live" or "act now". */
.pb-ring{position:absolute;inset:0;border-radius:inherit;background:#f43f5e;z-index:0;animation:pbPulse 2s ease-out infinite}
.pb-ring2{animation-delay:1s}
@keyframes pbPulse{0%{transform:scale(1);opacity:.55}100%{transform:scale(1.7);opacity:0}}

.pb-btn.stopped .pb-ring{animation-play-state:paused;opacity:0}
`;

export default function PulseButton() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var btn = document.getElementById('pbBtn');
    var label = btn.querySelector('.pb-label');
    var live = true;
    
    // Toggle the live state: clicking stops the pulse and flips the label, like
    // starting/stopping a broadcast.
    btn.addEventListener('click', function () {
      live = !live;
      btn.classList.toggle('stopped', !live);
      label.textContent = live ? '● Go live' : '■ Stop';
      btn.style.background = live
        ? 'linear-gradient(120deg,#e11d48,#f43f5e)'
        : 'linear-gradient(120deg,#334155,#475569)';
      btn.style.boxShadow = live ? '0 10px 30px -8px rgba(244,63,94,.6)' : 'none';
    });
    
    // Pause rings when off-screen to avoid needless compositing.
    var io = new IntersectionObserver(function (entries) {
      entries.forEach(function (e) {
        btn.querySelectorAll('.pb-ring').forEach(function (r) {
          r.style.animationPlayState = (e.isIntersecting && live) ? 'running' : 'paused';
        });
      });
    }, { threshold: 0 });
    io.observe(btn);
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{css}</style>
      <div className="pb-stage">
        <button type="button" className="pb-btn" id="pbBtn">
          <span className="pb-ring" aria-hidden="true"></span>
          <span className="pb-ring pb-ring2" aria-hidden="true"></span>
          <span className="pb-label">● Go live</span>
        </button>
      </div>
    </>
  );
}
React + Tailwind
import React, { useEffect } from 'react';
// Requires Tailwind CSS v3+ — https://tailwindcss.com/docs/installation
// Arbitrary value classes (e.g. bg-[#0f172a]) are valid Tailwind v3+

export default function PulseButton() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var btn = document.getElementById('pbBtn');
    var label = btn.querySelector('.pb-label');
    var live = true;
    
    // Toggle the live state: clicking stops the pulse and flips the label, like
    // starting/stopping a broadcast.
    btn.addEventListener('click', function () {
      live = !live;
      btn.classList.toggle('stopped', !live);
      label.textContent = live ? '● Go live' : '■ Stop';
      btn.style.background = live
        ? 'linear-gradient(120deg,#e11d48,#f43f5e)'
        : 'linear-gradient(120deg,#334155,#475569)';
      btn.style.boxShadow = live ? '0 10px 30px -8px rgba(244,63,94,.6)' : 'none';
    });
    
    // Pause rings when off-screen to avoid needless compositing.
    var io = new IntersectionObserver(function (entries) {
      entries.forEach(function (e) {
        btn.querySelectorAll('.pb-ring').forEach(function (r) {
          r.style.animationPlayState = (e.isIntersecting && live) ? 'running' : 'paused';
        });
      });
    }, { threshold: 0 });
    io.observe(btn);
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{`
@keyframes pbPulse{0%{transform:scale(1);opacity:.55}100%{transform:scale(1.7);opacity:0}}

* {
  box-sizing:border-box;margin:0;padding:0
}

body {
  font-family:system-ui,-apple-system,sans-serif;background:#0a0a14;display:flex;justify-content:center;align-items:center;min-height:100vh
}

.pb-btn.stopped .pb-ring {
  animation-play-state:paused;opacity:0
}
      `}</style>
      <div className="p-[50px]">
        <button type="button" className="pb-btn relative border-0 rounded-[14px] py-[15px] px-[30px] font-[inherit] text-[15px] font-extrabold text-[#fff] cursor-pointer [background:linear-gradient(120deg,#e11d48,#f43f5e)] shadow-[0_10px_30px_-8px_rgba(244,63,94,.6)] [transition:transform_.15s] hover:[transform:translateY(-2px)] active:[transform:translateY(0)]" id="pbBtn">
          <span className="pb-ring absolute inset-0 rounded-[inherit] bg-[#f43f5e] z-0 [animation:pbPulse_2s_ease-out_infinite]" aria-hidden="true"></span>
          <span className="pb-ring absolute inset-0 rounded-[inherit] bg-[#f43f5e] z-0 [animation:pbPulse_2s_ease-out_infinite] [animation-delay:1s]" aria-hidden="true"></span>
          <span className="pb-label relative z-[1]">● Go live</span>
        </button>
      </div>
    </>
  );
}
Vue
<template>
  <div class="pb-stage">
    <button type="button" class="pb-btn" id="pbBtn">
      <span class="pb-ring" aria-hidden="true"></span>
      <span class="pb-ring pb-ring2" aria-hidden="true"></span>
      <span class="pb-label">● Go live</span>
    </button>
  </div>
</template>

<script setup>
import { onMounted } from 'vue';

let btn;
let label;
var live = true;
let io;

onMounted(() => {
  btn = document.getElementById('pbBtn');
  label = btn.querySelector('.pb-label');
  // Toggle the live state: clicking stops the pulse and flips the label, like
  // starting/stopping a broadcast.
  btn.addEventListener('click', function () {
    live = !live;
    btn.classList.toggle('stopped', !live);
    label.textContent = live ? '● Go live' : '■ Stop';
    btn.style.background = live
      ? 'linear-gradient(120deg,#e11d48,#f43f5e)'
      : 'linear-gradient(120deg,#334155,#475569)';
    btn.style.boxShadow = live ? '0 10px 30px -8px rgba(244,63,94,.6)' : 'none';
  });
  io = new IntersectionObserver(function (entries) {
    entries.forEach(function (e) {
      btn.querySelectorAll('.pb-ring').forEach(function (r) {
        r.style.animationPlayState = (e.isIntersecting && live) ? 'running' : 'paused';
      });
    });
  }, { threshold: 0 });
  io.observe(btn);
});
</script>

<style scoped>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a14;display:flex;justify-content:center;align-items:center;min-height:100vh}

.pb-stage{padding:50px}
.pb-btn{position:relative;border:none;border-radius:14px;padding:15px 30px;font-family:inherit;font-size:15px;font-weight:800;color:#fff;cursor:pointer;background:linear-gradient(120deg,#e11d48,#f43f5e);box-shadow:0 10px 30px -8px rgba(244,63,94,.6);transition:transform .15s}
.pb-btn:hover{transform:translateY(-2px)}
.pb-btn:active{transform:translateY(0)}
.pb-label{position:relative;z-index:1}

/* Two expanding rings emit from the button on a staggered loop, fading as they
   grow — a sonar pulse that signals "live" or "act now". */
.pb-ring{position:absolute;inset:0;border-radius:inherit;background:#f43f5e;z-index:0;animation:pbPulse 2s ease-out infinite}
.pb-ring2{animation-delay:1s}
@keyframes pbPulse{0%{transform:scale(1);opacity:.55}100%{transform:scale(1.7);opacity:0}}

.pb-btn.stopped .pb-ring{animation-play-state:paused;opacity:0}
</style>
Angular
// @ts-nocheck
// Note: vanilla JS DOM manipulation is preserved as-is inside ngAfterViewInit().
// For idiomatic Angular, replace document.getElementById() with @ViewChild() refs
// and move state into component properties with two-way binding.
import { Component, AfterViewInit, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-pulse-button',
  standalone: true,
  imports: [CommonModule],
  encapsulation: ViewEncapsulation.None,
  template: `
    <div class="pb-stage">
      <button type="button" class="pb-btn" id="pbBtn">
        <span class="pb-ring" aria-hidden="true"></span>
        <span class="pb-ring pb-ring2" aria-hidden="true"></span>
        <span class="pb-label">● Go live</span>
      </button>
    </div>
  `,
  styles: [`
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a14;display:flex;justify-content:center;align-items:center;min-height:100vh}
    
    .pb-stage{padding:50px}
    .pb-btn{position:relative;border:none;border-radius:14px;padding:15px 30px;font-family:inherit;font-size:15px;font-weight:800;color:#fff;cursor:pointer;background:linear-gradient(120deg,#e11d48,#f43f5e);box-shadow:0 10px 30px -8px rgba(244,63,94,.6);transition:transform .15s}
    .pb-btn:hover{transform:translateY(-2px)}
    .pb-btn:active{transform:translateY(0)}
    .pb-label{position:relative;z-index:1}
    
    /* Two expanding rings emit from the button on a staggered loop, fading as they
       grow — a sonar pulse that signals "live" or "act now". */
    .pb-ring{position:absolute;inset:0;border-radius:inherit;background:#f43f5e;z-index:0;animation:pbPulse 2s ease-out infinite}
    .pb-ring2{animation-delay:1s}
    @keyframes pbPulse{0%{transform:scale(1);opacity:.55}100%{transform:scale(1.7);opacity:0}}
    
    .pb-btn.stopped .pb-ring{animation-play-state:paused;opacity:0}
  `]
})
export class PulseButtonComponent implements AfterViewInit {
  ngAfterViewInit(): void {
    var btn = document.getElementById('pbBtn');
    var label = btn.querySelector('.pb-label');
    var live = true;
    
    // Toggle the live state: clicking stops the pulse and flips the label, like
    // starting/stopping a broadcast.
    btn.addEventListener('click', function () {
      live = !live;
      btn.classList.toggle('stopped', !live);
      label.textContent = live ? '● Go live' : '■ Stop';
      btn.style.background = live
        ? 'linear-gradient(120deg,#e11d48,#f43f5e)'
        : 'linear-gradient(120deg,#334155,#475569)';
      btn.style.boxShadow = live ? '0 10px 30px -8px rgba(244,63,94,.6)' : 'none';
    });
    
    // Pause rings when off-screen to avoid needless compositing.
    var io = new IntersectionObserver(function (entries) {
      entries.forEach(function (e) {
        btn.querySelectorAll('.pb-ring').forEach(function (r) {
          r.style.animationPlayState = (e.isIntersecting && live) ? 'running' : 'paused';
        });
      });
    }, { threshold: 0 });
    io.observe(btn);
  }
}