# Heartbeat Sync
*URL: https://www.grettech.com/components/heartbeat-sync*

Two heartbeats on a heart monitor, each at its own rhythm. Hold the heart and they drift together until they beat as one line, then your message appears with the days you've been in sync.

- Two live heartbeats drawn like a heart monitor, each at its own rate
- Hold the heart: rate and rhythm drift together until they beat as one line
- Let go early and they drift apart again, or stay in sync once you get there (latch)
- Your message appears in sync, with the days since the day you met
- ECG paper in light mode, a glowing monitor in dark mode
- Optional heartbeat sound and phone vibration on each beat
- Works with mouse, touch and keyboard (hold Space or Enter); respects reduced motion

## Install with the shadcn CLI
```bash
npx shadcn@latest add https://www.grettech.com/api/registry/heartbeat-sync
```
With its demo: `npx shadcn@latest add https://www.grettech.com/api/registry/heartbeat-sync-demo`

**Dependencies:** npm: clsx, tailwind-merge, lucide-react, motion

## Manual installation
**Step 1: Install dependencies**
Install the packages this component uses. It also needs the `cn` helper in `lib/utils.ts`, which `npx shadcn@latest init` adds.

```bash
npm install clsx tailwind-merge lucide-react motion
```

**Step 2: Copy the source code**
Add these files to your project components directory.

File: `components/ui/heartbeat-sync.tsx`
```tsx
"use client"

import { cn } from "@/lib/utils"
import { Heart } from "lucide-react"
import {
  animate,
  AnimatePresence,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
} from "motion/react"
import * as React from "react"

export interface HeartbeatSyncProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Your name, beside your heartbeat */
  you?: string
  /** Their name, beside theirs */
  them?: string
  /** Your heart rate before you're in sync, in beats per minute */
  yourBpm?: number
  /** Their heart rate before you're in sync, in beats per minute */
  theirBpm?: number
  /** The day you met (YYYY-MM-DD): counts the days you've been in sync */
  since?: string
  /** Seconds of holding the heart it takes to fall into sync */
  holdSeconds?: number
  /** Your line's colour, then theirs. In sync, the one line carries both */
  colors?: [string, string]
  /** Stay in sync after letting go. Off, the beats drift apart again */
  latch?: boolean
  /** A soft heartbeat with each beat once in sync. It starts with the hold, so browsers allow it */
  sound?: boolean
  /** Phones vibrate with each beat once in sync (Android; iPhones ignore it) */
  haptics?: boolean
  /** Under the heart until you're in sync */
  hint?: string
  /** Called when you fall into sync */
  onSync?: () => void
}

/** How a heartbeat looks on a monitor over one beat (0–1): P, QRS, T */
function ecg(p: number) {
  const g = (mu: number, s: number, a: number) =>
    a * Math.exp(-((p - mu) ** 2) / (2 * s * s))
  return (
    g(0.16, 0.022, 0.11) -
    g(0.3, 0.007, 0.12) +
    g(0.325, 0.011, 1) -
    g(0.35, 0.009, 0.28) +
    g(0.56, 0.045, 0.24)
  )
}

const RATE = 100 // samples a second
const WINDOW = 3.6 // seconds of trace on screen
const SAMPLES = Math.round(RATE * WINDOW)
/** Where in a beat the spike peaks: the moment it's felt */
const SPIKE = 0.325

const smooth = (t: number) => t * t * (3 - 2 * t)
const noSubscription = () => () => {}

/** "In sync for 1,284 days · since 12 Feb 2022", or null without a date */
function daysSince(since?: string) {
  const met = since ? new Date(`${since}T00:00:00`) : null
  if (!met || Number.isNaN(met.getTime())) return null
  const count = Math.max(0, Math.floor((Date.now() - met.getTime()) / 864e5))
  const date = met.toLocaleDateString(undefined, {
    day: "numeric",
    month: "short",
    year: "numeric",
  })
  return `In sync for ${count.toLocaleString()} ${count === 1 ? "day" : "days"} · since ${date}`
}
const lerp = (a: number, b: number, t: number) => a + (b - a) * t

/**
 * Two heartbeats on a heart monitor, each at its own rhythm. Hold the
 * heart and they drift together, rate and rhythm, until they beat as one
 * line; then your message appears with the days you've been in sync.
 * Letting go too early lets them drift apart again.
 */
export const HeartbeatSync = React.forwardRef<
  HTMLDivElement,
  HeartbeatSyncProps
>(
  (
    {
      you = "You",
      them = "Them",
      yourBpm = 68,
      theirBpm = 86,
      since,
      holdSeconds = 3,
      colors = ["#ff5d73", "#6c8cff"],
      latch = true,
      sound = false,
      haptics = true,
      hint = "Hold to sync our heartbeats",
      onSync,
      className,
      children,
      ...props
    },
    ref
  ) => {
    const wrapRef = React.useRef<HTMLDivElement | null>(null)
    const canvasRef = React.useRef<HTMLCanvasElement>(null)
    const reduceMotion = useReducedMotion()
    const [holding, setHolding] = React.useState(false)
    const [synced, setSynced] = React.useState(false)
    const progress = useMotionValue(0)
    const beat = useMotionValue(1)
    const ring = useTransform(progress, (v) => 1 - v)

    // The loop reads the latest props and input without restarting
    const live = React.useRef({
      holding: false,
      synced: false,
      you,
      them,
      yourBpm,
      theirBpm,
      holdSeconds,
      colors,
      latch,
      sound,
      haptics,
      onSync,
    })
    live.current = {
      ...live.current,
      you,
      them,
      yourBpm,
      theirBpm,
      holdSeconds,
      colors,
      latch,
      sound,
      haptics,
      onSync,
    }
    const audio = React.useRef<AudioContext | null>(null)

    const hold = (on: boolean) => {
      live.current.holding = on
      setHolding(on)
      // Sound needs a gesture to start; the hold is one
      if (on && live.current.sound && !audio.current) {
        const Ctx = window.AudioContext
        if (Ctx) audio.current = new Ctx()
      }
    }

    React.useEffect(() => {
      const wrap = wrapRef.current
      const canvas = canvasRef.current
      const ctx = canvas?.getContext("2d")
      if (!wrap || !canvas || !ctx) return

      const traces = [new Float32Array(SAMPLES), new Float32Array(SAMPLES)]
      const phase = [0, 0.43] // they start out of step
      let sync = live.current.synced ? 1 : 0
      let width = 1
      let height = 1
      let ink = "#000"
      let font = "sans-serif"
      let dark = false
      let carry = 0
      let last = performance.now()
      let raf = 0
      let visible = true

      const thump = () => {
        const ac = audio.current
        if (!ac || !live.current.sound) return
        // "Lub-dub": two low, soft knocks
        for (const [at, gain] of [
          [0, 0.5],
          [0.17, 0.32],
        ]) {
          const t = ac.currentTime + at
          const osc = ac.createOscillator()
          const amp = ac.createGain()
          osc.frequency.setValueAtTime(62, t)
          osc.frequency.exponentialRampToValueAtTime(38, t + 0.14)
          amp.gain.setValueAtTime(0.0001, t)
          amp.gain.exponentialRampToValueAtTime(gain, t + 0.015)
          amp.gain.exponentialRampToValueAtTime(0.0001, t + 0.16)
          osc.connect(amp).connect(ac.destination)
          osc.start(t)
          osc.stop(t + 0.18)
        }
      }

      const onBeat = (inSync: boolean) => {
        if (reduceMotion) return
        animate(beat, inSync ? [1, 1.2, 1, 1.1, 1] : [1, 1.07, 1], {
          duration: inSync ? 0.55 : 0.35,
          ease: "easeOut",
        })
        if (!inSync) return
        thump()
        if (live.current.haptics) navigator.vibrate?.([24, 110, 16])
      }

      const step = (now: number) => {
        raf = 0
        const dt = Math.min(0.05, (now - last) / 1000)
        last = now
        const l = live.current

        // Holding pulls them together; letting go early lets them drift
        if (l.holding || (l.latch && l.synced)) sync += dt / l.holdSeconds
        else sync -= dt / (l.holdSeconds * 1.6)
        sync = Math.min(1, Math.max(0, sync))
        if (sync >= 1 && !l.synced) {
          l.synced = true
          setSynced(true)
          l.onSync?.()
        } else if (sync < 0.98 && l.synced && !l.latch) {
          l.synced = false
          setSynced(false)
        }
        progress.set(sync)
        const e = smooth(sync)
        const shared = (l.yourBpm + l.theirBpm) / 2
        const bpm = [lerp(l.yourBpm, shared, e), lerp(l.theirBpm, shared, e)]

        // New samples. With reduced motion the trace holds still
        carry += reduceMotion ? 0 : dt * RATE
        for (; carry >= 1; carry--) {
          const before = phase[0] % 1
          for (const i of [0, 1]) {
            phase[i] += bpm[i] / 60 / RATE
            traces[i].copyWithin(0, 1)
            traces[i][SAMPLES - 1] = ecg(phase[i] % 1)
          }
          // Rhythm lines up as well as rate
          let diff = (phase[0] - phase[1]) % 1
          if (diff > 0.5) diff -= 1
          if (diff < -0.5) diff += 1
          phase[1] += diff * Math.min(1, (6 * e) / RATE)
          if (before < SPIKE && phase[0] % 1 >= SPIKE)
            onBeat(l.synced || sync >= 1)
        }
        draw(e, bpm, shared)
        if (visible && !document.hidden) raf = requestAnimationFrame(step)
      }

      // Colours as RGB, normalised by the canvas itself (any CSS colour)
      const rgbCache = new Map<string, number[] | null>()
      const rgb = (color: string) => {
        if (!rgbCache.has(color)) {
          ctx.fillStyle = "#000000"
          ctx.fillStyle = color
          const hex = /^#([0-9a-f]{6})$/i.exec(String(ctx.fillStyle))?.[1]
          rgbCache.set(
            color,
            hex ? [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16)) : null
          )
        }
        return rgbCache.get(color)
      }
      /** Each line's colour, moving toward the two blended as they sync */
      const lineColor = (i: number, e: number) => {
        const [a, b] = live.current.colors.map(rgb)
        if (!a || !b) return live.current.colors[i]
        const own = i ? b : a
        const mix = own.map((v, k) => lerp(v, (a[k] + b[k]) / 2, e))
        return `rgb(${mix.map(Math.round).join(" ")})`
      }

      const draw = (e: number, bpm: number[], shared: number) => {
        const l = live.current
        ctx.clearRect(0, 0, width, height)
        const amp = Math.min(height * 0.11, 60)
        const base = [
          lerp(height * 0.27, height * 0.38, e),
          lerp(height * 0.5, height * 0.38, e),
        ]
        ctx.lineJoin = "round"
        ctx.lineCap = "round"
        // In sync, the two lines glow into one
        ctx.globalCompositeOperation = dark ? "lighter" : "source-over"
        for (const i of [0, 1]) {
          const color = lineColor(i, e)
          ctx.strokeStyle = color
          ctx.lineWidth = 2
          ctx.shadowColor = color
          ctx.shadowBlur = dark ? 6 + 8 * e : 0
          ctx.beginPath()
          for (let s = 0; s < SAMPLES; s++) {
            const x = (s / (SAMPLES - 1)) * width
            const y = base[i] - traces[i][s] * amp
            if (s) ctx.lineTo(x, y)
            else ctx.moveTo(x, y)
          }
          ctx.stroke()
          // The monitor's writing head
          ctx.fillStyle = color
          ctx.beginPath()
          ctx.arc(width - 1, base[i] - traces[i][SAMPLES - 1] * amp, 3, 0, 7)
          ctx.fill()
        }
        ctx.shadowBlur = 0
        // Older beats fade toward the left, like a sweep fading out
        ctx.globalCompositeOperation = "destination-out"
        const fade = ctx.createLinearGradient(0, 0, width * 0.45, 0)
        fade.addColorStop(0, "rgba(0,0,0,0.95)")
        fade.addColorStop(1, "rgba(0,0,0,0)")
        ctx.fillStyle = fade
        ctx.fillRect(0, 0, width * 0.45, height)
        ctx.globalCompositeOperation = "source-over"

        // Names and rates above each line; one label once they're one
        ctx.font = `500 12px ${font}`
        ctx.fillStyle = ink
        const left = Math.max(16, width * 0.04)
        const label = (text: string, y: number, alpha: number) => {
          ctx.globalAlpha = alpha
          ctx.fillText(text, left, y)
        }
        const apart = 1 - smooth(Math.min(1, Math.max(0, (e - 0.75) / 0.25)))
        label(
          `${l.you} · ${Math.round(bpm[0])} bpm`,
          base[0] - amp - 14,
          0.7 * apart
        )
        label(
          `${l.them} · ${Math.round(bpm[1])} bpm`,
          base[1] - amp - 14,
          0.7 * apart
        )
        label(
          `${l.you} & ${l.them} · ${Math.round(shared)} bpm`,
          base[0] - amp - 14,
          0.8 * (1 - apart)
        )
        ctx.globalAlpha = 1
      }

      const readStyle = () => {
        const style = getComputedStyle(wrap)
        ink = style.color
        font = style.fontFamily
        dark = document.documentElement.classList.contains("dark")
      }
      const resize = () => {
        const rect = wrap.getBoundingClientRect()
        const dpr = Math.min(window.devicePixelRatio || 1, 2)
        width = Math.max(1, Math.round(rect.width))
        height = Math.max(1, Math.round(rect.height))
        canvas.width = width * dpr
        canvas.height = height * dpr
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
        readStyle()
      }
      const start = () => {
        if (raf || !visible || document.hidden) return
        last = performance.now()
        raf = requestAnimationFrame(step)
      }

      // A first stretch of trace, so it isn't empty on arrival
      for (let s = 0; s < SAMPLES; s++)
        for (const i of [0, 1]) {
          phase[i] += live.current[i ? "theirBpm" : "yourBpm"] / 60 / RATE
          traces[i].copyWithin(0, 1)
          traces[i][SAMPLES - 1] = ecg(phase[i] % 1)
        }

      const resizeObserver = new ResizeObserver(() => {
        resize()
        start()
      })
      resizeObserver.observe(wrap)
      const io = new IntersectionObserver(([entry]) => {
        visible = entry.isIntersecting
        start()
      })
      io.observe(wrap)
      const themeObserver = new MutationObserver(readStyle)
      themeObserver.observe(document.documentElement, {
        attributes: true,
        attributeFilter: ["class", "style", "data-theme"],
      })
      document.addEventListener("visibilitychange", start)
      start()
      return () => {
        void audio.current?.close()
        audio.current = null
        cancelAnimationFrame(raf)
        resizeObserver.disconnect()
        io.disconnect()
        themeObserver.disconnect()
        document.removeEventListener("visibilitychange", start)
      }
    }, [reduceMotion, progress, beat])

    // Counted in the browser, not when the page was built
    const days = React.useSyncExternalStore(
      noSubscription,
      () => daysSince(since),
      () => null
    )

    return (
      <div
        ref={(node) => {
          wrapRef.current = node
          if (typeof ref === "function") ref(node)
          else if (ref) ref.current = node
        }}
        className={cn(
          "relative isolate h-[440px] w-full overflow-hidden rounded-3xl border bg-[#fffafa] text-neutral-700 select-none dark:bg-neutral-950 dark:text-neutral-300",
          className
        )}
        {...props}
      >
        {/* ECG paper: 1 mm squares, a bolder line every 5 */}
        <div
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 -z-10 mask-radial-from-40% [--bold:rgb(255_93_115/0.22)] [--fine:rgb(255_93_115/0.1)] dark:[--bold:rgb(255_255_255/0.07)] dark:[--fine:rgb(255_255_255/0.03)]"
          style={{
            backgroundImage:
              "linear-gradient(var(--bold) 1px, transparent 1px), linear-gradient(90deg, var(--bold) 1px, transparent 1px), linear-gradient(var(--fine) 1px, transparent 1px), linear-gradient(90deg, var(--fine) 1px, transparent 1px)",
            backgroundSize: "40px 40px, 40px 40px, 8px 8px, 8px 8px",
          }}
        />
        <canvas
          ref={canvasRef}
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 size-full"
        />

        <div className="absolute inset-x-0 top-[58%] flex flex-col items-center gap-4 px-6 text-center">
          <motion.button
            type="button"
            aria-label={
              synced ? `In sync with ${them}` : `Hold to sync with ${them}`
            }
            aria-pressed={synced}
            onPointerDown={(e) => {
              e.currentTarget.setPointerCapture(e.pointerId)
              hold(true)
            }}
            onPointerUp={() => hold(false)}
            onPointerCancel={() => hold(false)}
            onKeyDown={(e) => {
              if (e.key !== " " && e.key !== "Enter") return
              e.preventDefault()
              if (!e.repeat) hold(true)
            }}
            onKeyUp={(e) => {
              if (e.key === " " || e.key === "Enter") hold(false)
            }}
            onContextMenu={(e) => e.preventDefault()}
            style={{ scale: beat }}
            className="relative grid size-20 touch-none place-items-center rounded-full bg-white/80 shadow-[0_8px_30px_-6px_rgb(255_93_115/0.45)] backdrop-blur focus-visible:ring-2 focus-visible:ring-[#ff5d73] focus-visible:ring-offset-2 focus-visible:outline-none dark:bg-white/5"
          >
            <svg
              viewBox="0 0 80 80"
              aria-hidden="true"
              className="absolute inset-0 size-full -rotate-90"
            >
              <circle
                cx="40"
                cy="40"
                r="37"
                fill="none"
                strokeWidth="2"
                className="stroke-[#ff5d73]/15"
              />
              <motion.circle
                cx="40"
                cy="40"
                r="37"
                fill="none"
                strokeWidth="2.5"
                strokeLinecap="round"
                pathLength={1}
                strokeDasharray="1 1"
                style={{ strokeDashoffset: ring }}
                stroke={colors[0]}
              />
            </svg>
            <Heart
              className="size-8"
              style={{ color: colors[0] }}
              fill={synced ? colors[0] : "transparent"}
              strokeWidth={1.75}
            />
          </motion.button>

          <AnimatePresence mode="wait" initial={false}>
            {synced ? (
              <motion.div
                key="message"
                role="status"
                initial={{ opacity: 0, y: reduceMotion ? 0 : 8 }}
                animate={{ opacity: 1, y: 0 }}
                transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
                className="flex max-w-md flex-col gap-1.5"
              >
                {children && (
                  <p className="font-serif text-xl text-balance text-neutral-900 italic sm:text-2xl dark:text-neutral-50">
                    {children}
                  </p>
                )}
                {days && (
                  <p className="text-xs tracking-wide text-neutral-500 tabular-nums dark:text-neutral-400">
                    {days}
                  </p>
                )}
              </motion.div>
            ) : (
              <motion.p
                key="hint"
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                className="text-sm text-neutral-500 dark:text-neutral-400"
              >
                {holding ? "Keep holding…" : hint}
              </motion.p>
            )}
          </AnimatePresence>
        </div>
      </div>
    )
  }
)
HeartbeatSync.displayName = "HeartbeatSync"

```

**Step 3: Framework Adjustments (Optional)**
If you are manually copying this code into a Vite or Remix project (without using the CLI), make sure to remove the `"use client";` directive from the very top of the file.

## Props
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
| `you` | `string` | `"You"` | No | Your name, beside your heartbeat |
| `them` | `string` | `"Them"` | No | Their name, beside theirs |
| `yourBpm` | `number` | `68` | No | Your heart rate before you're in sync, in beats per minute |
| `theirBpm` | `number` | `86` | No | Their heart rate before you're in sync, in beats per minute |
| `since` | `string` | - | No | The day you met (YYYY-MM-DD): counts the days you've been in sync |
| `holdSeconds` | `number` | `3` | No | Seconds of holding the heart it takes to fall into sync |
| `colors` | `[string, string]` | `["#ff5d73", "#6c8cff"]` | No | Your line's colour, then theirs. In sync, the one line carries both |
| `latch` | `boolean` | `true` | No | Stay in sync after letting go. Off, the beats drift apart again |
| `sound` | `boolean` | `false` | No | A soft heartbeat with each beat once in sync. It starts with the hold, so browsers allow it |
| `haptics` | `boolean` | `true` | No | Phones vibrate with each beat once in sync (Android; iPhones ignore it) |
| `hint` | `string` | `"Hold to sync our heartbeats"` | No | Under the heart until you're in sync |
| `onSync` | `() => void` | - | No | Called when you fall into sync |

## Usage
```tsx
"use client"

import { HeartbeatSync } from "@/components/ui/heartbeat-sync"

export default function HeartbeatSyncDemo() {
  return (
    <HeartbeatSync
      you="Aarav"
      them="Mira"
      yourBpm={68}
      theirBpm={86}
      since="2022-02-12"
      className="h-[480px]"
    >
      Still you. Every single beat.
    </HeartbeatSync>
  )
}

```
