useBeatClock

beat-clockFoundations

A tempo-synced face has to answer one question per frame: what beat are we on inside the window being drawn? There are only two honest answers, and this hook exists so a face never blurs them. With a host transport that is PLAYING, the phase comes from ppqPosition: the window tiles along the project's quarter notes from zero, so the same musical position always draws at the same x and a loop or a relocate does not shift the picture. Without one — or with one that is stopped — it free-runs from a bpm on a requestAnimationFrame loop, which is a plausible illustration at the right tempo and emphatically not where the host is. running={false} parks at phase 0 rather than freezing wherever it happened to be. The returned source is "host" | "free" | "stopped" precisely so a caption can say which, because a face claiming host sync over a free-running playhead is lying, and that is the failure this replaces. Note the deliberate asymmetry: a stopped DAW free-runs (a face frozen on the host's last position looks broken) but still borrows the host's TEMPO, since the tempo is real even when the position is not. All the arithmetic and all the policy are pure exported functions pinned in Node — the phase mappings, the source rule, the tempo fallback — and the hook is only the loop around them. Nothing touches requestAnimationFrame, performance or window outside an effect, so the first and only render on the server is phase 0 rather than a throw.

Phase0.000beat 0.00 of 8
Clockfree

Free-running at 120 BPM — a plausible illustration, not the host's position. Tick Host transport and the phase starts coming from a stand-in DAW's ppqPosition; untick Transport playing and it falls back to free-running, because a face frozen on the host's last position looks broken — but note it keeps the host's tempo, since the tempo is real even when the position is not. The source field exists so a caption can state which of those is true instead of implying a sync it does not have.

Install

Copies the source into your project. Anything it depends on comes with it.

$ npx shadcn add @syntho-ui/beat-clock

First time? Add the registry to your components.json once:

components.json
{
  "registries": {
    "@syntho-ui": "https://engine.syntho.app/r/{name}.json"
  }
}

Usage

Controlled or uncontrolled, whichever suits the surrounding state.

example.tsx
import { useBeatClock } from "@/lib/beat-clock"

const { phase, beats, source } = useBeatClock({
  transport: host.transport, // { playing, tempo, ppqPosition } - omit to free-run
  bpm: 128,                  // fallback tempo, and the free-run's rate
  bars: 2,
  running: !bypassed,
})

// The caption never claims a sync it does not have.
const label =
  source === "host" ? "Host-locked"
  : source === "free" ? `Free-running at ${bpm} BPM`
  : "Stopped"

Props

PropTypeDefaultDescription
transportBeatClockTransport | null{ playing, tempo, ppqPosition } — structurally the head of audio-contracts' TransportState, so a host's state is assignable as-is, but declared locally so an installed copy stands alone. Supplying it is NOT enough to be host-locked: it must also be playing.
bpmnumber120Tempo for the free-run, and when the transport reports none. A host that IS reporting a usable tempo always wins, including while stopped.
bars / beatsPerBarnumber2 / 4The window the phase is measured across.
runningbooleantrueFalse parks at phase 0 — the contract for a bypassed module or a hidden panel. It outranks a rolling transport.
returns{ phase, beats, source }phase is 0..1 across the window, beats is that in the window's own units, and source is "host" | "free" | "stopped" — the field a caption reads.
freeRunPhase / hostWindowPhasepure functionsThe two mappings, exported and tested: elapsed seconds at a tempo, and a ppqPosition tiled into the window. Both wrap into 0..1, both return 0 rather than NaN for an unresolved tempo or a non-finite position, and a negative pre-roll PPQ wraps forward instead of going negative.
resolveBeatClockSource / resolveBeatClockTempopure functionsThe policy, alone and assertable: which clock is authoritative, and whose bpm the free-run should use.

Interaction

  • Drag with pointer capture, so the gesture keeps tracking past the edge of the control.
  • Shift while dragging for a fine pass; release it mid-gesture and coarse tracking resumes.
  • Double-click resets to defaultValue.
  • Arrow keys step normally, Shift+arrow steps finely, PageUp/PageDown jump by ten, Home/End go to the extremes.
  • Proper slider and switch roles with live aria-valuetext, so a screen reader announces the formatted value rather than the raw number.
  • The scroll wheel adjusts the value while the pointer is over the control; stepped ranges move at least one declared step per notch by default.