Beat Grid Scope
beat-grid-scopeViewersThe chrome of a dynamics scope, with the signal left to you. Bars run across with bar lines heavier than beats and an optional sixteenth grid; the dB axis reads downward from 0 at the top, because gain reduction is depth, not height, and that is the convention every compressor bridge has used since hardware. A dashed line marks the hard budget — dashed on purpose, since a limit is not a measurement and a solid line reads as data. The gutter's own numbers are held to the same standard: they count in round decibels, and the bottom one is the depth the axis actually reaches, not a decorative infinity symbol that would claim silence on a plot only three decibels deep. Beneath the plot sits a strip of event lanes drawn as onset ticks at their exact beat, on the plot's own xForBeat, so a swung hit visibly sits off its gridline rather than snapping to the middle of a step: the same call drum-lane makes, for the same reason. The playhead runs through plot and lanes together, because an onset and the curve that answered it have to be readable against one cursor. It draws no signal of its own: children is a render function handed the resolved layout, so a prediction envelope, a measured waveform and a live overlay all live in the same coordinate space as the grid and cannot disagree about where beat 3 is — there is no second axis to keep in sync, because there is no second axis. On time it refuses to bluff: pass a phase and you own the clock (null draws no playhead, which is the right picture for a stopped transport — nothing pinned at zero), or pass a host transport and useBeatClock derives one, reporting back through onClockChange whether the position is host-locked or free-running so the caption can say which. Pure SVG over a fixed layout box, nothing measured, renders on the server.
The scope draws the grid, the dB gutter, the dashed budget line and the playhead; the cyan curve comes from the demo through the children render slot, using the same layout.xForBeat / layout.yForDb the grid was drawn with — which is why it cannot drift out of alignment with the onset ticks below. Push Swing and the ghosted offbeat percs slide off their sixteenth lines. Tick Host transport and the playhead switches to the stand-in DAW's ppqPosition; the caption follows the clock's reported source rather than claiming a sync it does not have. Stop, and the playhead disappears entirely.
Install
Copies the source into your project. Anything it depends on comes with it.
$ npx shadcn add @syntho-ui/beat-grid-scopePulls in
First time? Add the registry to your components.json once:
{
"registries": {
"@syntho-ui": "https://engine.syntho.app/r/{name}.json"
}
}Usage
Controlled or uncontrolled, whichever suits the surrounding state.
import { BeatGridScope } from "@/components/ui/beat-grid-scope"
// The consumer draws the signal; the scope owns the space it is drawn in.
<BeatGridScope
bars={2}
ceilingDb={14}
transport={host.transport} // omit for a free-run at bpm
bpm={128}
onClockChange={setClockSource} // "host" | "free" | "stopped" -> the caption
lanes={[
{ id: "kick", label: "KICK", events: [{ beat: 0 }, { beat: 2, strength: 0.6 }] },
{ id: "clap", label: "CLAP", events: [{ beat: 1, ghost: true }] },
]}
caption="Predicted from the reference pattern - free-running at 128 BPM"
>
{(layout) => (
<path
d={envelopePath(layout)} // layout.xForBeat / layout.yForDb
stroke="currentColor"
fill="none"
/>
)}
</BeatGridScope>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children | (layout: BeatGridLayout) => ReactNode | — | The signal layer. A render function receiving the resolved layout, so your paths share the grid's exact coordinate space (xForBeat, xForSeconds, yForDb). Clipped to the plot, so a curve past the axis cannot spill over the gutter or the lanes. |
| renderBackground | (layout: BeatGridLayout) => ReactNode | — | A second slot drawn UNDER the grid — for a waveform the grid lines should sit on top of. |
| bars / beatsPerBar / subdivisions | number | 2 / 4 / 4 | The window. subdivisions is grid lines per beat; 4 draws the sixteenth grid that makes an off-grid onset read as off-grid, 1 draws none. |
| axisDb / minAxisDb | number | 24 / 6 | How far down the dB axis reaches, and the floor on it. The floor is what stops a caller scaling the axis to live data from magnifying 0.2 dB of movement into a full-height dive. |
| ceilingDb / ceilingLabel | number / string | — | The hard budget, drawn as a dashed labelled line and clamped into the plot so a budget past the axis lands on the floor rather than off the surface. Sign-agnostic: -14 and 14 are the same line. |
| phase | number | null | — | Playhead position 0..1, when you own the clock. null draws no playhead — the honest picture for a stopped transport. Omit the prop entirely to derive one from transport/bpm. |
| transport / bpm / running | BeatClockTransport | null / number / boolean | — | The derived clock's inputs, passed straight to useBeatClock: a playing host transport locks the playhead to its ppqPosition, anything else free-runs at bpm, running={false} parks at 0. |
| onClockChange | (source: BeatClockSource) => void | — | Reports "host" | "free" | "stopped" for the derived clock, so a caption can distinguish host-locked from free-running instead of implying a sync the face does not have. Silent when you pass phase yourself. |
| lanes | BeatGridLane[] | — | The event strip: { id, label, color?, marker?, events } where an event is { beat, strength?, ghost?, recoveryBeat? }. Ticks are placed at their exact beat; strength sets height and opacity, ghost draws hollow, recoveryBeat draws a tail to where the recovery completes. marker is a one-glyph pointer drawn in the gutter beside the lane's name, so the strip itself says which lane a readout elsewhere is talking about. |
| laneHeight / plotHeight / viewWidth | number | 16 / 150 / 320 | Layout-box dimensions. The SVG scales to its container's width; the box is the coordinate space your layer draws in. |
| hideDbAxis / dbTickCount | boolean / number | false / 4 | Hide the dB gutter (and reclaim its width), or change how many labels sit below 0. dbTickCount is a target rather than a promise: the round-step chooser will land a label or two either side of it to keep the numbers whole. |
| axisFloorLabel | string | null | — | What the bottom of the gutter prints. The default is the depth the axis ACTUALLY reaches — "-3.0" on a three-decibel axis — because a face that scales axisDb to live data cannot know in advance how deep its plot ended up, and a fixed "-infinity" down there claims a silence that is nowhere on the screen. Pass "-infinity" when the floor really is silence, the way a fader's bottom detent is; pass null to keep the floor rule and withhold only its text. |
| niceDbTicks | boolean | true | Snap the gutter's steps to round decibels — 1 / 2 / 3 / 6 / 12 and multiples of 12 — so a 3 dB axis reads -1 / -2 / -3 and a 24 dB axis reads -6 / -12 / -18 / -24 instead of whatever an even division of the axis happened to produce. A step qualifies only if it lands exactly on the floor and stays near the requested count, so an axis no round step fits (15 dB in two steps) quietly keeps the even division and prints -7.5. False forces that division always. |
| caption / ariaLabel | ReactNode / string | — | The line under the scope — the place for the prediction/measurement qualifier — and the accessible name for the plot. |
| color / accent2 | string | — | Override the playhead/lane accent and the ceiling colour. Otherwise both read --uikit-accent / --uikit-accent-2, so a themed host repaints the scope for free. |
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
sliderandswitchroles with livearia-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.