diff --git a/src/components/Piano.jsx b/src/components/Piano.jsx
new file mode 100644
index 0000000..024c5c7
--- /dev/null
+++ b/src/components/Piano.jsx
@@ -0,0 +1,133 @@
+import { getFullScale, getChordTones, NOTES } from '../lib/theory'
+
+// White keys in order within an octave, mapped to pitch class
+const WHITE_KEYS = [
+ { pc: 0, label: 'C' },
+ { pc: 2, label: 'D' },
+ { pc: 4, label: 'E' },
+ { pc: 5, label: 'F' },
+ { pc: 7, label: 'G' },
+ { pc: 9, label: 'A' },
+ { pc: 11, label: 'B' },
+]
+
+// Black keys: position (in white-key units from left of octave) and pitch class
+const BLACK_KEYS = [
+ { pc: 1, offset: 0.7 }, // C#
+ { pc: 3, offset: 1.7 }, // D#
+ { pc: 6, offset: 3.7 }, // F#
+ { pc: 8, offset: 4.7 }, // G#
+ { pc: 10, offset: 5.7 }, // A#
+]
+
+const OCTAVES = 2 // number of octaves shown
+const KEY_W = 40 // white key width
+const KEY_H = 130 // white key height
+const BLACK_W = 26 // black key width
+const BLACK_H = 82 // black key height
+const LABEL_Y = KEY_H - 10 // y of note label on white key
+const BLACK_LABEL_Y = BLACK_H - 8
+
+function keyColor(isChordTone, isScale, isBlack) {
+ if (isChordTone) return { fill: '#f59e0b', text: '#000' }
+ if (isScale) return { fill: '#a855f7', text: '#fff' }
+ return isBlack
+ ? { fill: '#1f1f1f', text: '#6b7280' }
+ : { fill: '#f5f5f5', text: '#6b7280' }
+}
+
+export default function Piano({ keyInfo, currentChord }) {
+ const { root, mode } = keyInfo ?? {}
+ if (!root) return null
+
+ const scaleSet = new Set(getFullScale(root, mode).map(n => NOTES.indexOf(n)))
+ const chordSet = currentChord
+ ? new Set(getChordTones(currentChord).map(n => NOTES.indexOf(n)))
+ : new Set()
+
+ const totalWhite = WHITE_KEYS.length * OCTAVES
+ const svgW = totalWhite * KEY_W + 2
+ const svgH = KEY_H + 20 // +20 for octave labels
+
+ return (
+
+
+ Piano — {root} {mode}
+ {currentChord && / {currentChord}}
+
+
+
+
+
+
+
+ ● Chord tone
+ ● Scale note
+
+
+ )
+}