15 / 20
All 20 in 20 CSS Ripple Effects
- 01Ripple Origin from Click Coordinates
- 02Keeping Ripples Circular in Any Box
- 03Overlapping Ripples Without Stacking
- 04Ripple Easing That Feels Like Water
- 05Compositor Only Ripples at Sixty FPS
- 06Ripple Cleanup and Memory in Long Lists
- 07Ripple on Touch Without the Tap Delay
- 08Accessible Ripples and Reduced Motion
- 09Water Surface Click Ripple
- 10Material Ink Button Ripple
- 11Pond Background with Drifting Ripples
- 12Neon Glow Ripple Ring
- 13Ripple Loading Spinner
- 14Sonar Ping Status Ripple
- 15Heartbeat Pulse Ripple
- 16Ripple Card Hover Reveal
- 17Sound Wave Ripple Visualizer
- 18Liquid Drop Impact Ripple
- 19Ripple Login Form Interaction
- 20Aurora Ripple Background
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.
Build UI like this from scratch: Flexbox, Grid, animations & responsive CSS
ZTM · The CSS Bootcamp · 30‑day money-back guarantee · Affiliate link
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><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;
}
}.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;
}
}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;
}
}
```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-cycleto 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-durindependently 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
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 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.