Piano Keyboard
piano-keyboardControlsOne to ten octaves of real keys. Press a key to strike with velocity based on how far down the key you landed. The front of the key is the loud end, matching a real key's leverage and phone DAW convention. A floor keeps an edge tap quiet but never silent. Drag to glissando: the previous note turns off before the entered note turns on, so a monophonic host never hears an overlap. Notes leave through onNoteOn / onNoteOff, never through a param channel, because performance gestures are transient and must not enter saved state. A held map shaped like the module contract's activeNotes determines which keys light, so the keyboard reflects the audio rather than the pointer. It also handles declined notes: a struck key that made no sound appears struck and silent, not blank. Black keys use the equal-white-tops derivation instead of sitting at their boundaries, keeping scale runs even under the fingers. Placement and hit testing share arithmetic in a pure core. Scale membership and root tint are controlled props. One QWERTY input prop wires lib/computer-keyboard rather than rebuilding it. Every key is a real button with a note-name label; without callbacks, the keyboard becomes a read-only voicing display.
Install
Copies the source into your project. Anything it depends on comes with it.
$ pnpm dlx shadcn@latest add @pluginui/core-piano-keyboardPulls in
First time? Add the registry to your components.json once:
{
"registries": {
"@pluginui": "https://pluginui.com/r/{name}.json"
}
}Usage
Connect the documented props to your application or host.
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
| Prop | Type | Default | Description |
|---|---|---|---|
| startNote / octaves | number | 48 / 2 | The 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) => void | — | Strike, 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) => void | — | Release. Called on pointer up, on leaving the surface, on the far side of a glissando step, and on Enter/Space key-up. |
| held | ReadonlyMap<number, boolean> | — | MIDI notes currently held, mapped to whether each strike made a sound. This uses the module contract's activeNotes shape and meaning. True lights the key; false draws it struck but silent (dashed, dimmed and struck through) because an instrument must show when it declines a note. Absent means there is no information, not that nothing is held. |
| scalePcs | number[] | — | Pitch classes in the current scale, tinted at the front of every octave of every member. |
| rootPc | number | — | The scale's root, marked more strongly than the other members. |
| computerKeyboard | boolean | false | Bind the QWERTY keyboard through lib/computer-keyboard. It provides physical-key mapping for AZERTY and Dvorak, a text-field guard, held-key sustain and spacebar panic. Chord degrees are disabled because a keyboard has no chord bank. The library does not bind octave-shift keys, so − / = shift the octave here. 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. |
| velocity | number | 0.8 | Velocity for strikes without a press position, including Enter/Space and computer-keyboard input. It matches lib/computer-keyboard's default so typed and clicked notes sound alike. |
| onInteraction | (event) => void | — | Fires once on the first gesture of any kind. Use it to resume a suspended AudioContext. |
| color | string | — | Override the accent colour for this instance. |
| height | number | 120 | Keyboard 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
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.