Pure CSSMIT licensed

Heartbeat Pulse Ripple

A heartbeat is not an even throb, it is lub-dub: two beats close together, then a longer gap. That asymmetry lives entirely in the keyframe percentages. Rings leave the sensor point in the same two-beat envelope, laid over a clinical trace panel where the beat interval is stated in numbers as well as motion.

Live Demo
Try it

Build UI like this from scratch: Flexbox, Grid, animations & responsive CSS

ZTM · The CSS Bootcamp · 30‑day money-back guarantee · Affiliate link

10% off at checkout with code
Check ZTM Course

The code

<section class="rpl-15" aria-labelledby="rpl-15-title">
  <input class="rpl-15__theme-input" type="checkbox" id="rpl-15-theme">
  <label class="rpl-15__theme" for="rpl-15-theme">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor" stroke="none"/></svg>
    <span class="rpl-15__theme-sr">Switch between dark and light theme</span>
  </label>
  <div class="rpl-15__card">
    <header class="rpl-15__top">
      <p class="rpl-15__eyebrow">Continuous monitor</p>
      <h1 class="rpl-15__h1" id="rpl-15-title">Resting heart rate</h1>
    </header>
    <div class="rpl-15__read">
      <div class="rpl-15__pulse" aria-hidden="true">
        <span class="rpl-15__beat"></span>
        <span class="rpl-15__beat rpl-15__beat--two"></span>
        <span class="rpl-15__heart"></span>
      </div>
      <div class="rpl-15__figure">
        <span class="rpl-15__bpm">58</span>
        <span class="rpl-15__unit">bpm</span>
        <span class="rpl-15__delta">4 below your 7-day average</span>
      </div>
    </div>
    <div class="rpl-15__trace" aria-hidden="true">
      <svg viewBox="0 0 320 60" preserveAspectRatio="none"><path d="M0 42h34l7-6 6 18 8-38 7 26h30l7-6 6 18 8-38 7 26h30l7-6 6 18 8-38 7 26h30l7-6 6 18 8-38 7 26h44" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>
    </div>
    <div class="rpl-15__grid">
      <div class="rpl-15__cell"><span class="rpl-15__cn">41</span><span class="rpl-15__cl">ms HRV</span></div>
      <div class="rpl-15__cell"><span class="rpl-15__cn">96%</span><span class="rpl-15__cl">SpO₂</span></div>
      <div class="rpl-15__cell"><span class="rpl-15__cn">7h 12m</span><span class="rpl-15__cl">sleep</span></div>
    </div>
  </div>
</section>
.rpl-15 {
  --rpl-15-bg: #fbf7f5;
  --rpl-15-bg: oklch(0.978 0.006 30);
  --rpl-15-card: #ffffff;
  --rpl-15-card: oklch(1 0 0);
  --rpl-15-line: #ece3e0;
  --rpl-15-line: oklch(0.918 0.010 30);
  --rpl-15-fg: #1b1414;
  --rpl-15-fg: oklch(0.22 0.014 25);
  --rpl-15-muted: #665a5a;
  --rpl-15-muted: oklch(0.505 0.016 25);
  --rpl-15-accent: #c92841;
  --rpl-15-accent: oklch(0.54 0.20 20);
  --rpl-15-cycle: 1.4s;
  position: relative;
  display: grid;
  place-items: center;
  min-block-size: 100vh;
  min-block-size: 100svh;
  padding: clamp(16px, 4vw, 48px);
  background: var(--rpl-15-bg);
  color: var(--rpl-15-fg);
  font-family: "Helvetica Neue", -apple-system, "Segoe UI", system-ui, sans-serif;
  box-sizing: border-box;
}

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

@media (prefers-color-scheme: dark) {
  .rpl-15:not(:has(#rpl-15-theme:checked)) {
    --rpl-15-bg: #120e0e;
    --rpl-15-bg: oklch(0.185 0.010 25);
    --rpl-15-card: #1c1616;
    --rpl-15-card: oklch(0.24 0.012 25);
    --rpl-15-line: #2e2626;
    --rpl-15-line: oklch(0.315 0.012 25);
    --rpl-15-fg: #f7f0ef;
    --rpl-15-fg: oklch(0.965 0.008 30);
    --rpl-15-muted: #a4938f;
    --rpl-15-muted: oklch(0.725 0.016 30);
    --rpl-15-accent: #ff6b7d;
    --rpl-15-accent: oklch(0.74 0.16 18);
  }
}

@media (prefers-color-scheme: light) {
  .rpl-15:has(#rpl-15-theme:checked) {
    --rpl-15-bg: #120e0e;
    --rpl-15-bg: oklch(0.185 0.010 25);
    --rpl-15-card: #1c1616;
    --rpl-15-card: oklch(0.24 0.012 25);
    --rpl-15-line: #2e2626;
    --rpl-15-line: oklch(0.315 0.012 25);
    --rpl-15-fg: #f7f0ef;
    --rpl-15-fg: oklch(0.965 0.008 30);
    --rpl-15-muted: #a4938f;
    --rpl-15-muted: oklch(0.725 0.016 30);
    --rpl-15-accent: #ff6b7d;
    --rpl-15-accent: oklch(0.74 0.16 18);
  }
}

.rpl-15__card {
  inline-size: 100%;
  max-inline-size: 460px;
  display: flex;
  flex-direction: column;
  gap: 22px;
  padding: clamp(24px, 4vw, 34px);
  border: 1px solid var(--rpl-15-line);
  border-radius: 28px;
  background: var(--rpl-15-card);
}

.rpl-15__top {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.rpl-15__eyebrow {
  margin: 0;
  font-size: 10.5px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--rpl-15-accent);
  font-weight: 600;
}

.rpl-15__h1 {
  margin: 0;
  font-size: clamp(21px, 3.2vw, 26px);
  font-weight: 600;
  letter-spacing: -0.022em;
}

.rpl-15__read {
  display: flex;
  align-items: center;
  gap: clamp(18px, 4vw, 30px);
}

.rpl-15__pulse {
  position: relative;
  display: grid;
  place-items: center;
  inline-size: 104px;
  block-size: 104px;
  flex: none;
}

.rpl-15__heart {
  inline-size: 34%;
  aspect-ratio: 1;
  rotate: -45deg;
  background: var(--rpl-15-accent);
}

.rpl-15__heart::before,
.rpl-15__heart::after {
  content: "";
  position: absolute;
  inline-size: 100%;
  block-size: 100%;
  border-radius: 50%;
  background: var(--rpl-15-accent);
}

.rpl-15__heart::before {
  inset-block-start: -50%;
  inset-inline-start: 0;
}

.rpl-15__heart::after {
  inset-block-start: 0;
  inset-inline-start: 50%;
}
/* lub-dub: two rings from one cycle, the second fired at 26% with a shorter reach */

.rpl-15__beat {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  inline-size: 104px;
  aspect-ratio: 1;
  border: 2px solid var(--rpl-15-accent);
  border-radius: 50%;
  animation: rpl-15-lub var(--rpl-15-cycle) cubic-bezier(0.12, 0.7, 0.3, 1) infinite;
}

.rpl-15__beat--two {
  animation-name: rpl-15-dub;
}

@keyframes rpl-15-lub {
  0% {
    transform: translate(-50%, -50%) scale(0.24);
    opacity: 0.7;
  }

  70% {
    transform: translate(-50%, -50%) scale(1);
    opacity: 0;
  }

  100% {
    transform: translate(-50%, -50%) scale(1);
    opacity: 0;
  }
}

@keyframes rpl-15-dub {
  0%,
    24% {
    transform: translate(-50%, -50%) scale(0.24);
    opacity: 0;
  }

  26% {
    transform: translate(-50%, -50%) scale(0.24);
    opacity: 0.45;
  }

  80% {
    transform: translate(-50%, -50%) scale(0.78);
    opacity: 0;
  }

  100% {
    transform: translate(-50%, -50%) scale(0.78);
    opacity: 0;
  }
}

.rpl-15__figure {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 8px;
  min-inline-size: 0;
}

.rpl-15__bpm {
  font-size: clamp(52px, 11vw, 74px);
  font-weight: 300;
  letter-spacing: -0.05em;
  line-height: 0.9;
  font-variant-numeric: tabular-nums;
}

.rpl-15__unit {
  font-size: 15px;
  color: var(--rpl-15-muted);
}

.rpl-15__delta {
  inline-size: 100%;
  font-size: 12.5px;
  color: var(--rpl-15-muted);
}

.rpl-15__trace {
  color: var(--rpl-15-accent);
  opacity: 0.55;
}

.rpl-15__trace svg {
  inline-size: 100%;
  block-size: 56px;
  display: block;
}

.rpl-15__grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 10px;
}

.rpl-15__cell {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 14px;
  border: 1px solid var(--rpl-15-line);
  border-radius: 16px;
}

.rpl-15__cn {
  font-size: 17px;
  font-weight: 600;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
}

.rpl-15__cl {
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--rpl-15-muted);
}

@media (prefers-reduced-motion: reduce) {
  .rpl-15__beat,
    .rpl-15__beat--two {
    animation: none;
    opacity: 0.2;
    transform: translate(-50%, -50%) scale(0.55);
  }
}

.rpl-15 {
  position: relative;
}

.rpl-15__theme-input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.rpl-15__theme {
  position: absolute;
  z-index: 60;
  inset-block-start: 14px;
  inset-inline-end: 14px;
  display: grid;
  place-items: center;
  inline-size: 44px;
  block-size: 44px;
  border-radius: 999px;
  border: 1px solid color-mix(in oklab, currentColor 22%, transparent);
  background: color-mix(in oklab, currentColor 8%, transparent);
  color: inherit;
  cursor: pointer;
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  transition: background 160ms ease, border-color 160ms ease, transform 160ms ease;
}

.rpl-15__theme:hover {
  background: color-mix(in oklab, currentColor 15%, transparent);
  border-color: color-mix(in oklab, currentColor 38%, transparent);
}

.rpl-15__theme:active {
  transform: scale(0.94);
}

.rpl-15__theme svg {
  inline-size: 19px;
  block-size: 19px;
  display: block;
  transition: transform 320ms cubic-bezier(0.2, 0.7, 0.3, 1);
}

.rpl-15__theme-input:checked + .rpl-15__theme svg {
  transform: rotate(180deg);
}

.rpl-15__theme-input:focus-visible + .rpl-15__theme {
  outline: 2px solid currentColor;
  outline-offset: 3px;
}

.rpl-15__theme-sr {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .rpl-15__theme,
    .rpl-15__theme svg {
    transition: none;
  }
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Ripple Effect from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Heartbeat Pulse Ripple
Source: https://codefronts.com/motion/css-ripple-designs/css-heartbeat-pulse-ripple-effect/

A heartbeat is not an even throb, it is lub-dub: two beats close together, then a longer gap. That asymmetry lives entirely in the keyframe percentages. Rings leave the sensor point in the same two-beat envelope, laid over a clinical trace panel where the beat interval is stated in numbers as well as motion.
## HTML
```html
<section class="rpl-15" aria-labelledby="rpl-15-title">
  <input class="rpl-15__theme-input" type="checkbox" id="rpl-15-theme">
  <label class="rpl-15__theme" for="rpl-15-theme">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor" stroke="none"/></svg>
    <span class="rpl-15__theme-sr">Switch between dark and light theme</span>
  </label>
  <div class="rpl-15__card">
    <header class="rpl-15__top">
      <p class="rpl-15__eyebrow">Continuous monitor</p>
      <h1 class="rpl-15__h1" id="rpl-15-title">Resting heart rate</h1>
    </header>
    <div class="rpl-15__read">
      <div class="rpl-15__pulse" aria-hidden="true">
        <span class="rpl-15__beat"></span>
        <span class="rpl-15__beat rpl-15__beat--two"></span>
        <span class="rpl-15__heart"></span>
      </div>
      <div class="rpl-15__figure">
        <span class="rpl-15__bpm">58</span>
        <span class="rpl-15__unit">bpm</span>
        <span class="rpl-15__delta">4 below your 7-day average</span>
      </div>
    </div>
    <div class="rpl-15__trace" aria-hidden="true">
      <svg viewBox="0 0 320 60" preserveAspectRatio="none"><path d="M0 42h34l7-6 6 18 8-38 7 26h30l7-6 6 18 8-38 7 26h30l7-6 6 18 8-38 7 26h30l7-6 6 18 8-38 7 26h44" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>
    </div>
    <div class="rpl-15__grid">
      <div class="rpl-15__cell"><span class="rpl-15__cn">41</span><span class="rpl-15__cl">ms HRV</span></div>
      <div class="rpl-15__cell"><span class="rpl-15__cn">96%</span><span class="rpl-15__cl">SpO₂</span></div>
      <div class="rpl-15__cell"><span class="rpl-15__cn">7h 12m</span><span class="rpl-15__cl">sleep</span></div>
    </div>
  </div>
</section>
```
## CSS
```css
.rpl-15 {
  --rpl-15-bg: #fbf7f5;
  --rpl-15-bg: oklch(0.978 0.006 30);
  --rpl-15-card: #ffffff;
  --rpl-15-card: oklch(1 0 0);
  --rpl-15-line: #ece3e0;
  --rpl-15-line: oklch(0.918 0.010 30);
  --rpl-15-fg: #1b1414;
  --rpl-15-fg: oklch(0.22 0.014 25);
  --rpl-15-muted: #665a5a;
  --rpl-15-muted: oklch(0.505 0.016 25);
  --rpl-15-accent: #c92841;
  --rpl-15-accent: oklch(0.54 0.20 20);
  --rpl-15-cycle: 1.4s;
  position: relative;
  display: grid;
  place-items: center;
  min-block-size: 100vh;
  min-block-size: 100svh;
  padding: clamp(16px, 4vw, 48px);
  background: var(--rpl-15-bg);
  color: var(--rpl-15-fg);
  font-family: "Helvetica Neue", -apple-system, "Segoe UI", system-ui, sans-serif;
  box-sizing: border-box;
}

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

@media (prefers-color-scheme: dark) {
  .rpl-15:not(:has(#rpl-15-theme:checked)) {
    --rpl-15-bg: #120e0e;
    --rpl-15-bg: oklch(0.185 0.010 25);
    --rpl-15-card: #1c1616;
    --rpl-15-card: oklch(0.24 0.012 25);
    --rpl-15-line: #2e2626;
    --rpl-15-line: oklch(0.315 0.012 25);
    --rpl-15-fg: #f7f0ef;
    --rpl-15-fg: oklch(0.965 0.008 30);
    --rpl-15-muted: #a4938f;
    --rpl-15-muted: oklch(0.725 0.016 30);
    --rpl-15-accent: #ff6b7d;
    --rpl-15-accent: oklch(0.74 0.16 18);
  }
}

@media (prefers-color-scheme: light) {
  .rpl-15:has(#rpl-15-theme:checked) {
    --rpl-15-bg: #120e0e;
    --rpl-15-bg: oklch(0.185 0.010 25);
    --rpl-15-card: #1c1616;
    --rpl-15-card: oklch(0.24 0.012 25);
    --rpl-15-line: #2e2626;
    --rpl-15-line: oklch(0.315 0.012 25);
    --rpl-15-fg: #f7f0ef;
    --rpl-15-fg: oklch(0.965 0.008 30);
    --rpl-15-muted: #a4938f;
    --rpl-15-muted: oklch(0.725 0.016 30);
    --rpl-15-accent: #ff6b7d;
    --rpl-15-accent: oklch(0.74 0.16 18);
  }
}

.rpl-15__card {
  inline-size: 100%;
  max-inline-size: 460px;
  display: flex;
  flex-direction: column;
  gap: 22px;
  padding: clamp(24px, 4vw, 34px);
  border: 1px solid var(--rpl-15-line);
  border-radius: 28px;
  background: var(--rpl-15-card);
}

.rpl-15__top {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.rpl-15__eyebrow {
  margin: 0;
  font-size: 10.5px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--rpl-15-accent);
  font-weight: 600;
}

.rpl-15__h1 {
  margin: 0;
  font-size: clamp(21px, 3.2vw, 26px);
  font-weight: 600;
  letter-spacing: -0.022em;
}

.rpl-15__read {
  display: flex;
  align-items: center;
  gap: clamp(18px, 4vw, 30px);
}

.rpl-15__pulse {
  position: relative;
  display: grid;
  place-items: center;
  inline-size: 104px;
  block-size: 104px;
  flex: none;
}

.rpl-15__heart {
  inline-size: 34%;
  aspect-ratio: 1;
  rotate: -45deg;
  background: var(--rpl-15-accent);
}

.rpl-15__heart::before,
.rpl-15__heart::after {
  content: "";
  position: absolute;
  inline-size: 100%;
  block-size: 100%;
  border-radius: 50%;
  background: var(--rpl-15-accent);
}

.rpl-15__heart::before {
  inset-block-start: -50%;
  inset-inline-start: 0;
}

.rpl-15__heart::after {
  inset-block-start: 0;
  inset-inline-start: 50%;
}
/* lub-dub: two rings from one cycle, the second fired at 26% with a shorter reach */

.rpl-15__beat {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  inline-size: 104px;
  aspect-ratio: 1;
  border: 2px solid var(--rpl-15-accent);
  border-radius: 50%;
  animation: rpl-15-lub var(--rpl-15-cycle) cubic-bezier(0.12, 0.7, 0.3, 1) infinite;
}

.rpl-15__beat--two {
  animation-name: rpl-15-dub;
}

@keyframes rpl-15-lub {
  0% {
    transform: translate(-50%, -50%) scale(0.24);
    opacity: 0.7;
  }

  70% {
    transform: translate(-50%, -50%) scale(1);
    opacity: 0;
  }

  100% {
    transform: translate(-50%, -50%) scale(1);
    opacity: 0;
  }
}

@keyframes rpl-15-dub {
  0%,
    24% {
    transform: translate(-50%, -50%) scale(0.24);
    opacity: 0;
  }

  26% {
    transform: translate(-50%, -50%) scale(0.24);
    opacity: 0.45;
  }

  80% {
    transform: translate(-50%, -50%) scale(0.78);
    opacity: 0;
  }

  100% {
    transform: translate(-50%, -50%) scale(0.78);
    opacity: 0;
  }
}

.rpl-15__figure {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 8px;
  min-inline-size: 0;
}

.rpl-15__bpm {
  font-size: clamp(52px, 11vw, 74px);
  font-weight: 300;
  letter-spacing: -0.05em;
  line-height: 0.9;
  font-variant-numeric: tabular-nums;
}

.rpl-15__unit {
  font-size: 15px;
  color: var(--rpl-15-muted);
}

.rpl-15__delta {
  inline-size: 100%;
  font-size: 12.5px;
  color: var(--rpl-15-muted);
}

.rpl-15__trace {
  color: var(--rpl-15-accent);
  opacity: 0.55;
}

.rpl-15__trace svg {
  inline-size: 100%;
  block-size: 56px;
  display: block;
}

.rpl-15__grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 10px;
}

.rpl-15__cell {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 14px;
  border: 1px solid var(--rpl-15-line);
  border-radius: 16px;
}

.rpl-15__cn {
  font-size: 17px;
  font-weight: 600;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
}

.rpl-15__cl {
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--rpl-15-muted);
}

@media (prefers-reduced-motion: reduce) {
  .rpl-15__beat,
    .rpl-15__beat--two {
    animation: none;
    opacity: 0.2;
    transform: translate(-50%, -50%) scale(0.55);
  }
}

.rpl-15 {
  position: relative;
}

.rpl-15__theme-input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.rpl-15__theme {
  position: absolute;
  z-index: 60;
  inset-block-start: 14px;
  inset-inline-end: 14px;
  display: grid;
  place-items: center;
  inline-size: 44px;
  block-size: 44px;
  border-radius: 999px;
  border: 1px solid color-mix(in oklab, currentColor 22%, transparent);
  background: color-mix(in oklab, currentColor 8%, transparent);
  color: inherit;
  cursor: pointer;
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  transition: background 160ms ease, border-color 160ms ease, transform 160ms ease;
}

.rpl-15__theme:hover {
  background: color-mix(in oklab, currentColor 15%, transparent);
  border-color: color-mix(in oklab, currentColor 38%, transparent);
}

.rpl-15__theme:active {
  transform: scale(0.94);
}

.rpl-15__theme svg {
  inline-size: 19px;
  block-size: 19px;
  display: block;
  transition: transform 320ms cubic-bezier(0.2, 0.7, 0.3, 1);
}

.rpl-15__theme-input:checked + .rpl-15__theme svg {
  transform: rotate(180deg);
}

.rpl-15__theme-input:focus-visible + .rpl-15__theme {
  outline: 2px solid currentColor;
  outline-offset: 3px;
}

.rpl-15__theme-sr {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .rpl-15__theme,
    .rpl-15__theme svg {
    transition: none;
  }
}
```

How this works

Lub-dub is a percentage problem. At 72 beats per minute the cycle is roughly 830ms, and the two beats occupy only the first third of it: the first at 0 to 9 percent, the second at about 18 to 27 percent, then nothing until the cycle repeats. Space the two beats evenly across 0 and 50 percent instead and you get a metronome, which reads as a machine or a notification rather than a heart. The uneven stops are the entire difference.

The second beat is smaller. Diastole produces less displacement than systole, so the second ring should reach a smaller final scale and carry slightly less opacity. Making both beats identical is the other common miss — the rhythm is right but it sounds like two taps rather than one heartbeat, because the ear and the eye both use the amplitude drop to group them.

The rings have an origin, which is what keeps this a ripple. The sensor dot emits; the rings expand away from it and dissipate. A ring that scales in place with no source is a pulse, and a pulse cannot tell you where the beat came from. Here the origin is the same point the trace reads from, which ties the two halves of the panel together visually.

Vital signs must never be motion-only. The reading is printed — beats per minute, interval in milliseconds, last sample time — because a rhythm communicated only by animation is unavailable to anyone with reduced motion enabled, to a screen reader, and to anyone glancing at a screenshot. The animation is there because a heartbeat is easier to feel than to read, not because it carries the data.

Make it yours

  • Change --rpl-15-cycle to retime the whole panel; 600ms reads as tachycardia, 1.2s as resting bradycardia.
  • Move the second beat by editing the 18 and 27 percent stops; widening the gap immediately stops it reading as one heartbeat.
  • Lower the second ring's final scale further for a more pronounced amplitude drop between the beats.
  • Recolour with --rpl-15-ink; the trace stroke, rings and reading all derive from it.
  • Set --rpl-15-trace-dur independently if you want the trace to sweep at a different rate from the rings.
  • Remove the trace panel and keep the sensor dot as a compact live-vitals indicator.

Gotchas — read before shipping

  • Evenly spaced beats produce a metronome, not a heartbeat. The two beats belong in the first third of the cycle with a long rest after.
  • Identical amplitude on both beats makes them read as two separate taps; the second must be visibly smaller.
  • A ring that scales in place with no origin is a pulse. Emit from a source point or the effect says nothing about where the beat came from.
  • Vital-sign data communicated only through animation is invisible under reduced motion and to assistive technology. Print the numbers.
  • Fast heartbeat animations can approach the flash threshold. Keep the cycle above about 350ms so the beat rate stays under three per second.

Browser support

ChromeSafariFirefoxEdge
111+15.4+121+111+

oklch() and the :has() toggle set the floor. The beat envelope needs only multi-stop @keyframes, transform, opacity and SVG stroke-dashoffset — Baseline since Chrome 26 / Safari 9 / Firefox 16 — so the rhythm renders far below this floor. Engines without oklch() take the sRGB hex declared first; engines without :has() keep the base scheme and the printed reading is unaffected either way.

Techniques used in this demo

Search CodeFronts

Loading…