Piano Roll

piano-rollViewers

Time runs left to right in beats, pitch runs bottom to top like a keyboard, and every note is a block at its own beat and pitch. The whole loop is drawn at once — no virtualization, no scroll model — which is the deliberate choice for the case it is for: a short loop shown whole, the way a loop lab wants it, not a DAW timeline of thousands of notes. A two-tone grid draws bars heaviest, beats medium and sixteenths faint, so the measures stay countable while it plays; row stripes tint the black keys so the octaves read; the keyboard gutter names the naturals. The playhead is a prop the host drives from its own clock, the same way step-grid takes its step — and when it is null the cursor and its bar.beat.sixteenth readout are simply not drawn, so a stopped transport shows no line pinned at zero and no ghost measure number. Give it onSelectionChange and notes become selectable, lit with the accent so you can pick a phrase out of the roll; add the experimental edit flag and a selected note can move or stretch with optional snapping, behind a flag until a sequencer consumes it. Every colour is a --uikit-* token, so an embedded roll follows a host that repaints per mode — point --uikit-accent at the mode's colour and the playhead, selection and note fill recolour with no prop passing and no re-render.

D5C5B4A4G4F4E4D4C4B3C4C5G4F4C4

Playing: the head sweeps and the readout counts bar.beat.sixteenth. Stop it and the cursor and readout vanish — a parked playhead at zero reads as a bug, so it is simply not drawn. Click a note to select it; Shift-click to extend. Turn on Edit to move and stretch notes (experimental — no sequencer consumes edits yet). Every colour is a --uikit-* token, so the roll follows the preview plane's theme — the same channel a host that repaints per team uses.

Install

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

$ npx shadcn add @syntho-ui/piano-roll

Pulls in

piano-roll-geometrycontrol-theme

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 { PianoRoll } from "@/components/ui/piano-roll"

// A generated loop, read-only. The host owns the clock: playhead is a beat
// position, and null hides the cursor entirely when the transport is stopped.
<PianoRoll
  notes={notes}            // { id, midi, startBeats, durationBeats, velocity, tone }
  bars={4}
  playhead={playing ? loopBeats : null}
  onNoteActivate={(note) => audition(note.midi)}
/>

// Selectable, with the experimental edit preview turned on.
const [selected, setSelected] = useState<string[]>([])
<PianoRoll
  notes={notes}
  bars={4}
  selectedIds={selected}
  onSelectionChange={setSelected}
  experimentalEdit
  onNoteChange={(id, next) => updateNote(id, next)}
  snapDivision={0.25}
/>

Props

PropTypeDefaultDescription
notesPianoRollNote[]The notes to draw, each with a stable id, midi, startBeats, durationBeats and optional velocity (0..127, drawn as opacity), tone ('default' | 'accent' | 'muted'), per-note color and label.
barsnumberBars in the roll. Its length in beats is bars × beatsPerBar.
beatsPerBarnumber4Quarter-note beats per bar — the grid's bar spacing.
playheadnumber | nullAbsolute beat the host's clock is on. Drawn as a lit head with a bar.beat.sixteenth readout; null draws no cursor and no readout, so a stopped transport shows nothing pinned at zero.
highlightActiveNotesbooleantrueLight up the note the playhead is inside, for live feedback on what is sounding — an active note goes full-opacity with an accent halo, and the left gutter lights the key/label of every sounding pitch so the keyboard animates the melody. Has no effect while the playhead is null, so a stopped transport lights nothing; turn it off for a roll that should stay static under the cursor.
low / highnumberExplicit MIDI pitch range. Omit and the roll takes a two-semitone margin around the sounding notes.
subdivisionsnumber4Faint grid lines per beat: 1 is beats only, 4 is sixteenths. Bar lines are always drawn and always heaviest.
onSelectionChange(selectedIds: string[]) => voidReport selection. Present makes notes selectable (click to select, Shift-click to extend, background to clear); absent makes the roll a pure read-only display.
selectedIdsstring[]Controlled selection. Omit to let the roll keep its own.
onNoteActivate(note: PianoRollNote) => voidFired on a plain click or Enter/Space on a focused note — the hook to audition it.
experimentalEditbooleanfalseEXPERIMENTAL. With onNoteChange also given, a selected note can be dragged to move (pitch + time) or stretched from its right edge. Off by default — no sequencer module consumes edits yet.
onNoteChange(id, { midi, startBeats, durationBeats }) => voidFired on an experimental edit with the note's next geometry.
snapDivisionnumber0.25Snap grid for edits, in beats — 0.25 is a sixteenth, 0 is free.
showGutter / showRowStripes / showPositionbooleantrueToggle the keyboard-name gutter, the black-key row tint, and the transport readout (which is never shown while stopped regardless).
noteName(midi: number) => stringMap a MIDI note to a display name. Defaults to C4=60 naming.
colorstringOverride the accent for this instance. Otherwise the roll reads --uikit-accent, so it follows a themed host automatically.
heightnumber200Overall roll height in pixels; rows share it evenly.

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.