DEV Community

Melogen
Melogen

Posted on

Building a Piano Roll Editor From Scratch in React

No libraries. No canvas. Pure React + DOM + math. Here's every decision I made and why.


I'm building Melogen — a web editor that turns AI-generated music into editable MIDI. The centerpiece of the UI is a piano roll: the same grid-based note editor you'd find in FL Studio, Ableton, or Logic Pro.

Except I built mine from scratch in React, using nothing but div elements, pointer events, and coordinate math.

This is how it works, what went wrong, and what I'd do differently.


Why Not Use a Library?

I tried. Here's what I found:

abcjs — Renders sheet music notation, not a piano roll grid. Meant for displaying, not editing. I initially used it for parsing ABC notation but ended up replacing it with a custom parser because the library had rendering side effects I couldn't control.

MIDI.js / Tone.js — Audio playback libraries, not visual editors. Tone.js handles the sound. I needed something to handle the screen.

Canvas-based editors — A few exist on GitHub. But canvas means you lose React's component model, accessibility, and the ability to style notes with CSS. Every interaction becomes manual hit-testing.

I wanted notes to be real DOM elements. Clickable, draggable, style-able, inspectable in DevTools. So I built it with divs.


The Coordinate System

A piano roll is a 2D grid:

  • X axis = time (beats / bars)
  • Y axis = pitch (MIDI note numbers)

The fundamental challenge is converting between three coordinate spaces:

Musical space:  { bar: 3, offset_beats: 1.5, midi: 67, dur_beats: 0.5 }
         ↕
Grid space:     { col: 14, row: 5, width: 2_columns }
         ↕
Pixel space:    { left: 672px, top: 110px, width: 96px, height: 22px }
Enter fullscreen mode Exit fullscreen mode

Every interaction — click, drag, resize, playhead sync — requires round-trips between these three spaces.

Musical → Pixel

const PX_PER_BEAT = 48;    // each beat = 48 pixels wide
const PIANO_WIDTH = 56;     // left sidebar for key labels
const HEADER_HEIGHT = 20;   // top bar for bar numbers

function noteToPixel(note: FlatNote, rowHeight: number, beatsPerBar: number) {
  const totalBeat = note.bar * beatsPerBar + note.offset_beats;
  return {
    left: PIANO_WIDTH + totalBeat * PX_PER_BEAT,
    top:  HEADER_HEIGHT + (maxMidi - note.midi) * rowHeight,
    width: note.dur_beats * PX_PER_BEAT,
    height: rowHeight,
  };
}
Enter fullscreen mode Exit fullscreen mode

Notice maxMidi - note.midi: higher pitches go up on screen (lower Y value), which means the MIDI axis is inverted. I got this wrong the first time and spent an hour wondering why all my notes were upside down.

Pixel → Musical (for drag operations)

function pixelToMusical(clientX: number, clientY: number, gridRect: DOMRect) {
  const relX = clientX - gridRect.left - PIANO_WIDTH + scrollLeft;
  const relY = clientY - gridRect.top - HEADER_HEIGHT + scrollTop;

  const beat = relX / PX_PER_BEAT;
  const midi = maxMidi - Math.floor(relY / rowHeight);

  // Snap to grid
  const snappedBeat = Math.round(beat / snapBeat) * snapBeat;
  const bar = Math.floor(snappedBeat / beatsPerBar);
![ ](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/a55rmi6jitgljqcfrx85.png)
  const offset = snappedBeat - bar * beatsPerBar;

  return { bar, offset_beats: offset, midi };
}
Enter fullscreen mode Exit fullscreen mode

The snapBeat parameter controls quantization. 0.25 snaps to sixteenth notes. 0.5 snaps to eighth notes. Users don't see this math — they just feel their notes "locking" into place.


Rendering the Grid

The grid has several visual layers, rendered bottom-to-top:

  1. Row stripes — alternating colors for white/black piano keys
  2. Vertical lines — beat divisions (thicker every bar)
  3. Horizontal lines — pitch rows
  4. Notes — colored rectangles
  5. Playhead — vertical line during playback
  6. Selection marquee — rubber-band rectangle

All of these are absolutely positioned divs inside a scrollable container.

Black Key Detection

const NOTE_NAMES = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];

function isBlackKey(midi: number): boolean {
  return NOTE_NAMES[midi % 12].includes("#");
}
Enter fullscreen mode Exit fullscreen mode

Black-key rows get a darker background. This is pure cosmetics but makes the piano roll instantly readable — you can see octave boundaries at a glance.

Beat Lines

const totalBeats = bars * beatsPerBar;
const lines = [];

for (let b = 0; b <= totalBeats; b++) {
  const isBarLine = b % beatsPerBar === 0;
  lines.push(
    <div
      key={b}
      style={{
        position: 'absolute',
        left: b * PX_PER_BEAT,
        top: 0,
        width: 1,
        height: fullHeight,
        background: isBarLine 
          ? 'rgba(148,163,184,0.4)'   // strong line at bar boundaries
          : 'rgba(51,65,85,0.6)',     // subtle line at beats
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

I also add sub-beat lines at sixteenth-note intervals (every PX_PER_BEAT / 4 pixels) with very low opacity. They're invisible until you zoom in, but they give the grid a "real DAW" feel.


Note Rendering

Each note is a rounded rectangle with three interaction zones:

┌──────────────────────────┐
│ ◄──── body (drag) ────► ▐│ ◄── right edge (resize)
└──────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
function NoteBlock({ note, index }: { note: FlatNote; index: number }) {
  const pos = noteToPixel(note, rowHeight, beatsPerBar);
  const isSelected = selected.includes(index);

  return (
    <div
      style={{
        position: 'absolute',
        left: pos.left,
        top: pos.top,
        width: Math.max(pos.width, 6), // minimum visible width
        height: pos.height - 2,        // 1px gap between rows
        borderRadius: 3,
        background: isSelected ? SELECTED_COLOR : trackColor,
        cursor: 'grab',
      }}
      onPointerDown={(e) => handleNotePointerDown(e, index)}
    >
      {/* Resize handle */}
      <div
        style={{
          position: 'absolute',
          right: 0,
          top: 0,
          width: 6,
          height: '100%',
          cursor: 'ew-resize',
        }}
        onPointerDown={(e) => {
          e.stopPropagation();
          startResize(e, index);
        }}
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Math.max(pos.width, 6) is critical. Very short notes (a thirty-second note at normal zoom) would be 6 pixels wide — too small to click. The minimum width ensures every note is always grabbable.


Drag & Drop: The Hard Part

Dragging notes requires tracking three distinct modes:

type DragMode = 'none' | 'move' | 'resize' | 'create' | 'marquee';
Enter fullscreen mode Exit fullscreen mode

Move vs. Click Disambiguation

When a user presses down on a note, they might want to:

  • Click it (select)
  • Drag it (move)

I distinguish these with a dead zone:

const DRAG_THRESHOLD = 4; // pixels

function handlePointerMove(e: PointerEvent) {
  if (dragMode === 'none' && dragStartRef.current) {
    const dx = e.clientX - dragStartRef.current.x;
    const dy = e.clientY - dragStartRef.current.y;

    if (Math.hypot(dx, dy) > DRAG_THRESHOLD) {
      setDragMode('move');
    }
    return;
  }

  if (dragMode === 'move') {
    // Calculate new position with snap
    const newPos = pixelToMusical(e.clientX, e.clientY, gridRect);

    // Apply delta from original position
    updateNote(dragIdx, {
      bar: newPos.bar,
      offset_beats: newPos.offset_beats,
      midi: newPos.midi,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Without the dead zone, every click would turn into a tiny drag, and notes would jump by one pixel on every selection.

Resize

Resizing changes only the duration, not the position:

if (dragMode === 'resize') {
  const relX = e.clientX - gridRect.left - PIANO_WIDTH + scrollLeft;
  const endBeat = relX / PX_PER_BEAT;
  const startBeat = note.bar * beatsPerBar + note.offset_beats;

  let newDur = endBeat - startBeat;
  newDur = Math.max(snapBeat, Math.round(newDur / snapBeat) * snapBeat);

  updateNote(dragIdx, { dur_beats: newDur });
}
Enter fullscreen mode Exit fullscreen mode

The Math.max(snapBeat, ...) prevents notes from being resized to zero or negative length.

Creating Notes

Clicking on empty grid space creates a new note:

function handleGridClick(e: PointerEvent) {
  if (e.target !== gridRef.current) return; // only on empty space

  const pos = pixelToMusical(e.clientX, e.clientY, gridRect);

  const newNote: FlatNote = {
    bar: pos.bar,
    offset_beats: pos.offset_beats,
    midi: pos.midi,
    dur_beats: snapBeat * 4, // default: one beat
    vel: 80,
  };

  setLocal([...local, newNote]);
}
Enter fullscreen mode Exit fullscreen mode

Deleting Notes

Double-click or Delete key. I chose both because DAW users have different muscle memory:

// Double-click on note
function handleNoteDoubleClick(index: number) {
  const next = local.filter((_, i) => i !== index);
  setLocal(next);
  onChange?.(next);
}

// Delete key for selected notes (ripple delete)
function handleKeyDown(e: KeyboardEvent) {
  if (e.key === 'Delete' || e.key === 'Backspace') {
    const next = local.filter((_, i) => !selected.includes(i));
    setLocal(next);
    setSelected([]);
    onChange?.(next);
  }
}
Enter fullscreen mode Exit fullscreen mode

Multi-Select with Marquee

Rubber-band selection was the most technically fiddly feature. The marquee is a translucent rectangle that appears when you drag on empty space:

const [marquee, setMarquee] = useState<{
  x0: number; y0: number; x1: number; y1: number;
} | null>(null);

// During drag on empty space:
setMarquee({
  x0: startX,
  y0: startY,
  x1: currentX,
  y1: currentY,
});

// Render:
{marquee && (
  <div style={{
    position: 'absolute',
    left: Math.min(marquee.x0, marquee.x1),
    top: Math.min(marquee.y0, marquee.y1),
    width: Math.abs(marquee.x1 - marquee.x0),
    height: Math.abs(marquee.y1 - marquee.y0),
    background: 'rgba(99,102,241,0.2)',
    border: '1px solid rgba(99,102,241,0.6)',
    pointerEvents: 'none',
  }} />
)}
Enter fullscreen mode Exit fullscreen mode

On pointer-up, I check which notes intersect the marquee rectangle:

function getNotesInMarquee(marquee, notes) {
  const rect = normalizeRect(marquee);

  return notes.filter((note, i) => {
    const noteRect = noteToPixel(note, rowHeight, beatsPerBar);
    return rectsOverlap(rect, noteRect);
  });
}
Enter fullscreen mode Exit fullscreen mode

Shift+drag adds to the existing selection. Plain drag replaces it.


The Piano Keyboard (Left Sidebar)

The left sidebar shows piano key labels. It scrolls vertically with the grid but stays fixed horizontally:

<div style={{ 
  position: 'sticky', 
  left: 0, 
  zIndex: 10,
  width: PIANO_WIDTH 
}}>
  {Array.from({ length: maxMidi - minMidi + 1 }, (_, i) => {
    const midi = maxMidi - i;
    const isBlack = isBlackKey(midi);
    const label = midiToLabel(midi); // "C4", "F#5", etc.

    return (
      <div
        key={midi}
        style={{
          height: rowHeight,
          background: isBlack 
            ? 'linear-gradient(to bottom, #111827, #020617)'
            : 'linear-gradient(to bottom, #f9fafb, #e5e7eb)',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'flex-end',
          paddingRight: 4,
          fontSize: 10,
          fontWeight: label.startsWith('C') ? 700 : 400,
          color: isBlack ? '#e5e7eb' : '#111827',
        }}
      >
        {label}
      </div>
    );
  })}
</div>
Enter fullscreen mode Exit fullscreen mode

C notes are bold — they mark octave boundaries and help with visual orientation. It's a tiny detail but every DAW does it for a reason.


Playhead Synchronization

During playback, a vertical line sweeps across the grid, synchronized to Tone.js:

useEffect(() => {
  if (!isPlaying) return;

  const playheadX = PIANO_WIDTH + playBeat * PX_PER_BEAT;

  // Auto-scroll to keep playhead visible
  const viewport = viewportRef.current;
  if (viewport) {
    const viewLeft = viewport.scrollLeft;
    const viewRight = viewLeft + viewport.clientWidth;

    if (playheadX > viewRight - 100 || playheadX < viewLeft) {
      viewport.scrollLeft = playheadX - 200;
    }
  }
}, [playBeat, isPlaying]);
Enter fullscreen mode Exit fullscreen mode

The playBeat value comes from the parent component, which uses Tone.Transport.position converted to beats. The piano roll doesn't know about audio — it just receives a number and draws a line.

{isPlaying && (
  <div style={{
    position: 'absolute',
    left: PIANO_WIDTH + playBeat * PX_PER_BEAT,
    top: 0,
    width: 2,
    height: fullHeight,
    background: 'gold',
    zIndex: 20,
    pointerEvents: 'none',
  }} />
)}
Enter fullscreen mode Exit fullscreen mode

Scrolling: The Performance Trap

With 16+ bars and a 3-octave range, the grid can be thousands of pixels wide. Scrolling must be smooth.

My first approach: a giant container with all notes rendered. This worked until about 200 notes, then React's reconciliation got slow.

The fix: viewport culling. Only render notes that are visible:

const visibleNotes = local.filter((note) => {
  const px = noteToPixel(note, rowHeight, beatsPerBar);
  return (
    px.left + px.width > scrollLeft - 100 &&
    px.left < scrollLeft + viewportWidth + 100
  );
});
Enter fullscreen mode Exit fullscreen mode

The 100px buffer prevents pop-in at the edges during fast scrolling. Grid lines still render for the full width (they're cheap), but notes only render when visible.

I also switched from scroll events to tracking scrollLeft via a ref:

const [scrollLeft, setScrollLeft] = useState(0);

const onScroll = useCallback(() => {
  const el = viewportRef.current;
  if (el) setScrollLeft(el.scrollLeft);
}, []);
Enter fullscreen mode Exit fullscreen mode

This avoids re-rendering on every pixel of scroll — the state only updates when React needs it for note culling.


Dynamic Grid Sizing

The grid should always be wide enough to show all notes plus room to add more:

const EXTRA_EMPTY_BARS = 8;

const maxBarFromNotes = local.reduce(
  (max, n) => Math.max(max, n.bar + Math.ceil(n.dur_beats / beatsPerBar)),
  0
);

const totalBars = Math.max(
  bars,                          // minimum from props
  maxBarFromNotes + EXTRA_EMPTY_BARS,  // extend past last note
  16                             // absolute minimum
);

const gridWidth = totalBars * beatsPerBar * PX_PER_BEAT;
Enter fullscreen mode Exit fullscreen mode

The 8 extra empty bars mean you can always scroll past your last note and add more. As you add notes at the end, the grid grows automatically.


Row Height Auto-Scaling

The row height adapts to the container size and pitch range:

const rowsCount = maxMidi - minMidi + 1;
const availableHeight = containerHeight - HEADER_HEIGHT;
const rowHeight = Math.max(ROW_MIN_PX, availableHeight / rowsCount);
Enter fullscreen mode Exit fullscreen mode

ROW_MIN_PX (22px) prevents rows from becoming too tiny when the pitch range is large. If there are too many rows to fit, the container scrolls vertically.


What I'd Do Differently

Use useReducer instead of useState for notes. The local state management got complex with undo/redo potential. A reducer with action types (MOVE_NOTE, RESIZE_NOTE, DELETE_NOTES, CREATE_NOTE) would be cleaner.

Implement virtual scrolling for grid lines too. With 64+ bars, rendering thousands of grid-line divs is wasteful. A canvas underlayer for the grid with DOM overlays for notes might be the best of both worlds.

Add touch support earlier. Pointer events mostly work on mobile, but pinch-to-zoom and two-finger scroll needed special handling that I bolted on late.

Consider position: sticky for the header bar. My current implementation manually tracks scroll position for the bar-number header. CSS sticky positioning would have been simpler.


The Numbers

The final component is about 800 lines of TypeScript. It handles:

  • Rendering 16-64 bars of music
  • 3-5 octave pitch range
  • Click, drag, resize, create, delete, multi-select
  • Playhead sync with external audio engine
  • Keyboard shortcuts
  • Scroll-based viewport culling
  • Snap-to-grid quantization
  • Multiple selection with marquee

No external editor library. No canvas. No contentEditable. Just React, TypeScript, pointer events, and a lot of coordinate math.

The component is a controlled input — it receives notes and onChange, just like a text field. The parent component owns the data. The piano roll just renders it and reports edits.

If you want to see it in action, paste any AI-generated melody into melogen.app and start clicking around.


This is Part 3 of a series on building Melogen. Part 1: How I Built a Web Editor for AI Music. Part 2: Parsing Music JSON from 4 Different LLMs. Next: Using LLMs as a Music Mix Engineer — With Fallback Chains.


Tags: react typescript music webdev

Top comments (0)