How to Make a “Pulsing” Animation

A pulsing animation draws the eye without screaming for attention. Think about a live-status dot, a subtle beacon on a map pin, or a call-to-action that gently radiates. By the end of this guide, you will build a flexible, accessible, and smooth pulsing effect in pure CSS, with variants for small indicators and buttons. You will control timing, scale, and color with a few variables and keep everything performant with transform and opacity animations.

Why Pulsing Animations Matter

A pulse is a simple signal: something is active, new, or nearby. CSS handles this pattern well through keyframes, transforms, and opacity. You avoid extra JavaScript, dodgy GIFs, and layout thrash. A small, GPU-friendly scale-and-fade animation can run for hours without jank. CSS variables let you theme pulses per component, and pseudo-elements let you add animated rings without extra DOM nodes. You get a compact, portable pattern that fits anywhere from dashboards to landing pages.

Prerequisites

You will get more out of this guide if you are comfortable with basic HTML and CSS. We will use CSS variables, pseudo-elements, and media queries for reduced motion.

  • Basic HTML
  • CSS custom properties
  • CSS pseudo-elements (::before / ::after)

Step 1: The HTML Structure

The markup stays lean. We render a small pulsing dot for status and a pulsing button for a call-to-action. Both variants are purely decorative in structure; meaning comes from surrounding context (text labels, button text). Pseudo-elements will draw animated rings, so we keep the DOM clean.

<!-- HTML -->
<div class="stage">
  <!-- A tiny status pulse -->
  <div class="pulse-dot" aria-hidden="true"></div>

  <!-- A second instance to show staggered timing via CSS only -->
  <div class="pulse-dot pulse-dot--alt" aria-hidden="true"></div>

  <!-- A button with a subtle pulsing halo -->
  <button class="pulse-cta" type="button">
    Get Started
  </button>
</div>

The container .stage only helps with layout in the demo. The .pulse-dot elements hold a solid core and a radiating ring drawn with ::before and ::after. The .pulse-cta shows how to add the same animation idea to a larger, rounded rectangle without overwhelming the layout.

Step 2: The Basic CSS & Styling

Set up a simple theme with CSS variables. Keep the background dark so the pulse reads clearly. The .stage uses a small grid to showcase the components side by side.

/* CSS */
:root {
  --bg: #0f172a;               /* page background */
  --fg: #e2e8f0;               /* default text */
  --radius: 12px;

  /* Pulse theme */
  --pulse-color: #22d3ee;      /* core color */
  --pulse-size: 16px;          /* diameter of the dot */
  --pulse-speed: 1400ms;       /* animation duration */
  --pulse-scale: 2.25;         /* how far the ring expands */
  --ring-width: 3px;

  /* CTA theme */
  --cta-bg: #0ea5e9;
  --cta-text: #ffffff;
  --cta-pulse-color: #38bdf8;
  --cta-speed: 1800ms;
  --cta-scale: 1.6;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html, body {
  height: 100%;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
  display: grid;
  place-items: center;
}

.stage {
  display: grid;
  grid-template-columns: repeat(3, max-content);
  gap: 32px;
  align-items: center;
}

Advanced Tip: Use CSS variables for speed, scale, and color so you can tune each instance in a design system. You can speed up one pulse for alerts while keeping another gentle by overriding a single variable on that element.

Step 3: Building the Pulse Dot

The dot is a classic circular pulse. We draw a solid center, then a ring that expands and fades. Both are attached via pseudo-elements to avoid extra HTML. If you want a refresher on shape basics, review how to make a circle with CSS using border-radius.

/* CSS */
.pulse-dot {
  --pulse-color: var(--pulse-color);
  --pulse-size: var(--pulse-size);
  --pulse-speed: var(--pulse-speed);
  --pulse-scale: var(--pulse-scale);
  --ring-width: var(--ring-width);

  position: relative;
  width: var(--pulse-size);
  height: var(--pulse-size);
  color: var(--pulse-color);       /* drives currentColor */
  border-radius: 50%;
}

/* Solid core */
.pulse-dot::before {
  content: "";
  position: absolute;
  inset: 0;
  background: currentColor;
  border-radius: inherit;
  transform: scale(1);
  animation: pulse-core var(--pulse-speed) ease-in-out infinite;
}

/* Expanding ring */
.pulse-dot::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  border: var(--ring-width) solid currentColor;
  transform: scale(1);
  opacity: 0;
  animation: pulse-ring var(--pulse-speed) ease-out infinite;
}

/* Alternate instance with different timing and scale */
.pulse-dot--alt {
  --pulse-color: #f59e0b;     /* amber */
  --pulse-speed: 1000ms;
  --pulse-scale: 2.8;
}

/* Keyframes */
@keyframes pulse-core {
  0%, 100% { transform: scale(1); }
  50%      { transform: scale(0.85); }
}

@keyframes pulse-ring {
  0%   { transform: scale(1); opacity: 0.6; }
  70%  { transform: scale(var(--pulse-scale)); opacity: 0; }
  100% { opacity: 0; }
}

How This Works (Code Breakdown)

.pulse-dot sets width and height to the same value and rounds the corners to 50% to get a circle. Using color with currentColor keeps the core and ring in sync; changing –pulse-color changes both. The element becomes a small, reusable token that can be dropped anywhere.

The ::before pseudo-element fills the circle with a solid background and breathes with pulse-core. The scale dips slightly at 50%, which reads like a living signal without a harsh bounce. The ::after pseudo-element draws a ring using a border. It starts at scale(1) with 0.6 opacity, then expands to var(–pulse-scale) while fading to 0 in pulse-ring. The ring returns to its initial state when the keyframe loops, creating a repeating ripple.

The .pulse-dot–alt class shows how each instance can override variables for a unique rhythm or color. You can define a library of pulses with presets for duration and scale. For pill or tag shapes, a pulse can be elliptical. If you need a refresher on ellipses, see how to make an ellipse with CSS and carry the same keyframes into a wider element.

Step 4: Building the Pulsing CTA Badge

A button can carry a gentle pulsing halo that signals a primary action without screaming. The key is restraint: keep the ring thin and use a longer duration than the tiny dot. Add a clear focus style so keyboard users get a strong cue.

/* CSS */
.pulse-cta {
  position: relative;
  padding: 0.7rem 1rem;
  border: 0;
  border-radius: var(--radius);
  background: var(--cta-bg);
  color: var(--cta-text);
  font-weight: 600;
  letter-spacing: 0.2px;
  cursor: pointer;
  line-height: 1;
}

.pulse-cta::after {
  content: "";
  position: absolute;
  inset: -8px;                       /* ring outside the button */
  border-radius: inherit;
  border: 2px solid var(--cta-pulse-color);
  opacity: 0;
  transform: scale(1);
  animation: pulse-ring var(--cta-speed) ease-out infinite;
}

/* Focus styles matter */
.pulse-cta:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 2px;
}

/* Speed up and brighten on hover for feedback */
.pulse-cta:hover {
  filter: saturate(1.2);
}
.pulse-cta:hover::after {
  animation-duration: calc(var(--cta-speed) * 0.75);
  opacity: 0.7;
}

How This Works (Code Breakdown)

The button is a rounded rectangle with a background color and clear text. The halo sits on ::after with a negative inset so it renders outside the element bounds. Using the same pulse-ring keyframes keeps both components consistent. The longer default timing on the CTA produces a calmer glow, then the hover rule shortens the duration for immediate feedback.

Using border for the halo ring keeps drawing simple. Animating transform and opacity keeps the compositor happy and avoids layout recalculation. For a brand-forward variant, change –cta-pulse-color and –cta-bg, or apply an alternate text color for contrast.

Advanced Techniques: Variations and Controls

Once the base pulse works, you can build small variations without touching the DOM. Here are a few CSS-only upgrades that stay performant.

/* CSS */
/* 1) Stagger two rings on the same dot by splitting durations */
.pulse-dot.pulse-dot--double::after {
  animation: pulse-ring var(--pulse-speed) ease-out infinite,
             pulse-ring-delayed var(--pulse-speed) ease-out infinite;
}

@keyframes pulse-ring-delayed {
  0%   { transform: scale(1); opacity: 0; }
  35%  { transform: scale(1); opacity: 0.6; }           /* delayed start */
  100% { transform: scale(var(--pulse-scale)); opacity: 0; }
}

/* 2) Per-instance tuning via CSS variables on the element */
.pulse-dot[data-speed="slow"]   { --pulse-speed: 2000ms; }
.pulse-dot[data-speed="medium"] { --pulse-speed: 1400ms; }
.pulse-dot[data-speed="fast"]   { --pulse-speed: 900ms; }

/* 3) Responsive scaling of the pulse based on container size */
@container (min-width: 300px) {
  .pulse-dot { --pulse-scale: 2.8; }
}

/* 4) Add a heartbeat variant for fun iconography */
.pulse-dot.heartbeat::before {
  animation-name: heartbeat-core;
}
@keyframes heartbeat-core {
  0%, 100% { transform: scale(1); }
  20%      { transform: scale(0.78); }
  30%      { transform: scale(1); }
  40%      { transform: scale(0.9); }
  50%      { transform: scale(1); }
}

The first snippet layers a second ring using a second keyframe track with a delayed start. It creates a double ripple without extra HTML. The data-speed attribute provides a semantic slot for tuning the animation from markup. A container query scales the pulse expansion when the element sits in a roomier layout. For playful moments like a like-button, the heartbeat timing changes the pattern. Pair that idea with a custom shape, such as a heart built with CSS. If you want a reference, see how to make a heart with CSS and drop the same keyframes on it.

Accessibility & Performance

Designs with motion need guardrails. Small touches help readers avoid fatigue and keep the UI readable.

Accessibility

Decorative pulses should not steal focus. Add aria-hidden=”true” when the pulse does not convey new meaning on its own. If a pulse needs a name, place it on a focusable element that already has text, or provide an aria-label that describes the state (for example, “Online”). Respect motion preferences with a simple media query that either disables the animation or reduces the scale.

/* CSS */
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  .pulse-dot::before,
  .pulse-dot::after,
  .pulse-cta::after {
    animation: none !important;
    opacity: 1;                 /* keep visible */
    transform: scale(1);
  }
  /* Provide a static cue in place of motion */
  .pulse-cta::after {
    opacity: 0.25;              /* faint halo */
  }
}

Keyboard focus should never rely on animation. The button uses :focus-visible with a solid outline. That outline remains even when motion is reduced.

Performance

Stick to transform and opacity for pulsing. These properties promote well to the compositor and avoid layout changes or paint-heavy work. Avoid animating box-shadow size and blur, which tends to be costly across the screen. Keep durations long enough that the eye tracks the motion easily; a 1 to 2 second cycle usually reads well for background pulses. Limit simultaneous pulses on a page or stagger them with animation-delay so the GPU does not have to animate too many layers at once.

If a page needs dozens of pulses, consider reducing the number of rings or switching some to a static state at lower priorities. Share keyframes and rely on variables instead of duplicating animations, so the stylesheet stays compact and cache friendly.

Keep the Beat Going

You built a crisp pulsing dot and a calm button halo using CSS only. You learned how to tune timing, scale, and color per instance, and how to respect reduced motion without losing meaning. With this foundation, you can extend pulses to other shapes or sizes, from badges to custom icons, and create a motion language that guides attention with restraint.

Leave a Comment