Piano Roll
piano-rollViewersTime 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.
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-rollPulls 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 { 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
| Prop | Type | Default | Description |
|---|---|---|---|
| notes | PianoRollNote[] | — | 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. |
| bars | number | — | Bars in the roll. Its length in beats is bars × beatsPerBar. |
| beatsPerBar | number | 4 | Quarter-note beats per bar — the grid's bar spacing. |
| playhead | number | null | — | Absolute 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. |
| highlightActiveNotes | boolean | true | Light 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 / high | number | — | Explicit MIDI pitch range. Omit and the roll takes a two-semitone margin around the sounding notes. |
| subdivisions | number | 4 | Faint grid lines per beat: 1 is beats only, 4 is sixteenths. Bar lines are always drawn and always heaviest. |
| onSelectionChange | (selectedIds: string[]) => void | — | Report selection. Present makes notes selectable (click to select, Shift-click to extend, background to clear); absent makes the roll a pure read-only display. |
| selectedIds | string[] | — | Controlled selection. Omit to let the roll keep its own. |
| onNoteActivate | (note: PianoRollNote) => void | — | Fired on a plain click or Enter/Space on a focused note — the hook to audition it. |
| experimentalEdit | boolean | false | EXPERIMENTAL. 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 }) => void | — | Fired on an experimental edit with the note's next geometry. |
| snapDivision | number | 0.25 | Snap grid for edits, in beats — 0.25 is a sixteenth, 0 is free. |
| showGutter / showRowStripes / showPosition | boolean | true | Toggle the keyboard-name gutter, the black-key row tint, and the transport readout (which is never shown while stopped regardless). |
| noteName | (midi: number) => string | — | Map a MIDI note to a display name. Defaults to C4=60 naming. |
| color | string | — | Override the accent for this instance. Otherwise the roll reads --uikit-accent, so it follows a themed host automatically. |
| height | number | 200 | Overall 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
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.