Piano Roll

piano-rollViewers

The whole loop as a pitch × beat note grid, rendered at once.

$ pnpm dlx shadcn@latest add @pluginui/core-piano-roll
Loading interactive preview…

Install

Copies the source into your project. Anything it depends on comes with it. Run this in your React project with shadcn initialized; no registry configuration is needed.

Pulls in

piano-roll-geometrycontrol-theme
Install using the registry URL
$ pnpm dlx shadcn@latest add https://pluginui.com/r/core-piano-roll.json

Usage

Connect the documented props to your application or host.

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}
/>
About Piano Roll

Time runs left to right in beats and pitch runs bottom to top like a keyboard. Each note is a block at its beat and pitch. The component draws the whole loop at once, without virtualization or a scroll model. This suits a short loop shown in full, as in a loop lab, rather than a DAW timeline with thousands of notes. A two-tone grid makes bars heaviest, beats medium and sixteenths faint. Row stripes tint the black keys, and the keyboard gutter names the naturals. The host drives the playhead from its own clock, as it does for step-grid. When playhead is null, neither the cursor nor its bar.beat.sixteenth readout appears, so a stopped transport shows no line pinned at zero or ghost measure number. Provide onSelectionChange to make notes selectable and highlight them with the accent. Add the experimental edit flag to let selected notes move or stretch with optional snapping; editing remains behind a flag until a sequencer consumes it. Every colour uses a --uikit-* token, so an embedded roll follows a host repainting per mode. Point --uikit-accent at the mode's colour to recolour the playhead, selection and note fill without prop passing or a re-render.

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.
barsnumber—Bars in the roll. Its length in beats is bars × beatsPerBar.
beatsPerBarnumber4Quarter-note beats per bar, which determines the grid's bar spacing.
playheadnumber | 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.
highlightActiveNotesbooleantrueLight the note under the playhead to show what is sounding. An active note becomes fully opaque with an accent halo, and the left gutter lights the key or label for every sounding pitch so the keyboard animates the melody. This has no effect when the playhead is null, so a stopped transport lights nothing. Turn it off for a roll that should stay static under the cursor.
low / highnumber—Explicit 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[]) => 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.
selectedIdsstring[]—Controlled selection. Omit to let the roll keep its own.
onNoteActivate(note: PianoRollNote) => void—Fires on a plain click or Enter/Space on a focused note. Use it to audition the note.
experimentalEditbooleanfalseEXPERIMENTAL. Provide onNoteChange to let a selected note move (pitch + time) when dragged or stretch from its right edge. It is off by default because no sequencer module consumes edits yet.
onNoteChange(id, { midi, startBeats, durationBeats }) => void—Fired on an experimental edit with the note's next geometry.
snapDivisionnumber0.25Snap grid for edits, in beats. 0.25 is a sixteenth; 0 allows free movement.
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) => string—Map a MIDI note to a display name. Defaults to C4=60 naming.
colorstring—Override 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.