Piano Keyboard

piano-keyboardControls

One to ten octaves of real keys. Press one and it strikes with a velocity taken from how far DOWN the key you landed — the front of the key is the loud end, matching a real key's leverage and every phone DAW's convention — with a floor, so an edge tap is quiet and never silent. Drag and it glissandos: the previous note off, the entered note on, in that order, so a monophonic host never hears an overlap. Notes leave through onNoteOn / onNoteOff and never through a param channel, because a performance gesture is transient and must not land in saved state; what is LIT comes back in as a held map shaped exactly like the module contract's activeNotes, so the keyboard is telling the truth about the audio rather than about the pointer — including the false case, where a key that was struck but declined draws as struck-and-silent instead of as nothing at all. The black keys are placed by the equal-white-tops derivation rather than centred on their boundaries, which is what stops a scale run feeling lumpy under the fingers, and placement and hit-testing are the same arithmetic in a pure core. Scale membership and a root tint are controlled props, QWERTY input is one prop that wires lib/computer-keyboard rather than reinventing it, every key is a real button with a note-name label, and with no callbacks at all it becomes a read-only voicing display.

QWERTY keys play · space panics− / = octave 0

Press a key: the velocity comes from how far down the key you land, front loudest, with a floor so an edge tap is quiet rather than silent. Drag along the keys to glissando — the previous note is released as the next is struck — or play from the QWERTY row (space panics, − and = shift the octave). This demo is a scale-locked instrument: strikes outside the scale are declined, and a declined key draws struck-but-silent rather than dark, because a key that produces nothing is otherwise indistinguishable from a broken one. No audio here: the kit emits notes, hosts make the sound.

Install

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

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

Pulls in

keyboard-geometrycomputer-keyboarduse-surface-pointeruse-first-interactioncontrol-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 { PianoKeyboard } from "@/components/ui/piano-keyboard"

// Playable. The host owns what is sounding and says so through `held`.
const [held, setHeld] = useState<Map<number, boolean>>(new Map())

<PianoKeyboard
  startNote={48}
  octaves={2}
  held={held}
  scalePcs={[0, 2, 4, 5, 7, 9, 11]}
  rootPc={0}
  computerKeyboard
  onNoteOn={(note, velocity) => {
    // velocity is 0..1, from how far down the key the press landed
    const sounded = synth.noteOn(note, velocity)
    setHeld((current) => new Map(current).set(note, sounded))
  }}
  onNoteOff={(note) => {
    synth.noteOff(note)
    setHeld((current) => {
      const next = new Map(current)
      next.delete(note)
      return next
    })
  }}
/>

// Read-only: no callbacks, so it draws a voicing rather than playing one.
<PianoKeyboard startNote={48} octaves={2} held={voicing} labels="c" />

Props

PropTypeDefaultDescription
startNote / octavesnumber48 / 2The range. The start snaps down to a white key (a black key has nothing to sit on otherwise) and the range includes its top C, so n octaves is 7n + 1 white keys and 12n + 1 in all.
onNoteOn(note, velocity) => voidStrike, with a 0..1 velocity from the press height. Passing either note callback makes the keyboard playable; passing neither makes it a read-only display.
onNoteOff(note) => voidRelease. Called on pointer up, on leaving the surface, on the far side of a glissando step, and on Enter/Space key-up.
heldReadonlyMap<number, boolean>MIDI notes currently held, mapped to whether that strike made a sound — the module contract's activeNotes, same shape and same meaning. True lights the key; false draws it struck-but-silent (dashed, dimmed, struck through) because an instrument that declines a note must say so. Absent means no information, not nothing held.
scalePcsnumber[]Pitch classes in the current scale, tinted at the front of every octave of every member.
rootPcnumberThe scale's root, marked more strongly than the other members.
computerKeyboardbooleanfalseBind the QWERTY keyboard through lib/computer-keyboard — physical-key mapping so AZERTY and Dvorak survive, text-field guard, held-key sustain, spacebar panic. Chord degrees are switched off (a keyboard has no chord bank), and − / = shift the octave, which that lib binds no key for. Ignored when read-only.
computerKeyboardLayout"daw-piano-qwerty" | "chromatic-qwerty""daw-piano-qwerty"Which of that lib's mappings to bind.
labels"none" | "c" | "all""c"Which keys carry a name. Labelling only the Cs is what a player navigates by; every other label is noise once you know where C is.
velocitynumber0.8Velocity for strikes with no press position — Enter/Space and the computer keyboard. Matches lib/computer-keyboard's own default so a typed note and a clicked one sound alike.
onInteraction(event) => voidFired once, on the first gesture of any kind — the place to resume a suspended AudioContext.
colorstringOverride the accent colour for this instance.
heightnumber120Keyboard height in pixels; width defaults to 100%.

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.