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>

Heart Rate Zone Card — Free HTML CSS JS Snippet

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

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.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why zoneForBpm compares a percentage of MAX_HR rather than the raw bpm value against fixed thresholds, and why that distinction matters for two athletes of different ages and fitness levels using the same card. The same assistant can help optimize it — for instance asking whether accumulating ZONES[i].minutes should be driven by actual elapsed wall-clock time rather than static demo values once wired to a real sensor. It's also useful for extending the card: ask it to add a target-zone alert that changes color when the athlete drifts outside a prescribed zone, connect it to the Web Bluetooth heart-rate service for a real chest-strap sensor, or add a post-workout summary view that freezes the final numbers. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a live "heart rate zone card" in plain HTML, CSS, and JavaScript — no charting library, no framework.

Requirements:
- Define five named training zones (for example Warm up, Fat burn, Cardio, Peak, Max) as a plain JavaScript array, each with a minimum and maximum percentage-of-max-heart-rate threshold and its own accent color, plus a separate constant for the athlete's estimated maximum heart rate in bpm.
- Write a function that takes a raw bpm reading, converts it to a percentage of the maximum heart rate constant, and returns whichever zone's percentage range that value falls into — the comparison must be against the percentage of max, never against fixed bpm numbers, so changing the max heart rate constant alone changes every zone's effective bpm range.
- Show a large live bpm number with a small pulsing heart icon animated via CSS keyframes, and a colored badge showing the current zone's name, whose background and text color come directly from that zone's own color value.
- Maintain a rolling array of the last 10 or so bpm readings and render them as a smooth SVG polyline waveform whose vertical scale is normalized to that array's own current minimum and maximum (with small padding) rather than a fixed range, so the line always uses the available height whether readings are calm or spiking.
- Render a horizontal bar per zone showing what percentage of total accumulated session minutes has been spent in that zone, separate from and updating independently of the single current-zone badge.
- Simulate a live data stream with a repeating timer that nudges the previous bpm reading by a small random amount within a clamped realistic range, structured so replacing the timer with a real sensor callback would require touching only the one line that generates the next bpm value.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

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.