25 CSS Number Counter Animations21 / 25

Light JSMIT licensed

Health Metrics & Heart Rate Animated Display

A vitals monitor where the animation itself carries the data: the heart glyph, the ECG sweep and the halo all beat at the patient's actual BPM because their animation-duration is computed from a registered custom property. Change one number and every rhythm in the panel retimes — the classic mistake here is animating at a fixed 1s and letting the visual lie about the reading.

Published Updated

Live Demo
Try it

The code

<section class="cnc-21" aria-label="Health metrics and heart rate animated display demo">
  <div class="cnc-21__stage">
    <article class="cnc-21__panel">
      <header class="cnc-21__head">
        <div>
          <p class="cnc-21__eyebrow">Live vitals · sample data</p>
          <h3 class="cnc-21__title">Morning zone 2 session</h3>
        </div>
        <span class="cnc-21__badge"><i aria-hidden="true"></i>Streaming</span>
      </header>
      <div class="cnc-21__hero">
        <span class="cnc-21__heart" aria-hidden="true">
          <svg viewBox="0 0 24 24" width="34" height="34"><path d="M12 21s-8-4.9-8-10.6A4.8 4.8 0 0 1 12 7a4.8 4.8 0 0 1 8 3.4C20 16.1 12 21 12 21z" fill="currentColor"/></svg>
        </span>
        <p class="cnc-21__bpm"><span class="cnc-21__num" aria-hidden="true">64</span><small>bpm</small></p>
        <p class="cnc-21__zone">Zone <b class="cnc-21__zonen">2</b><span class="cnc-21__zonel">Fat burn</span></p>
      </div>
      <div class="cnc-21__ecg" aria-hidden="true">
        <svg viewBox="0 0 600 120" preserveAspectRatio="none">
          <defs><path id="cnc-21-beat" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" d="M0 60 H18 l5 -8 l5 16 l5 -8 H40 l4 -32 l5 58 l5 -32 H70 l7 -13 l7 13 H100" /></defs>
          <g class="cnc-21__trace">
            <use href="#cnc-21-beat" x="0"/><use href="#cnc-21-beat" x="100"/><use href="#cnc-21-beat" x="200"/>
            <use href="#cnc-21-beat" x="300"/><use href="#cnc-21-beat" x="400"/><use href="#cnc-21-beat" x="500"/>
          </g>
        </svg>
      </div>
      <div class="cnc-21__zones" aria-hidden="true">
        <span data-z="1"></span><span data-z="2"></span><span data-z="3"></span><span data-z="4"></span>
        <i class="cnc-21__marker"></i>
      </div>
      <dl class="cnc-21__grid">
        <div><dt>HRV</dt><dd><span class="cnc-21__hrv">48</span><small>ms</small></dd></div>
        <div><dt>SpO₂</dt><dd><span class="cnc-21__spo">98</span><small>%</small></dd></div>
        <div><dt>Resting</dt><dd><span>54</span><small>bpm</small></dd></div>
        <div><dt>Calories</dt><dd><span class="cnc-21__kcal">128</span><small>kcal</small></dd></div>
      </dl>
      <p class="cnc-21__sr" role="status" aria-live="polite"></p>
    </article>
    <p class="cnc-21__chip" aria-hidden="true">@property &lt;number&gt; · animation-duration: calc(60s / var(--bpm)) · debounced live region</p>
  </div>
</section>
.cnc-21,
.cnc-21 *,
.cnc-21 *::before,
.cnc-21 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

@property --cnc-21-bpm {
  syntax: '<number>';
  inherits: true;
  initial-value: 64;
}

.cnc-21 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --cnc-21-bg: oklch(0.16 0.02 255);
  --cnc-21-card: oklch(0.21 0.026 258);
  --cnc-21-ink: oklch(0.97 0.005 250);
  --cnc-21-mut: oklch(0.68 0.02 258);
  --cnc-21-acc: oklch(0.70 0.20 18);
  --cnc-21-cool: oklch(0.80 0.14 195);
  background: var(--cnc-21-bg);
  color: var(--cnc-21-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}

.cnc-21__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 18px;
  padding: clamp(16px,4vw,44px);
}

.cnc-21__panel {
  --cnc-21-bpm: 64;
  --cnc-21-zp: 32;
  width: min(100%,460px);
  padding: clamp(18px,4vw,26px);
  border-radius: 26px;
  background: linear-gradient(180deg,color-mix(in oklch,var(--cnc-21-acc) 9%,var(--cnc-21-card)),var(--cnc-21-card));
  box-shadow: 0 0 0 1px oklch(1 0 0/.08),0 30px 70px oklch(0 0 0/.5);
  transition: --cnc-21-bpm 1.1s linear;
}

.cnc-21__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
}

.cnc-21__eyebrow {
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--cnc-21-mut);
}

.cnc-21__title {
  margin-top: 5px;
  font-size: 16px;
  font-weight: 660;
  letter-spacing: -.02em;
}

.cnc-21__badge {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 6px 11px;
  border-radius: 99px;
  font-size: 11px;
  font-weight: 640;
  color: var(--cnc-21-cool);
  background: color-mix(in oklch,var(--cnc-21-cool) 14%,transparent);
  white-space: nowrap;
}

.cnc-21__badge i {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: currentColor;
  animation: cnc-21-blip calc(60s / var(--cnc-21-bpm)) ease-in-out infinite;
}

@keyframes cnc-21-blip {
  0%,
    70%,
    100% {
    opacity: .35;
  }

  12% {
    opacity: 1;
  }
}

.cnc-21__hero {
  display: flex;
  align-items: center;
  gap: 16px;
  margin-top: 22px;
}

.cnc-21__heart {
  display: grid;
  place-items: center;
  width: 62px;
  height: 62px;
  border-radius: 50%;
  color: var(--cnc-21-acc);
  background: color-mix(in oklch,var(--cnc-21-acc) 16%,transparent);
  animation: cnc-21-beat calc(60s / var(--cnc-21-bpm)) cubic-bezier(.2,.8,.3,1) infinite;
}

@keyframes cnc-21-beat {
  0% {
    transform: scale(1);
    box-shadow: 0 0 0 0 color-mix(in oklch,var(--cnc-21-acc) 45%,transparent);
  }

  14% {
    transform: scale(1.14);
  }

  28% {
    transform: scale(1.02);
  }

  42% {
    transform: scale(1.08);
  }

  70%,
    100% {
    transform: scale(1);
    box-shadow: 0 0 0 22px color-mix(in oklch,var(--cnc-21-acc) 0%,transparent);
  }
}

.cnc-21__bpm {
  display: flex;
  align-items: baseline;
  gap: 6px;
  font-size: clamp(44px,10vw,62px);
  font-weight: 750;
  letter-spacing: -.055em;
  line-height: .9;
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}

.cnc-21__bpm small {
  font-size: 15px;
  font-weight: 640;
  letter-spacing: 0;
  color: var(--cnc-21-mut);
}

.cnc-21__num {
  display: inline-block;
  min-width: 3ch;
  text-align: right;
}

.cnc-21__zone {
  margin-left: auto;
  text-align: right;
  font-size: 12px;
  color: var(--cnc-21-mut);
  line-height: 1.5;
}

.cnc-21__zone b {
  display: block;
  font-size: 20px;
  color: var(--cnc-21-ink);
  font-weight: 730;
}

.cnc-21__zonel {
  display: block;
  font-size: 11.5px;
}

.cnc-21__ecg {
  position: relative;
  height: 96px;
  margin-top: 16px;
  border-radius: 14px;
  overflow: hidden;
  background: radial-gradient(120% 100% at 0% 50%,oklch(0.25 0.03 258),oklch(0.19 0.02 258));
  box-shadow: inset 0 0 0 1px oklch(1 0 0/.06);
}

.cnc-21__ecg svg {
  width: 100%;
  height: 100%;
  display: block;
}

.cnc-21__trace {
  color: var(--cnc-21-acc);
  filter: drop-shadow(0 0 6px color-mix(in oklch,var(--cnc-21-acc) 60%,transparent));
}

.cnc-21__ecg::after {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 26%;
  background: linear-gradient(90deg,transparent,oklch(1 0 0/.16),transparent);
  animation: cnc-21-sweep calc(60s / var(--cnc-21-bpm) * 6) linear infinite;
}

@keyframes cnc-21-sweep {
  from {
    transform: translateX(-100%);
  }

  to {
    transform: translateX(400%);
  }
}

.cnc-21__zones {
  position: relative;
  display: grid;
  grid-template-columns: repeat(4,1fr);
  gap: 4px;
  margin-top: 16px;
  height: 10px;
}

.cnc-21__zones span {
  border-radius: 99px;
  background: oklch(1 0 0/.12);
}

.cnc-21__zones span[data-z="1"] {
  background: color-mix(in oklch,var(--cnc-21-cool) 45%,transparent);
}

.cnc-21__zones span[data-z="2"] {
  background: color-mix(in oklch,oklch(0.85 0.17 145) 55%,transparent);
}

.cnc-21__zones span[data-z="3"] {
  background: color-mix(in oklch,oklch(0.84 0.16 80) 50%,transparent);
}

.cnc-21__zones span[data-z="4"] {
  background: color-mix(in oklch,var(--cnc-21-acc) 55%,transparent);
}

.cnc-21__marker {
  position: absolute;
  top: -5px;
  left: calc(var(--cnc-21-zp) * 1%);
  width: 4px;
  height: 20px;
  border-radius: 99px;
  background: var(--cnc-21-ink);
  box-shadow: 0 0 12px oklch(1 0 0/.55);
  transition: left .9s cubic-bezier(.16,1,.3,1);
}

.cnc-21__grid {
  display: grid;
  grid-template-columns: repeat(4,1fr);
  gap: 10px;
  margin-top: 20px;
  padding-top: 18px;
  border-top: 1px solid oklch(1 0 0/.09);
}

.cnc-21__grid dt {
  font-size: 10.5px;
  font-weight: 660;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--cnc-21-mut);
}

.cnc-21__grid dd {
  margin-top: 5px;
  font-size: 19px;
  font-weight: 720;
  letter-spacing: -.03em;
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}

.cnc-21__grid dd small {
  font-size: .58em;
  font-weight: 600;
  color: var(--cnc-21-mut);
  margin-left: 2px;
}

.cnc-21__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.cnc-21__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--cnc-21-mut);
  padding: 7px 14px;
  border: 1px solid oklch(1 0 0/.13);
  border-radius: 99px;
  text-align: center;
  max-width: min(100%,560px);
}

@media (prefers-reduced-motion: reduce) {
  .cnc-21 * {
    animation: none !important;
    transition-duration: .01ms !important;
  }
}
(() => {
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  const ZONES = [[0, 'Recovery'], [98, 'Fat burn'], [128, 'Cardio'], [158, 'Peak']];

  document.querySelectorAll('.cnc-21__panel').forEach((panel) => {
    if (panel.dataset.wired) return;
    panel.dataset.wired = '1';
    const numEl = panel.querySelector('.cnc-21__num');
    const zoneN = panel.querySelector('.cnc-21__zonen');
    const zoneL = panel.querySelector('.cnc-21__zonel');
    const hrvEl = panel.querySelector('.cnc-21__hrv');
    const spoEl = panel.querySelector('.cnc-21__spo');
    const kcalEl = panel.querySelector('.cnc-21__kcal');
    const sr = panel.querySelector('.cnc-21__sr');
    let bpm = 64, kcal = 128, settle = 0;

    const roll = (el, from, to, ms) => {
      if (reduce || from === to) { el.textContent = to; return; }
      const t0 = performance.now();
      const frame = (t) => {
        const p = Math.min(1, (t - t0) / ms);
        el.textContent = Math.round(from + (to - from) * (1 - Math.pow(1 - p, 3)));
        if (p < 1) requestAnimationFrame(frame);
      };
      requestAnimationFrame(frame);
    };

    const apply = (next) => {
      const prev = bpm; bpm = next;
      panel.style.setProperty('--cnc-21-bpm', bpm);          // one property retimes every rhythm
      panel.style.setProperty('--cnc-21-zp', Math.max(4, Math.min(96, ((bpm - 55) / 120) * 100)));
      roll(numEl, prev, bpm, 420);
      let zi = 0; ZONES.forEach((z, i) => { if (bpm >= z[0]) zi = i; });
      zoneN.textContent = zi + 1;
      zoneL.textContent = ZONES[zi][1];
      clearTimeout(settle);
      settle = setTimeout(() => { sr.textContent = 'Heart rate ' + bpm + ' beats per minute, zone ' + (zi + 1) + ', ' + ZONES[zi][1] + '.'; }, 1200);
    };

    apply(112);
    const id = setInterval(() => {
      if (document.visibilityState !== 'visible') return;
      apply(Math.max(96, Math.min(148, bpm + Math.round((Math.random() - 0.48) * 14))));
      hrvEl.textContent = 40 + Math.round(Math.random() * 18);
      spoEl.textContent = 96 + Math.round(Math.random() * 3);
      kcal += 2 + Math.round(Math.random() * 3);
      kcalEl.textContent = kcal;
    }, 2600);
    addEventListener('pagehide', () => clearInterval(id), { once: true });
  });
})();
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 Number Counter Animation 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: Health Metrics & Heart Rate Animated Display
Source: https://codefronts.com/motion/css-number-counter-animations/bioluminescent-health-tracker/

A vitals monitor where the animation itself carries the data: the heart glyph, the ECG sweep and the halo all beat at the patient's actual BPM because their animation-duration is computed from a registered custom property. Change one number and every rhythm in the panel retimes — the classic mistake here is animating at a fixed 1s and letting the visual lie about the reading.
## HTML
```html
<section class="cnc-21" aria-label="Health metrics and heart rate animated display demo">
  <div class="cnc-21__stage">
    <article class="cnc-21__panel">
      <header class="cnc-21__head">
        <div>
          <p class="cnc-21__eyebrow">Live vitals · sample data</p>
          <h3 class="cnc-21__title">Morning zone 2 session</h3>
        </div>
        <span class="cnc-21__badge"><i aria-hidden="true"></i>Streaming</span>
      </header>
      <div class="cnc-21__hero">
        <span class="cnc-21__heart" aria-hidden="true">
          <svg viewBox="0 0 24 24" width="34" height="34"><path d="M12 21s-8-4.9-8-10.6A4.8 4.8 0 0 1 12 7a4.8 4.8 0 0 1 8 3.4C20 16.1 12 21 12 21z" fill="currentColor"/></svg>
        </span>
        <p class="cnc-21__bpm"><span class="cnc-21__num" aria-hidden="true">64</span><small>bpm</small></p>
        <p class="cnc-21__zone">Zone <b class="cnc-21__zonen">2</b><span class="cnc-21__zonel">Fat burn</span></p>
      </div>
      <div class="cnc-21__ecg" aria-hidden="true">
        <svg viewBox="0 0 600 120" preserveAspectRatio="none">
          <defs><path id="cnc-21-beat" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" d="M0 60 H18 l5 -8 l5 16 l5 -8 H40 l4 -32 l5 58 l5 -32 H70 l7 -13 l7 13 H100" /></defs>
          <g class="cnc-21__trace">
            <use href="#cnc-21-beat" x="0"/><use href="#cnc-21-beat" x="100"/><use href="#cnc-21-beat" x="200"/>
            <use href="#cnc-21-beat" x="300"/><use href="#cnc-21-beat" x="400"/><use href="#cnc-21-beat" x="500"/>
          </g>
        </svg>
      </div>
      <div class="cnc-21__zones" aria-hidden="true">
        <span data-z="1"></span><span data-z="2"></span><span data-z="3"></span><span data-z="4"></span>
        <i class="cnc-21__marker"></i>
      </div>
      <dl class="cnc-21__grid">
        <div><dt>HRV</dt><dd><span class="cnc-21__hrv">48</span><small>ms</small></dd></div>
        <div><dt>SpO₂</dt><dd><span class="cnc-21__spo">98</span><small>%</small></dd></div>
        <div><dt>Resting</dt><dd><span>54</span><small>bpm</small></dd></div>
        <div><dt>Calories</dt><dd><span class="cnc-21__kcal">128</span><small>kcal</small></dd></div>
      </dl>
      <p class="cnc-21__sr" role="status" aria-live="polite"></p>
    </article>
    <p class="cnc-21__chip" aria-hidden="true">@property &lt;number&gt; · animation-duration: calc(60s / var(--bpm)) · debounced live region</p>
  </div>
</section>
```
## CSS
```css
.cnc-21,
.cnc-21 *,
.cnc-21 *::before,
.cnc-21 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

@property --cnc-21-bpm {
  syntax: '<number>';
  inherits: true;
  initial-value: 64;
}

.cnc-21 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --cnc-21-bg: oklch(0.16 0.02 255);
  --cnc-21-card: oklch(0.21 0.026 258);
  --cnc-21-ink: oklch(0.97 0.005 250);
  --cnc-21-mut: oklch(0.68 0.02 258);
  --cnc-21-acc: oklch(0.70 0.20 18);
  --cnc-21-cool: oklch(0.80 0.14 195);
  background: var(--cnc-21-bg);
  color: var(--cnc-21-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}

.cnc-21__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 18px;
  padding: clamp(16px,4vw,44px);
}

.cnc-21__panel {
  --cnc-21-bpm: 64;
  --cnc-21-zp: 32;
  width: min(100%,460px);
  padding: clamp(18px,4vw,26px);
  border-radius: 26px;
  background: linear-gradient(180deg,color-mix(in oklch,var(--cnc-21-acc) 9%,var(--cnc-21-card)),var(--cnc-21-card));
  box-shadow: 0 0 0 1px oklch(1 0 0/.08),0 30px 70px oklch(0 0 0/.5);
  transition: --cnc-21-bpm 1.1s linear;
}

.cnc-21__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
}

.cnc-21__eyebrow {
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--cnc-21-mut);
}

.cnc-21__title {
  margin-top: 5px;
  font-size: 16px;
  font-weight: 660;
  letter-spacing: -.02em;
}

.cnc-21__badge {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 6px 11px;
  border-radius: 99px;
  font-size: 11px;
  font-weight: 640;
  color: var(--cnc-21-cool);
  background: color-mix(in oklch,var(--cnc-21-cool) 14%,transparent);
  white-space: nowrap;
}

.cnc-21__badge i {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: currentColor;
  animation: cnc-21-blip calc(60s / var(--cnc-21-bpm)) ease-in-out infinite;
}

@keyframes cnc-21-blip {
  0%,
    70%,
    100% {
    opacity: .35;
  }

  12% {
    opacity: 1;
  }
}

.cnc-21__hero {
  display: flex;
  align-items: center;
  gap: 16px;
  margin-top: 22px;
}

.cnc-21__heart {
  display: grid;
  place-items: center;
  width: 62px;
  height: 62px;
  border-radius: 50%;
  color: var(--cnc-21-acc);
  background: color-mix(in oklch,var(--cnc-21-acc) 16%,transparent);
  animation: cnc-21-beat calc(60s / var(--cnc-21-bpm)) cubic-bezier(.2,.8,.3,1) infinite;
}

@keyframes cnc-21-beat {
  0% {
    transform: scale(1);
    box-shadow: 0 0 0 0 color-mix(in oklch,var(--cnc-21-acc) 45%,transparent);
  }

  14% {
    transform: scale(1.14);
  }

  28% {
    transform: scale(1.02);
  }

  42% {
    transform: scale(1.08);
  }

  70%,
    100% {
    transform: scale(1);
    box-shadow: 0 0 0 22px color-mix(in oklch,var(--cnc-21-acc) 0%,transparent);
  }
}

.cnc-21__bpm {
  display: flex;
  align-items: baseline;
  gap: 6px;
  font-size: clamp(44px,10vw,62px);
  font-weight: 750;
  letter-spacing: -.055em;
  line-height: .9;
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}

.cnc-21__bpm small {
  font-size: 15px;
  font-weight: 640;
  letter-spacing: 0;
  color: var(--cnc-21-mut);
}

.cnc-21__num {
  display: inline-block;
  min-width: 3ch;
  text-align: right;
}

.cnc-21__zone {
  margin-left: auto;
  text-align: right;
  font-size: 12px;
  color: var(--cnc-21-mut);
  line-height: 1.5;
}

.cnc-21__zone b {
  display: block;
  font-size: 20px;
  color: var(--cnc-21-ink);
  font-weight: 730;
}

.cnc-21__zonel {
  display: block;
  font-size: 11.5px;
}

.cnc-21__ecg {
  position: relative;
  height: 96px;
  margin-top: 16px;
  border-radius: 14px;
  overflow: hidden;
  background: radial-gradient(120% 100% at 0% 50%,oklch(0.25 0.03 258),oklch(0.19 0.02 258));
  box-shadow: inset 0 0 0 1px oklch(1 0 0/.06);
}

.cnc-21__ecg svg {
  width: 100%;
  height: 100%;
  display: block;
}

.cnc-21__trace {
  color: var(--cnc-21-acc);
  filter: drop-shadow(0 0 6px color-mix(in oklch,var(--cnc-21-acc) 60%,transparent));
}

.cnc-21__ecg::after {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 26%;
  background: linear-gradient(90deg,transparent,oklch(1 0 0/.16),transparent);
  animation: cnc-21-sweep calc(60s / var(--cnc-21-bpm) * 6) linear infinite;
}

@keyframes cnc-21-sweep {
  from {
    transform: translateX(-100%);
  }

  to {
    transform: translateX(400%);
  }
}

.cnc-21__zones {
  position: relative;
  display: grid;
  grid-template-columns: repeat(4,1fr);
  gap: 4px;
  margin-top: 16px;
  height: 10px;
}

.cnc-21__zones span {
  border-radius: 99px;
  background: oklch(1 0 0/.12);
}

.cnc-21__zones span[data-z="1"] {
  background: color-mix(in oklch,var(--cnc-21-cool) 45%,transparent);
}

.cnc-21__zones span[data-z="2"] {
  background: color-mix(in oklch,oklch(0.85 0.17 145) 55%,transparent);
}

.cnc-21__zones span[data-z="3"] {
  background: color-mix(in oklch,oklch(0.84 0.16 80) 50%,transparent);
}

.cnc-21__zones span[data-z="4"] {
  background: color-mix(in oklch,var(--cnc-21-acc) 55%,transparent);
}

.cnc-21__marker {
  position: absolute;
  top: -5px;
  left: calc(var(--cnc-21-zp) * 1%);
  width: 4px;
  height: 20px;
  border-radius: 99px;
  background: var(--cnc-21-ink);
  box-shadow: 0 0 12px oklch(1 0 0/.55);
  transition: left .9s cubic-bezier(.16,1,.3,1);
}

.cnc-21__grid {
  display: grid;
  grid-template-columns: repeat(4,1fr);
  gap: 10px;
  margin-top: 20px;
  padding-top: 18px;
  border-top: 1px solid oklch(1 0 0/.09);
}

.cnc-21__grid dt {
  font-size: 10.5px;
  font-weight: 660;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--cnc-21-mut);
}

.cnc-21__grid dd {
  margin-top: 5px;
  font-size: 19px;
  font-weight: 720;
  letter-spacing: -.03em;
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}

.cnc-21__grid dd small {
  font-size: .58em;
  font-weight: 600;
  color: var(--cnc-21-mut);
  margin-left: 2px;
}

.cnc-21__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.cnc-21__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--cnc-21-mut);
  padding: 7px 14px;
  border: 1px solid oklch(1 0 0/.13);
  border-radius: 99px;
  text-align: center;
  max-width: min(100%,560px);
}

@media (prefers-reduced-motion: reduce) {
  .cnc-21 * {
    animation: none !important;
    transition-duration: .01ms !important;
  }
}
```

## JavaScript
```js
(() => {
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  const ZONES = [[0, 'Recovery'], [98, 'Fat burn'], [128, 'Cardio'], [158, 'Peak']];

  document.querySelectorAll('.cnc-21__panel').forEach((panel) => {
    if (panel.dataset.wired) return;
    panel.dataset.wired = '1';
    const numEl = panel.querySelector('.cnc-21__num');
    const zoneN = panel.querySelector('.cnc-21__zonen');
    const zoneL = panel.querySelector('.cnc-21__zonel');
    const hrvEl = panel.querySelector('.cnc-21__hrv');
    const spoEl = panel.querySelector('.cnc-21__spo');
    const kcalEl = panel.querySelector('.cnc-21__kcal');
    const sr = panel.querySelector('.cnc-21__sr');
    let bpm = 64, kcal = 128, settle = 0;

    const roll = (el, from, to, ms) => {
      if (reduce || from === to) { el.textContent = to; return; }
      const t0 = performance.now();
      const frame = (t) => {
        const p = Math.min(1, (t - t0) / ms);
        el.textContent = Math.round(from + (to - from) * (1 - Math.pow(1 - p, 3)));
        if (p < 1) requestAnimationFrame(frame);
      };
      requestAnimationFrame(frame);
    };

    const apply = (next) => {
      const prev = bpm; bpm = next;
      panel.style.setProperty('--cnc-21-bpm', bpm);          // one property retimes every rhythm
      panel.style.setProperty('--cnc-21-zp', Math.max(4, Math.min(96, ((bpm - 55) / 120) * 100)));
      roll(numEl, prev, bpm, 420);
      let zi = 0; ZONES.forEach((z, i) => { if (bpm >= z[0]) zi = i; });
      zoneN.textContent = zi + 1;
      zoneL.textContent = ZONES[zi][1];
      clearTimeout(settle);
      settle = setTimeout(() => { sr.textContent = 'Heart rate ' + bpm + ' beats per minute, zone ' + (zi + 1) + ', ' + ZONES[zi][1] + '.'; }, 1200);
    };

    apply(112);
    const id = setInterval(() => {
      if (document.visibilityState !== 'visible') return;
      apply(Math.max(96, Math.min(148, bpm + Math.round((Math.random() - 0.48) * 14))));
      hrvEl.textContent = 40 + Math.round(Math.random() * 18);
      spoEl.textContent = 96 + Math.round(Math.random() * 3);
      kcal += 2 + Math.round(Math.random() * 3);
      kcalEl.textContent = kcal;
    }, 2600);
    addEventListener('pagehide', () => clearInterval(id), { once: true });
  });
})();
```

How this works

Register the rate as a real number, then divide time by it:

@property --cnc-21-bpm{ syntax:'<number>'; inherits:true; initial-value:64 }

.panel{ --cnc-21-bpm:72; transition:--cnc-21-bpm 1.1s linear }
.heart{ animation:beat calc(60s / var(--cnc-21-bpm)) infinite }
.ecg::after{ animation:sweep calc(60s / var(--cnc-21-bpm) * 6) linear infinite }

This only works because the property is registered. An unregistered --bpm is an untyped token — calc(60s / var(--bpm)) is invalid at computed-value time and the animation never starts. @property with syntax:'<number>' gives it a real type, so it participates in arithmetic and interpolates: the transition means a jump from 72 to 118 bpm accelerates the beat smoothly instead of snapping.

panel.style.setProperty('--cnc-21-bpm', bpm);   // one line drives every rhythm
roll(numEl, prev, bpm, 420);                    // and the digits tween to match

The ECG trace is one PQRST path in <defs> repeated with six <use> elements — 40 bytes of geometry instead of a 600-point polyline — and the monitor sweep is a translated radial glow, so the whole readout stays on the compositor. The digits are aria-hidden; a debounced polite live region announces the settled reading, because a screen reader that reads every heartbeat is unusable.

Make it yours

  • Zone colouring: interpolate the accent with color-mix(in oklch, var(--peak) calc((var(--cnc-21-bpm) - 60) * 1.4%), var(--rest)) so the panel warms as the athlete climbs zones.
  • Slower clinical feel: drop the sweep and keep only the heart beat, at calc(60s / var(--cnc-21-bpm)) with a 12% scale — resting monitors are calm, fitness apps are loud.
  • Real device data: feed the Web Bluetooth heart-rate characteristic (0x2A37) straight into setProperty; the render path is already idempotent.
  • Add a second trace: duplicate the <use> row at a different y and change stroke-dasharray for a respiration line.

Gotchas — read before shipping

  • Unregistered custom properties cannot be used in calc() for a time value — with no @property block the beat animation silently never runs.
  • Never announce a live vital on every tick. Debounce to a settled value (this demo waits 1.2 s of stability) or the screen reader output becomes a stream of noise.
  • A pulsing element under prefers-reduced-motion is a genuine vestibular hazard, and for photosensitive users any flash above 3 Hz (180 bpm) is a WCAG 2.3.1 failure — clamp the rate or stop the animation.
  • Health numbers must never be decorative-only: pair the animated digits with real text in a role="status" region, and keep units in the DOM, not in a ::after.
  • Don't put medical claims on a demo UI — label it as sample data, as this panel does.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

Floor set by oklch(), color-mix(), @property as this demo is written. The core technique itself goes back further — Chrome 85+.

@property is the floor (Chrome 85 / Safari 16.4 / Firefox 128). Without it the fallback initial-value in the stylesheet still renders a static, readable panel — only the data-synced timing is lost.

Search CodeFronts

Loading…