# Heartbeat

> A looping ECG trace with a bright leading edge.

Category: Instruments. Docs: https://neonkit.heyaadi.com/components/heartbeat

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/heartbeat.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/heartbeat`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { Heartbeat } from "@/components/ui/heartbeat"

<Heartbeat bpm={72} />
```

## Examples

### Patient monitor

The trace sweeps left to right behind a bright leading dot and fades out along its tail. onBeat fires on every R peak; here it thumps the heart icon.

```tsx
"use client";

import * as React from "react";
import { Heart } from "@/components/ui/icons";
import { Heartbeat } from "@/components/ui/heartbeat";

export default function HeartbeatDemo() {
  const heart = React.useRef<SVGSVGElement>(null);

  // Thump the heart icon on every R peak. onBeat never fires under reduced motion.
  const thump = React.useCallback(() => {
    heart.current?.animate([{ transform: "scale(1.28)" }, { transform: "scale(1)" }], {
      duration: 320,
      easing: "cubic-bezier(0.23, 1, 0.32, 1)",
    });
  }, []);

  return (
    <div className="w-full max-w-2xl overflow-hidden rounded-2xl border border-white/10 bg-[#06070b] shadow-[inset_0_1px_0_rgb(255_255_255/0.05)]">
      <div className="flex items-center justify-between border-b border-white/[0.07] px-4 py-2.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-white/50">
        <span>Bed 4 · Adult</span>
        <span>II · 25 mm/s</span>
      </div>
      <div className="grid grid-cols-1 sm:grid-cols-[1fr_auto]">
        <div className="relative px-4 py-6 [background-image:linear-gradient(rgb(255_255_255/0.035)_1px,transparent_1px),linear-gradient(90deg,rgb(255_255_255/0.035)_1px,transparent_1px)] [background-size:20px_20px]">
          <Heartbeat bpm={72} height={132} onBeat={thump} />
        </div>
        <dl className="grid grid-cols-3 gap-px border-t border-white/[0.07] bg-white/[0.07] sm:w-44 sm:grid-cols-1 sm:border-l sm:border-t-0">
          <div className="min-w-0 bg-[#06070b] px-3 py-3 sm:px-4">
            <dt className="flex items-center justify-between text-[10px] font-semibold uppercase tracking-[0.14em] text-white/50">
              HR
              <Heart
                ref={heart}
                aria-hidden
                className="size-3.5 fill-[rgb(var(--neon-accent,34_211_238))] text-[rgb(var(--neon-accent,34_211_238))] [filter:drop-shadow(0_0_6px_rgb(var(--neon-accent,34_211_238)/0.8))]"
              />
            </dt>
            <dd className="mt-1 font-mono text-4xl font-medium tabular-nums leading-none text-[color-mix(in_srgb,rgb(var(--neon-accent,34_211_238))_40%,white)] [text-shadow:0_0_14px_rgb(var(--neon-accent,34_211_238)/0.7)]">
              72
            </dd>
          </div>
          <div className="min-w-0 bg-[#06070b] px-3 py-3 sm:px-4">
            <dt className="text-[10px] font-semibold uppercase tracking-[0.14em] text-white/50">SpO₂ %</dt>
            <dd className="mt-1 font-mono text-xl tabular-nums leading-none text-white/80 sm:text-2xl">98</dd>
          </div>
          <div className="min-w-0 bg-[#06070b] px-3 py-3 sm:px-4">
            <dt className="text-[10px] font-semibold uppercase tracking-[0.14em] text-white/50">NIBP</dt>
            <dd className="mt-1 whitespace-nowrap font-mono text-xl tabular-nums leading-none text-white/80 sm:text-2xl">
              118<span className="text-white/50">/</span>76
            </dd>
          </div>
        </dl>
      </div>
    </div>
  );
}
```

### Rates

The sweep fits a whole number of beats on screen, so the rhythm stays true to bpm at any width.

```tsx
import { Heartbeat } from "@/components/ui/heartbeat";

const rates = [
  { bpm: 52, label: "Asleep" },
  { bpm: 96, label: "Brisk walk" },
  { bpm: 148, label: "Tempo run" },
];

export default function HeartbeatRates() {
  return (
    <div className="grid w-full max-w-xl gap-3">
      {rates.map((rate) => (
        <div
          key={rate.bpm}
          className="grid grid-cols-[6rem_1fr] items-center gap-4 rounded-xl border border-white/10 bg-white/[0.02] px-4 py-2"
        >
          <div>
            <p className="font-mono text-xl tabular-nums leading-tight text-white/90">
              {rate.bpm}
              <span className="ml-1 text-[11px] text-white/55">bpm</span>
            </p>
            <p className="text-xs text-white/55">{rate.label}</p>
          </div>
          <Heartbeat bpm={rate.bpm} height={56} />
        </div>
      ))}
    </div>
  );
}
```

### Changing rate

Change bpm on the fly and the dot carries on from where it was.

```tsx
"use client";

import * as React from "react";
import { Heart } from "@/components/ui/icons";
import { Heartbeat } from "@/components/ui/heartbeat";
import { Slider } from "@/components/ui/slider";

export default function HeartbeatLiveRate() {
  const [bpm, setBpm] = React.useState(84);
  const heart = React.useRef<SVGSVGElement>(null);

  const thump = React.useCallback(() => {
    heart.current?.animate([{ transform: "scale(1.3)" }, { transform: "scale(1)" }], {
      duration: 280,
      easing: "cubic-bezier(0.23, 1, 0.32, 1)",
    });
  }, []);

  return (
    <div className="grid w-full max-w-lg gap-5 rounded-2xl border border-white/10 bg-white/[0.02] p-5">
      <div className="flex items-center justify-between">
        <p className="flex items-center gap-2 text-sm text-white/80">
          <Heart
            ref={heart}
            aria-hidden
            className="size-4 fill-[rgb(var(--neon-accent,34_211_238))] text-[rgb(var(--neon-accent,34_211_238))]"
          />
          Heart rate
        </p>
        <p className="font-mono text-2xl tabular-nums text-white/90">
          {bpm}
          <span className="ml-1 text-xs text-white/55">bpm</span>
        </p>
      </div>
      <Heartbeat bpm={bpm} height={88} onBeat={thump} />
      <Slider
        aria-label="Heart rate"
        min={40}
        max={180}
        value={[bpm]}
        onValueChange={([value]) => setBpm(value)}
        formatValue={(value) => `${value} beats per minute`}
      />
    </div>
  );
}
```

### Service health

A small strip as an is-it-alive signal. bpm={0} flatlines.

```tsx
import { Heartbeat } from "@/components/ui/heartbeat";

const services = [
  { name: "api", region: "eu-west-1", pulse: 66, latency: "42 ms" },
  { name: "billing-worker", region: "us-east-1", pulse: 54, latency: "118 ms" },
  { name: "search-indexer", region: "eu-west-1", pulse: 0, latency: "no response for 3 min" },
];

export default function HeartbeatServiceStatus() {
  return (
    <ul className="w-full max-w-md divide-y divide-white/[0.07] rounded-xl border border-white/10 bg-white/[0.02]">
      {services.map((service) => (
        <li key={service.name} className="grid grid-cols-[1fr_7rem] items-center gap-4 px-4 py-3">
          <div className="min-w-0">
            <p className="truncate font-mono text-[13px] text-white/90">{service.name}</p>
            <p className={service.pulse ? "text-xs text-white/55" : "text-xs text-[#ff4d5e]"}>
              {service.region} · {service.latency}
            </p>
          </div>
          {/* Each service's check-in rate drives its pulse; no check-ins flatlines it. */}
          <Heartbeat
            bpm={service.pulse}
            height={36}
            color={service.pulse ? undefined : "red"}
            aria-label={service.pulse ? `${service.name} is up` : `${service.name} is down`}
          />
        </li>
      ))}
    </ul>
  );
}
```

## API reference

### Heartbeat

Renders a div with role="img" and accepts its attributes; the ref points at it. It fills its container's width.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `bpm` | `number` | `72` | Beats per minute. 0 draws a flat line. |
| `height` | `number` | `96` | Height in pixels. |
| `onBeat` | `() => void` | — | Called as each R peak passes the leading dot. Not called under reduced motion. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |

## Accessibility

- Screen readers hear "Heart rate 72 beats per minute". Pass aria-label for other meanings, such as a service being up.
- The strip is one SVG path drawn once; each frame only moves a gradient window and the dot, so nothing re-renders while it runs.
- It pauses while off screen or in a hidden tab, and keeps its place when it resumes.
- With reduced motion, the trace holds still with the dot near the right edge.

## Source

`components/ui/heartbeat.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

// One cardiac cycle as [phase, amplitude]: P wave, QRS complex, T wave.
const halfSine = (from: number, span: number, amp: number, lift = 0, steps = 8): [number, number][] =>
  Array.from({ length: steps + 1 }, (_, i) => {
    const u = i / steps;
    return [from + u * span, lift * (1 - u) + Math.sin(u * Math.PI) * amp];
  });

const cycle: [number, number][] = [
  [0, 0],
  ...halfSine(0.06, 0.1, 0.1, 0, 6),
  [0.2, 0],
  [0.226, -0.09],
  [0.25, 1],
  [0.274, -0.3],
  [0.298, 0.02],
  ...halfSine(0.38, 0.18, 0.24, 0.04),
  [0.62, 0],
  [1, 0],
];
const R_PHASE = 0.25;
const NOMINAL_SPEED = 150; // px per second, before snapping to whole beats

function level(phase: number) {
  for (let i = 1; i < cycle.length; i++) {
    const [x1, y1] = cycle[i];
    if (phase <= x1) {
      const [x0, y0] = cycle[i - 1];
      return x1 === x0 ? y1 : y0 + ((phase - x0) / (x1 - x0)) * (y1 - y0);
    }
  }
  return 0;
}

// Beats differ by a few percent, like a real strip. Hashed, so server and client agree.
const variation = (beat: number) => 0.93 + 0.07 * Math.abs((Math.sin(beat * 12.9898 + 4.1) * 43758.5453) % 1);

function layout(width: number, height: number, bpm: number) {
  const flat = !(bpm > 0);
  const interval = flat ? 1 : 60 / bpm;
  // A whole number of beats per screen, so the strip repeats seamlessly.
  const beats = flat ? 1 : Math.max(1, Math.round(width / (NOMINAL_SPEED * interval)));
  const span = width / beats;
  const period = flat ? width / NOMINAL_SPEED : beats * interval;
  const base = height * 0.6;
  const reach = base - height * 0.12;
  const y = (x: number) => {
    if (flat) return base;
    const local = ((x % width) + width) % width;
    const beat = Math.floor(local / span);
    return base - level(local / span - beat) * reach * variation(beat);
  };
  let d = "";
  if (flat) d = `M0 ${base.toFixed(1)}H${(width * 2).toFixed(1)}`;
  else
    for (let k = 0; k < beats * 2; k++) {
      const amp = variation(k % beats);
      for (const [phase, value] of cycle) {
        if (k > 0 && phase === 0) continue;
        const x = (k + phase) * span;
        d += `${d ? "L" : "M"}${x.toFixed(1)} ${(base - value * reach * amp).toFixed(1)}`;
      }
    }
  return { d, span, period, y, flat };
}

export interface HeartbeatProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Beats per minute. 0 draws a flat line. */
  bpm?: number;
  /** Trace color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /** Height in pixels. The width fills the container. */
  height?: number;
  /** Called as each R peak passes the leading dot. */
  onBeat?: () => void;
}

/**
 * An ECG trace that sweeps left to right with a fading tail.
 */
const Heartbeat = React.forwardRef<HTMLDivElement, HeartbeatProps>(
  ({ className, style, color, bpm = 72, height = 96, onBeat, ...props }, ref) => {
    const rootRef = React.useRef<HTMLDivElement>(null);
    const haloRef = React.useRef<SVGLinearGradientElement>(null);
    const coreRef = React.useRef<SVGLinearGradientElement>(null);
    const headElRef = React.useRef<HTMLSpanElement>(null);
    const onBeatRef = React.useRef(onBeat);
    const headRef = React.useRef(-1);
    const [width, setWidth] = React.useState(0);
    const id = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");

    React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);

    React.useEffect(() => {
      onBeatRef.current = onBeat;
    }, [onBeat]);

    React.useEffect(() => {
      const root = rootRef.current;
      if (!root) return;
      const measure = () => setWidth(Math.round(root.getBoundingClientRect().width));
      measure();
      if (typeof ResizeObserver === "undefined") return;
      const observer = new ResizeObserver(measure);
      observer.observe(root);
      return () => observer.disconnect();
    }, []);

    const strip = React.useMemo(() => (width > 0 ? layout(width, height, bpm) : null), [width, height, bpm]);

    React.useEffect(() => {
      const root = rootRef.current;
      if (!root || !strip) return;
      const tail = width * 0.72;
      const motion = window.matchMedia?.("(prefers-reduced-motion: reduce)");
      let raf = 0;
      let last = 0;
      let onScreen = true;

      const place = (head: number) => {
        const p = width + head;
        for (const gradient of [haloRef.current, coreRef.current]) {
          gradient?.setAttribute("x1", (p - tail).toFixed(2));
          gradient?.setAttribute("x2", (p + 1).toFixed(2));
        }
        if (headElRef.current) {
          headElRef.current.style.transform = `translate(${head.toFixed(2)}px, ${strip.y(head).toFixed(2)}px)`;
        }
      };

      // Keep the dot where it was across bpm and size changes.
      if (headRef.current < 0 || headRef.current >= width) headRef.current = width * 0.86;
      place(headRef.current);

      const frame = (now: number) => {
        raf = requestAnimationFrame(frame);
        const dt = Math.min(0.05, last ? (now - last) / 1000 : 1 / 60);
        last = now;
        const from = headRef.current;
        let to = from + (width / strip.period) * dt;
        if (!strip.flat) {
          // Fire onBeat for every R peak the dot crossed this frame.
          const first = Math.ceil(from / strip.span - R_PHASE);
          for (let k = first; (k + R_PHASE) * strip.span <= to; k++) {
            if ((k + R_PHASE) * strip.span > from) onBeatRef.current?.();
          }
        }
        if (to >= width) to -= width;
        headRef.current = to;
        place(to);
      };

      const stop = () => {
        cancelAnimationFrame(raf);
        raf = 0;
        last = 0;
      };
      const sync = () => {
        const live = onScreen && !document.hidden && !motion?.matches;
        if (live && !raf) raf = requestAnimationFrame(frame);
        else if (!live && raf) stop();
      };

      const intersection =
        typeof IntersectionObserver !== "undefined"
          ? new IntersectionObserver(([entry]) => {
              onScreen = entry.isIntersecting;
              sync();
            })
          : null;
      intersection?.observe(root);
      document.addEventListener("visibilitychange", sync);
      motion?.addEventListener?.("change", sync);
      sync();

      return () => {
        stop();
        intersection?.disconnect();
        document.removeEventListener("visibilitychange", sync);
        motion?.removeEventListener?.("change", sync);
      };
    }, [strip, width]);

    const rate = Math.max(0, Math.round(bpm));
    // The pool of light around the dot scales with the strip.
    const spill = Math.min(88, Math.max(24, height * 0.85));
    // The tail fades in from nothing; the last stop cuts the trace off right at the dot.
    const stops: [number, number][] = [
      [0, 0],
      [0.4, 0.26],
      [0.8, 0.72],
      [0.997, 1],
      [1, 0],
    ];

    return (
      <div
        ref={rootRef}
        role="img"
        aria-label={rate > 0 ? `Heart rate ${rate} beats per minute` : "Flat line, no heart rate"}
        style={neonVars(color, { height, ...style })}
        className={cn("[--neon:var(--neon-accent,34_211_238)]", "relative w-full min-w-0", className)}
        {...props}
      >
        {strip && (
          <>
            <svg
              aria-hidden
              viewBox={`0 0 ${width} ${height}`}
              preserveAspectRatio="none"
              className="pointer-events-none absolute inset-0 size-full overflow-visible [clip-path:inset(-50%_0)]"
            >
              <defs>
                {(
                  [
                    [haloRef, `${id}-halo`, "rgb(var(--neon))"],
                    [coreRef, `${id}-core`, "color-mix(in srgb, rgb(var(--neon)) 30%, white)"],
                  ] as const
                ).map(([gradientRef, gradientId, stopColor]) => (
                  <linearGradient
                    key={gradientId}
                    ref={gradientRef}
                    id={gradientId}
                    gradientUnits="userSpaceOnUse"
                    x1={width * 1.14}
                    x2={width * 1.86 + 1}
                    y1="0"
                    y2="0"
                  >
                    {stops.map(([offset, opacity]) => (
                      <stop key={offset} offset={offset} style={{ stopColor, stopOpacity: opacity }} />
                    ))}
                  </linearGradient>
                ))}
              </defs>
              {/* The strip is two screens long, drawn twice, so the tail wraps around the edge. */}
              {[0, -width].map((shift) => (
                <g key={shift} transform={`translate(${shift} 0)`} fill="none" strokeLinecap="round" strokeLinejoin="round">
                  <path d={strip.d} stroke={`url(#${id}-halo)`} strokeWidth="7" strokeOpacity="0.16" />
                  <path d={strip.d} stroke={`url(#${id}-halo)`} strokeWidth="3" strokeOpacity="0.55" />
                  <path d={strip.d} stroke={`url(#${id}-core)`} strokeWidth="1.5" />
                </g>
              ))}
            </svg>
            {/* The leading dot and the pool of light it throws; outside the SVG so the edges never clip it. */}
            <span
              ref={headElRef}
              aria-hidden
              style={{ transform: `translate(${width * 0.86}px, ${strip.y(width * 0.86)}px)` }}
              className="pointer-events-none absolute left-0 top-0"
            >
              <span
                style={{ width: spill, height: spill, left: -spill / 2, top: -spill / 2 }}
                className="absolute rounded-full bg-[radial-gradient(circle,rgb(var(--neon)/CALC(0.3*var(--neon-glow,1))),transparent_68%)]"
              />
              <span className="absolute -left-[2.5px] -top-[2.5px] size-[5px] rounded-full bg-[color-mix(in_srgb,rgb(var(--neon))_30%,white)] shadow-[0_0_0_2px_rgb(var(--neon)/0.4),0_0_6px_3px_rgb(var(--neon)/CALC(0.55*var(--neon-glow,1))),0_0_16px_6px_rgb(var(--neon)/CALC(0.2*var(--neon-glow,1)))]" />
            </span>
          </>
        )}
      </div>
    );
  },
);
Heartbeat.displayName = "Heartbeat";

export { Heartbeat };
```
