webdevpuneet.com

Heart Rate Zone Card — Free HTML CSS JS Snippet

Heart Rate Zone Card · Cards · Plain HTML, CSS & JS · Live preview

CategoryCards

Copy & paste

Source Code

<div class="wrap">
  <div class="hr-card">
    <div class="hr-top">
      <div class="hr-pulse-wrap">
        <svg class="hr-pulse-icon" id="pulseIcon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8z"/></svg>
        <span class="hr-bpm" id="bpm">142</span>
        <span class="hr-unit">bpm</span>
      </div>
      <span class="hr-zone-badge" id="zoneBadge">Cardio</span>
    </div>

    <svg class="hr-wave" viewBox="0 0 300 50" preserveAspectRatio="none">
      <polyline id="hrWave" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" points=""/>
    </svg>

    <div class="hr-zones" id="zones"></div>

    <div class="hr-summary">
      <div class="hr-sum-item"><span class="hr-sum-val">18:24</span><span class="hr-sum-label">Duration</span></div>
      <div class="hr-sum-item"><span class="hr-sum-val" id="avgBpm">138</span><span class="hr-sum-label">Avg bpm</span></div>
      <div class="hr-sum-item"><span class="hr-sum-val">312</span><span class="hr-sum-label">Kcal</span></div>
    </div>
  </div>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

zoneForBpm() derives the current zone from percentage of MAX_HR, not a fixed bpm threshold
Rolling 10-reading bpm history array feeds a self-normalizing SVG waveform
Waveform y-axis auto-scales to the current session's own min/max so both calm and spiking readings stay visible
Time-in-zone bar chart shows accumulated minutes per zone, independent of the momentary bpm
Zone badge color and background derive directly from the active zone's own color value
Animated pulse icon (CSS keyframes) mimics a heartbeat rhythm next to the live number
Live simulated bpm stream via setInterval, structured so a real BLE sensor callback is a drop-in replacement
Rolling average bpm recomputed from the same history array used by the waveform

About this UI Snippet

Heart Rate Zone Card — Live BPM, Waveform & Time-in-Zone Breakdown

Screenshot of the Heart Rate Zone Card snippet rendered live

A raw beats-per-minute number tells you almost nothing on its own — 142 bpm means something completely different for someone walking versus someone sprinting intervals. Training zones fix this by expressing heart rate as a percentage of an estimated maximum, bucketed into named ranges (Warm up, Fat burn, Cardio, Peak, Max) that map to actual training intent. This card shows a live bpm reading, a small scrolling waveform, and a per-zone breakdown of how the current session's time has actually been spent.

Deriving the zone from a percentage of max, not a raw number

zoneForBpm() never compares a bpm value against hardcoded thresholds. It first converts to pctOfMax = (bpm / MAX_HR) * 100, then walks ZONES from highest to lowest looking for the first zone whose min percentage the current reading has reached. This is exactly how real heart-rate zone calculations work — zones are always relative to an individual's estimated max heart rate (commonly 220 - age as a rough formula, though the card just takes MAX_HR as a plain constant), not a fixed bpm number that would mean something different for every person.

A waveform built from the last 10 readings, not a canned animation

bpmHistory is a rolling array capped at 10 entries — tick() pushes a new reading and shifts the oldest one off. drawWave() maps that array onto an SVG <polyline>'s points attribute, normalizing each value between the array's own current min and max (with a small padding) so the waveform always uses the full height of its viewBox regardless of whether the session is currently calm or spiking — a fixed y-axis range would either flatten small fluctuations at low effort or clip a real spike at high effort.

Time-in-zone, not just current zone

The badge at the top shows the *current* zone, but the bar chart beneath the wave answers a different, arguably more useful question: how has this session's *time* actually been distributed across zones so far? Each ZONES[i].minutes value accumulates independently of the live bpm stream in this demo, and renderZones() computes each bar's width as that zone's share of total elapsed minutes — this is the number athletes actually care about post-workout ("I spent 8 of my 18 minutes in Cardio"), distinct from momentary bpm.

Connecting a real sensor feed

The setInterval(tick, 1800) loop simulates a live stream by nudging the last reading up or down by a small random amount, clamped to a plausible range. A production version would replace this entirely with a callback from a Bluetooth Low Energy (BLE) heart-rate sensor's Web Bluetooth API subscription or a wearable vendor's SDK stream — since every visual (bpm text, badge color, waveform, zone bars) is driven from the same bpmHistory array and tick() function, wiring in a real sensor is a matter of calling the same update logic from the sensor's own callback instead of setInterval.

Step by step

How to Use

  1. 1
    Watch it update liveThe demo simulates a live bpm stream every 1.8 seconds — bpm, the badge, the waveform, and the zone bars all update together.
  2. 2
    Set your max heart rateChange MAX_HR to the athlete's estimated maximum (a common formula is 220 minus age) — every zone threshold is a percentage of this value.
  3. 3
    Adjust zone rangesEdit the min/max percentages and colors in the ZONES array to match your training program's exact zone definitions.
  4. 4
    Connect a real sensorReplace the setInterval simulation in tick() with a callback from a Web Bluetooth heart-rate sensor or wearable SDK that calls the same update logic with each real reading.
  5. 5
    Reset time-in-zone per sessionZero out each ZONES[i].minutes at the start of a new workout, then increment the current zone's minutes by elapsed time inside tick().
  6. 6
    Export in your formatClick "HTML" for a standalone file, "JSX" for a React component, or "Tailwind" for a Tailwind CSS version.

Real-world uses

Common Use Cases

Fitness and workout tracking apps
The core use case — a live in-workout card showing current effort, zone, and how the session's time has broken down across zones so far.
Gym equipment and studio class displays
Drive the same card from a chest-strap or wrist sensor feed on a gym's cardio equipment or group class leaderboard display.
Post-workout summary screens
Freeze the final time-in-zone bars and averages as a session recap card once a workout ends, rather than updating live.
Learn percentage-of-max zone calculations
A clean example of deriving a categorical state (which zone) from a continuous value (bpm) via a percentage-of-max comparison rather than fixed thresholds.
Wearable device companion dashboards
Pair with a smartwatch or chest-strap companion app's workout detail screen, feeding the same tick() logic from the device's real data stream.
Personal training and coaching platforms
Let a coach set an athlete's specific MAX_HR and zone ranges to match a prescribed training program rather than generic defaults.

Got questions?

Frequently Asked Questions

zoneForBpm() converts the raw bpm to a percentage of MAX_HR, then finds the highest zone in the ZONES array whose min percentage threshold that value has reached. Zones are never compared against fixed bpm numbers, only percentages of the individual's own maximum.

A common rough estimate is 220 minus age, though more accurate lab or field tests exist. Set MAX_HR to whatever value your fitness platform already uses for this athlete — every zone threshold in ZONES is a percentage of it.

drawWave() normalizes each point between the current bpmHistory array's own min and max (with small padding), so the waveform always fills the available height. A fixed y-axis range would flatten small fluctuations during a calm warm-up or clip a real spike during a hard interval.

The badge shows the single zone the athlete is in right now, based on the latest bpm reading. The zone bars beneath the waveform show accumulated time-in-zone across the whole session so far — a separate, session-level statistic that updates independently of the live badge.

Replace the setInterval(tick, 1800) simulation with a subscription to your sensor source (for example, the Web Bluetooth API's heart-rate service, or a wearable vendor SDK's live-data callback) that calls a version of tick() using the sensor's real bpm value instead of the randomly nudged simulated one.

Yes — set every ZONES[i].minutes back to 0 when a new session starts, then increment the currently active zone's minutes by the elapsed interval each time tick() runs, instead of using the static demo minutes values.