5.7 KiB
Chord Diagram Feature Design
Date: 2026-04-09
Overview
A chord diagram feature for the song detail page that shows users how to play each chord on piano or guitar. The core component is dumb — it receives only a chord name string and renders the diagram. All music theory and voicing logic lives in a separate library layer.
Architecture
Three cleanly separated layers:
chord name string ("Cmaj7")
│
▼
[theory layer] — tonal parses name → root + note set {C, E, G, B}
│
▼
[voicing layer] — maps note set → renderable positions
├── Piano: note set → highlight keys on 1-octave keyboard
└── Guitar: chord quality + root → transpose moveable shape template
│
▼
[render layer] — dumb components, no music theory knowledge
├── <PianoKeys notes={["C","E","G","B"]} />
└── <GuitarFretboard frets={[x,3,2,0,1,0]} baseFret={1} />
Files
app/app/
lib/
chord-voicing.ts # theory layer: tonal → notes + guitar voicing
guitar-voicings.ts # data: ~25 quality templates
components/
chord-diagram/
piano-keys.tsx # dumb renderer: string[] of note names → keyboard
guitar-fretboard.tsx # dumb renderer: frets[] + baseFret → fretboard grid
chord-diagram.tsx # entry point: chord+instrument → voicing → renderer
chord-grid.tsx # wrapped grid of ChordDiagram cards for all song chords
Component API
<ChordDiagram>
<ChordDiagram chord="Cmaj7" instrument="piano" />
<ChordDiagram chord="Am" instrument="guitar" />
Renders nothing (graceful empty) if the chord cannot be parsed or has no voicing.
<ChordGrid>
<ChordGrid chords={["Em7", "G", "Dsus4", "Am7"]} />
Owns the instrument state ("piano" | "guitar"), persisted to localStorage as chordDiagramInstrument. Renders a global piano/guitar toggle and a flex-wrap grid of <ChordDiagram> cards.
Diagram Styles
- Piano: dot notation — white keys with filled circles on pressed keys, black keys overlaid. 1 fixed octave shown (C to B); notes are matched by name regardless of octave.
- Guitar: standard vertical fretboard — nut at top, 4 frets shown, dots on finger positions, O/X above strings for open/muted. Barre indicator where applicable.
Theory Layer (chord-voicing.ts)
Uses @tonaljs/tonal (already in npm, tree-shakeable):
export function getPianoNotes(chord: string): string[]
// "Cmaj7" → ["C", "E", "G", "B"]
// Returns [] if unparseable
export function getGuitarVoicing(chord: string): GuitarVoicing | null
// "Am" → { frets: [0,0,2,2,1,0], baseFret: 1, barre: null }
// Returns null if quality not in voicing map
Guitar Voicing Data (guitar-voicings.ts)
~25 moveable barre-chord templates keyed by tonal chord type name. Each template is a barre shape (no open strings) so it can be transposed by shifting baseFret. Two shape families are used: E-shapes (root on 6th string) and A-shapes (root on 5th string). baseFret is computed as the semitone distance from the template shape's root string pitch (E or A) to the target chord root.
interface GuitarVoicingTemplate {
frets: (number | null)[] // 6 strings; null = muted; fret numbers relative to baseFret
baseFret: number // 1 in template; shifted when transposing to target root
barre: number | null // fret (relative to baseFret) to draw barre, or null
rootString: 'E' | 'A' // which string carries the root (determines transposition offset)
}
Quality names match tonal's Chord.get(name).type output (e.g. "major", "minor", "major seventh", "dominant seventh", "minor seventh", "diminished", "augmented", "suspended fourth", "suspended second", "half-diminished", "dominant seventh flat five", etc.). ~25 entries total.
If tonal returns a quality name not in the map, getGuitarVoicing returns null and the diagram renders a "no guitar voicing" placeholder.
Layout & Integration
Breakpoint
lg (Tailwind) divides mobile from desktop layout.
Mobile
- Below
lg: lyrics and diagrams in a single column. - Inline popup: tapping a chord name in
chord-chart.tsxsetsactiveChordstate insongs.$id.tsx. An inline<ChordDiagram>panel appears immediately below the tapped line. It closes when the scroll container fires ascrollevent. - Bottom grid:
<ChordGrid>rendered after<ChordChart>in the scroll column. Not sticky — scrolls with content.
Desktop
- At
lgand above:songs.$id.tsxswitches to a two-column layout. - Left column:
<ChordChart>(existing). - Right column:
<ChordGrid>showing all unique chords in the song, wrapped. No inline popup on desktop (side column is always visible).
Chord list source
songs.$id.tsx derives uniqueChords: string[] from displayed.sections — all unique chord names in order of first appearance, deduplicated. This list is passed to <ChordGrid> and also used to determine which chord names in <ChordChart> are tappable.
Instrument toggle
Global piano/guitar toggle lives in <ChordGrid>. State persisted to localStorage as chordDiagramInstrument. Switching updates all visible diagrams at once.
Error / Unknown Chord Handling
getPianoNotesreturns[]→<PianoKeys>renders with no dots highlighted and a subtle "?" label.getGuitarVoicingreturnsnull→<GuitarFretboard>renders an empty fretboard with a "no voicing" label.- Unparseable chord name (garbage string) → same fallback as above.
Dependency
Add tonal to app/package.json. It is tree-shakeable; only chord parsing and note utilities will be bundled.