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 24 Apr 2024
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
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:
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
:
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.5To 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>
<div class="percussion-stave bar" data-duration="4">...</div>
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
A♭maj
the
bleak
A♭maj/G
mid-
win-
C7♭9
ter,
F-7
A♭7sus
Frost-
D♭maj
y
wind
B♭/D
made
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
:
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 @[email protected], 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:
- Support for SMuFL fonts – changing the font used for notation symbols. So far I have not been able to display their extended character sets reliably cross-browser.
- Support for nested sequences – enabling multi-part tunes.
- Split-stave rendering – placing multiple parts on one stave. The mechanics for this are already half in place – the drums stave and piano stave currently auto-split by pitch.
- Multi-stave rendering – placing multiple parts on multiple, aligned, staves.
I leave you with a transposable lead sheet for Dolphin Dance, rendered by <scribe-music>
: