Printing music with CSS Grid

Too often have I witnessed the improvising musician sweaty-handedly attempting to pinch-zoom an A4 pdf on a tiny mobile screen at the climax of a gig. We need fluid and responsive music rendering for the web!

Stephen Band

Music notation should be as accessible and as fluid as text is, on the web; that it is not, yet, is something of an afront to my sensibilities. Let us fix this pressing problem.

The Scribe prototype

SVG rendered by Scribe 0.2

Some years ago I prototyped a music renderer I called Scribe that outputs SVG from JSON. The original goal was to produce a responsive music renderer. It was a good demo, but to progress I was going to have to write a complex multi-pass layout engine, and, well, other things got in the way.

Shortly after making that I was busy adopting Grid into our projects at Cruncher when something about it struck me as familiar, and I wondered if it might not be an answer to some of the layout problems I had been tackling in Scribe.

The class .stave

The musical staff is grid-like. Pitch is plotted up the vertical axis and time runs left to right along the horizontal axis. I am going to define these two axes in two seperate classes. The vertical axis, defining grid rows, will be called .stave. We'll get to the time axis in a bit.

A .stave has fixed-size grid rows named with standard pitch names, and a background image that draws the staff. So for a treble clef stave the row map might look like this:


    .stave {
        display: grid;
        row-gap: 0;
        grid-template-rows:
            [A5] 0.25em [G5] 0.25em [F5] 0.25em [E5] 0.25em
            [D5] 0.25em [C5] 0.25em [B4] 0.25em [A4] 0.25em
            [G4] 0.25em [F4] 0.25em [E4] 0.25em [D4] 0.25em
            [C4] 0.25em ;

        background-image:    url('/path/to/stave.svg');
        background-repeat:   no-repeat;
        background-size:     100% 2.25em;
        background-position: 0 50%;
    }
    

Which, applied to a <div> gives us:

Ok. Not much to see, but on inspecting it we do see that each line and each space on the stave now has its own pitch-named grid line to identify each row:

Named grid rows

Placing pitches up the stave

Any given row on a stave may contain any of several pitches. The pitches G♭, G and G♯ must all sit on the G stave line, for example.

To place DOM elements that represent those pitches in their correct rows I am going to put pitch names in data-pitch attributes and use CSS to map data-pitch values to stave rows.


    .stave > [data-pitch^="G"][data-pitch$="4"] { grid-row-start: G4; }
    

This rule captures pitches that start with 'G' and end with '4', so it assigns pitches 'G♭4', 'G4' and 'G♯4' (and double flat 'G𝄫4' and double sharp 'G𝄪4') to the G4 row. That does need to be done for every stave row:


    .stave > [data-pitch^="A"][data-pitch$="5"] { grid-row-start: A5; }
    .stave > [data-pitch^="G"][data-pitch$="5"] { grid-row-start: G5; }
    .stave > [data-pitch^="F"][data-pitch$="5"] { grid-row-start: F5; }
    .stave > [data-pitch^="E"][data-pitch$="5"] { grid-row-start: E5; }
    .stave > [data-pitch^="D"][data-pitch$="5"] { grid-row-start: D5; }

    ...

    .stave > [data-pitch^="D"][data-pitch$="4"] { grid-row-start: D4; }
    .stave > [data-pitch^="C"][data-pitch$="4"] { grid-row-start: C4; }
    

That should give us enough to begin placing symbols on a stave! I have a bunch of SVG symbols that were prepared for the Scribe prototype, so let's try placing a couple on a stave:


    <div class="stave">
        <svg data-pitch="G4" class="head">
            <use href="#head[2]"></use>
        </svg>
        <svg data-pitch="E5" class="head">
            <use href="#head[2]"></use>
        </svg>
    </div>
    

That looks promising. Next, time.

The class .bar and its beats

Rhythm is perhaps a little trickier to handle. There is not one immediately obvious smallest rhythmic division to adopt that will support all kinds of rhythms. A judgement call must be made about what minimum note lengths and what cross-rhythms to support inside a grid.

A 24-column-per-beat approach supports beat divisions to evenly lay out eighth notes (12 columns), sixteenth notes (6 columns) 32nd notes (3 columns) as well as triplet values of those notes. It's a good starting point.

Here is a 4 beat bar defined as 4 × 24 = 96 grid columns, plus a column at the beginning and one at the end:


    .bar {
        column-gap: 0.03125em;
        grid-template-columns:
            [bar-begin]
            max-content
            repeat(96, minmax(max-content, auto))
            max-content
            [bar-end];
    }
    

Add a couple of bar lines as ::before and ::after content, and put a clef symbol in there centred on the stave with data-pitch="B4", and we get:


    <div class="stave bar">
        <svg data-pitch="B4" class="treble-clef">
            <use href="#treble-clef"></use>
        </svg>
    </div>
    

Inspect that and we see that the clef has dropped into the first column, and there are 96 zero-width columns, 24 per beat, each seperated by a small column-gap:

Named grid rows

Placing symbols at beats

This time I am going to use data-beat attributes to assign elements a beat, and CSS rules to map beats to grid columns. The CSS map looks like this, with a rule for each 1/24th of a beat:


    .bar > [data-beat^="1"]    { grid-column-start: 2; }
    .bar > [data-beat^="1.04"] { grid-column-start: 3; }
    .bar > [data-beat^="1.08"] { grid-column-start: 4; }
    .bar > [data-beat^="1.12"] { grid-column-start: 5; }
    .bar > [data-beat^="1.16"] { grid-column-start: 6; }
    .bar > [data-beat^="1.20"] { grid-column-start: 7; }
    .bar > [data-beat^="1.25"] { grid-column-start: 8; }

    ...

    .bar > [data-beat^="4.95"] { grid-column-start: 97; }
    

The attribute ^= starts-with selector makes the rule error-tolerant. At some point, inevitably, unrounded or floating point numbers will be rendered into data-beat. Two of their decimal places is enough to identify a 1/24th-of-a-beat grid column.

Put that together with our stave class and we are able to position symbols by beat and pitch by setting data-beat to a beat between 1 and 5, and data-pitch to a note name. As we do, the beat columns containing those symbols grow to accommodate them:


    <div class="stave bar">
        <svg class="clef" data-pitch="B4">…</svg>
        <svg class="flat" data-beat="1" data-pitch="Bb4">…</svg>
        <svg class="head" data-beat="1" data-pitch="Bb4">…</svg>
        <svg class="head" data-beat="2" data-pitch="D4">…</svg>
        <svg class="head" data-beat="3" data-pitch="G5">…</svg>
        <svg class="rest" data-beat="4" data-pitch="B4">…</svg>
    </div>
    

Ooo. Stems?

Yup. Tails?

Yup. The tail spacing can be improved (which should be achievable with margins) – but the positioning works.

Fluid and responsive notation

Stick a whole bunch of bars like these together in a flexbox container that wraps and we start to see responsive music:


    <figure class="flex">
        <div class="treble-stave stave bar">…</div>
        <div class="treble-stave stave bar">…</div>
        <div class="treble-stave stave bar">…</div>
        …
    </figure>
    

There are clearly a bunch of things missing from this, but this is a great base to start from. It already wraps more gracefully than I have yet seen an online music renderer do.

The space between the notes

Ignoring these beams for a moment, notice that note heads that occur closer in time to one another are rendered slightly closer together:

It's a subtle, deliberate effect created by the small column-gap, which serves as a sort of time 'ether' into which symbol elements slot. Columns themselves are zero width unless there is a note head in them, but there are more column-gaps – 24 per beat – between events that are further apart in beats, and so more distance.

Constant spacing can be controlled by adjusting margins on the symbols. To get a more constant spacing here we would reduce the column-gap while increasing the margin of note heads:

But ugh, that looks bad, because the head spacings give the reader no clue as to how rapid the rhythm is. The point is, CSS is giving us some nice control over the metrics. And the aim now is to tweak those metrics for readability.

Clefs and time signatures

You may be wondering why I employed seperate classes for vertical and horizontal spacing, why not just one? Seperating the axes means that one can be swapped out without the other. Take the melody:

0.5 B3 0.2 1.5 2 D4 0.2 1 3 F#4 0.2 1 4 E4 0.2 1 5 D4 0.2 1 6 B3 0.2 0.5 6.5 G3 0.2 0.5

To display this same melody on a bass clef, the stave class can be swapped out for a bass-stave class that maps the same data-pitch attributes to bass stave lines:


    <div class="bass-stave bar">...</div>
    
0.5 B3 0.2 1.5 2 D4 0.2 1 3 F#4 0.2 1 4 E4 0.2 1 5 D4 0.2 1 6 B3 0.2 0.5 6.5 G3 0.2 0.5

Or, with CSS that mapped data-duration="5" to 120 grid-template-columns on .bar, the same stave could be given a time signature of 5/4:


    <div class="bass-stave bar" data-duration="5">...</div>
    
0.5 B3 0.2 1.5 2 D4 0.2 1 3 F#4 0.2 1 4 E4 0.2 1 5 D4 0.2 1 6 B3 0.2 0.5 6.5 G3 0.2 0.5

Clearly I am glossing over a few details. Not everything is as simple as a class change, and a few stems and ledger lines need repositioned.

Here's a stave class that remaps pitches entirely. General MIDI places drum and percussion voices on a bunch of notes in the bottom octaves of a keyboard, but those notes are not related to where drums are printed on a stave. In CSS a drums-stave class can be defined that maps those pitches to their correct rows:


    <div class="drums-stave bar" data-duration="4">...</div>
    

4

4


    <div class="percussion-stave bar" data-duration="4">...</div>
    

4

4

That's some very readable drum notation. I'm pretty pleased with that.

Chords and lyrics

CSS Grid allows us to align other symbols inside the notation grid too. Chords and lyrics, dynamics and so on can be lined up with, and span, timed events:

4

4

In

Amaj

the

bleak

Amaj/G

mid-

win-

C79

ter,

F-7

A7sus

Frost-

Dmaj

y

wind

B/D

made

moan–

E7sus9

C/E

But what about those beams?

Beams, chords and some of the longer rests are made to span columns by mapping their data-duration attributes to grid-column-end span values:


    .stave > [data-duration="0.25"] { grid-column-end: span 6; }
    .stave > [data-duration="0.5"]  { grid-column-end: span 12; }
    .stave > [data-duration="0.75"] { grid-column-end: span 18; }
    .stave > [data-duration="1"]    { grid-column-end: span 24; }
    .stave > [data-duration="1.25"] { grid-column-end: span 30; }
    ...
    

Simple as, bru.

Sizing

Lastly, the whole system is sized in em, so to scale it we simply change the font-size:

0 meter 2 1 0 E4 1 0.5 1 B4 1 0.5 2 Bb4 1 2

Limits of Flex and Grid

Is it the perfect system? Honestly, I'm quietly gobsmacked that it works so well, but if we are looking for caveats… 1. CSS cannot automatically position a new clef/key signature at the beginning of each wrapped line, or 2. tie a head to a new head on a new line. And 3., angled beams are a whole story onto themselves; 1/16th and 1/32nd note beams are hard to align because we cannot know precisely where their stems are until after the Grid has laid them out:

So it's going to need a bit of tidy-up JavaScript to finish the job completely, but CSS shoulders the bulk of the layout work here, and that means far less layout work to do in JavaScript.

Let me know what you think

If you like this CSS system or this blog post, or if you can see how to make improvements, please do let me know. I'm on Bluesky @stephen.band, Mastodon @stephband@front-end.social, and Twitter (still, just about) @stephband. Or join me in making this in the Scribe repo...

<scribe-music>

A custom element for rendering music

I have written an interpreter around this new CSS system and wrapped that up in the element <scribe-music>. It's nowhere near production-ready, but as it is already capable of rendering a responsive lead sheet and notating drums I think it's interesting and useful.

Whazzitdo?

The <scribe-music> element renders music notation from data found in it's content:


        <scribe-music type="sequence">
            0 chord D maj 4
            0 F#5 0.2 4
            0 A4  0.2 4
            0 D4  0.2 4
        </scribe-music>
    
0 chord Dmaj 4 0 F#5 0.2 4 0 A4 0.2 4 0 D4 0.2 4

Or from a file fetched by its src attribute, such as this JSON:


        <scribe-music
            clef="drums"
            type="application/json"
            src="/static/blog/printing-music/data/caravan.json">
        </scribe-music>
    

Or from a JS object set on the element's .data property.

There's some basic documentation about all that in the README.

Try it out

You can try the current development build by importing these files in a web page:


        <link rel="stylesheet" href="https://stephen.band/scribe/scribe-music/module.css" />
        <script type="module" src="https://stephen.band/scribe/scribe-music/module.js"></script>
    

As I said, it's in development. Asides from some immediate improvements I can make to Scribe 0.3, like tuning the autospeller, fixing the 1/16th-note beams and detecting and displaying tuplets, some longer-term features I would like to investigate are:

I leave you with a transposable lead sheet for Dolphin Dance, rendered by <scribe-music>:

Dolphin Dance

Herbie Hancock

[ [0, "chord", "C", "∆", 4], [4, "chord", "G", "-", 4], [8, "chord", "C", "∆", 4], [12, "chord", "B", "ø", 2], [14, "chord", "E", "7alt", 2], [16, "chord", "A", "-", 4], [20, "chord", "F", "∆(♯11)", 2], [22, "chord", "E", "7alt", 2], [24, "chord", "A", "-", 4], [28, "chord", "F♯", "-7", 2], [30, "chord", "B", "7", 2], [32, "chord", "E", "∆", 4], [36, "chord", "F", "-7", 4], [40, "chord", "D", "-7", 6], [46, "chord", "E", "7alt", 2], [48, "chord", "A", "-7", 8], [56, "chord", "F♯", "-7", 4], [60, "chord", "B", "7", 4], [64, "chord", "E", "∆", 4], [68, "chord", "E", "7sus", 4], [72, "chord", "E", "∆♯11", 4], [76, "chord", "E", "7sus", 4], [80, "chord", "D", "7sus", 4], [84, "chord", "D", "∆♯11", 4], [88, "chord", "D", "7sus", 4], [92, "chord", "C♯", "-7", 2], [94, "chord", "F♯", "7", 2], [96, "chord", "C", "7♯11", 4], [100, "chord", "F♯", "-7", 2], [102, "chord", "B", "7", 2], [104, "chord", "G♯", "-7", 2], [108, "chord", "C♯", "7", 2], [110, "chord", "B", "-7", 2], [112, "chord", "B♭", "-7", 4], [116, "chord", "E♭", "7", 4], [120, "chord", "C♯", "-7", 4], [124, "chord", "C♯", "-♭6", 4], [128, "chord", "C♯", "-7", 4], [132, "chord", "C♯", "-♭6", 4], [136, "chord", "C", "7sus", 4], [140, "chord", "C", "∆♭6", 4], [144, "chord", "C", "7sus♭9", 4], [148, "chord", "E", "7alt", 4], [2, "note", 76, 0.25, 0.5], [2.5, "note", 77, 0.25, 0.5], [3, "note", 79, 0.25, 0.5], [3.5, "note", 74, 0.25, 3.5], [10, "note", 76, 0.25, 0.5], [10.5, "note", 77, 0.25, 0.5], [11, "note", 79, 0.25, 0.5], [11.5, "note", 74, 0.25, 3.5], [18, "note", 72, 0.25, 0.5], [18.5, "note", 74, 0.25, 1], [19.5, "note", 76, 0.25, 0.5], [20, "note", 71, 0.25, 1], [21, "note", 71, 0.25, 2], [26, "note", 72, 0.25, 0.5], [26.5, "note", 74, 0.25, 0.5], [27, "note", 76, 0.25, 0.5], [27.5, "note", 71, 0.25, 3.5], [31, "note", 69, 0.25, 1], [32, "note", 68, 0.25, 1.5], [33.5, "note", 75, 0.25, 2.5], [36, "note", 75, 0.25, 1.5], [37.5, "note", 75, 0.25, 0.5], [38, "note", 77, 0.25, 0.5], [38.5, "note", 75, 0.25, 0.5], [39, "note", 77, 0.25, 0.5], [39.5, "note", 79, 0.25, 4.5], [48, "note", 76, 0.25, 1.5], [49.5, "note", 79, 0.25, 2.5], [52, "note", 79, 0.25, 1], [53, "note", 79, 0.25, 0.5], [53.5, "note", 79, 0.25, 0.5], [54, "note", 81, 0.25, 0.5], [54.5, "note", 79, 0.25, 0.5], [55, "note", 81, 0.25, 0.5], [55.5, "note", 83, 0.25, 4.5], [66, "note", 80, 0.25, 0.5], [66.5, "note", 81, 0.25, 0.5], [67, "note", 83, 0.25, 0.5], [67.5, "note", 78, 0.25, 3.5], [74, "note", 76, 0.25, 0.5], [74.5, "note", 78, 0.25, 0.5], [75, "note", 80, 0.25, 0.5], [75.5, "note", 74, 0.25, 3.5], [82, "note", 72, 0.25, 0.5], [82.5, "note", 74, 0.25, 1], [83.5, "note", 76, 0.25, 0.5], [84, "note", 71, 0.25, 1], [85, "note", 71, 0.25, 2], [90, "note", 72, 0.25, 0.5], [90.5, "note", 74, 0.25, 1], [91, "note", 76, 0.25, 0.5], [91.5, "note", 78, 0.25, 4.5], [96, "note", 78, 0.25, 0.5], [96.5, "note", 79, 0.25, 0.5], [97.5, "note", 78, 0.25, 0.25], [97.75, "note", 77, 0.25, 0.25], [98, "note", 78, 0.25, 1], [99, "note", 78, 0.25, 0.5], [99.5, "note", 83, 0.25, 0.5], [100.5, "note", 80, 0.25, 3.5], [104, "note", 80, 0.25, 0.5], [104.5, "note", 82, 0.25, 0.5], [105.5, "note", 80, 0.25, 0.25], [105.75, "note", 78, 0.25, 0.25], [106, "note", 80, 0.25, 1], [107, "note", 80, 0.25, 0.5], [107.5, "note", 85, 0.25, 2.5], [110, "note", 86, 0.25, 2], [112, "note", 87, 0.25, 1.5], [113.5, "note", 85, 0.25, 1], [114.5, "note", 80, 0.25, 1], [115.5, "note", 77, 0.25, 0.5], [116, "note", 84, 0.25, 3], [119, "note", 75, 0.25, 0.5], [119.5, "note", 80, 0.25, 8.5], [138, "note", 81, 0.25, 0.5], [138.5, "note", 82, 0.25, 0.5], [139, "note", 84, 0.25, 0.5], [139.5, "note", 79, 0.25, 3.5], [146, "note", 76, 0.25, 0.5], [146.5, "note", 77, 0.25, 0.5], [147, "note", 79, 0.25, 0.5], [147.5, "note", 74, 0.25, 3.5] ]

Make your website sing

Email hello@cruncher.ch Telephone +41 21 546 68 00