Piano Keyboard
piano-keyboardControlsOne 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.
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-keyboardPulls 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 { 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 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. |
| 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 — 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. |
| velocity | number | 0.8 | Velocity 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) => void | — | Fired once, on the first gesture of any kind — the place 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.