Webeleon

Software

How We Render Chords in MDX

By Webeleon · 8 min read · Jun 18 2026

Every post on this blog is written in MDX, and the single most Webeleon-specific thing in that pipeline is how a chord symbol gets from my keyboard to your screen. A chord is not plain text. When I write G7 in a sentence, it carries a typeface, a harmonic function, and a colour — none of which survive if I just type the letters "G7" inline. This is the build note for how one tag becomes a styled, accessible, theme-aware chord: the <Chord> component, the way I colour chords by their harmonic role, the lazy-loaded notation engine, and why I never ship any of this as an image.

The authoring surface is a single tag

When I want a chord in prose, I write one thing and nothing else:

A <Chord fn="dominant">G7</Chord> resolving home.

That is the entire authoring contract. There is no wrapper markup, no class to remember, no inline style. The fn prop is a small, closed union — tonic, subdominant, or dominant — so a typo like fn="dominent" fails the type-check instead of rendering a mystery colour. I would rather the build break than a chord ship in the wrong function.

Under the hood, <Chord> is thin. It wraps a shared MonoToken atom that owns the Spline Sans Mono look, adds the optional function colour as a CSS-module class, and stamps a data-chord marker so tests and downstream tooling can find a chord without depending on a hashed class name. Its children render as plain React text — there is no dangerouslySetInnerHTML anywhere near it, so anything inside the tag is escaped, never parsed as HTML. A chord symbol is content, not a place to inject markup.

Colour is the harmonic function, not paint

The fn prop exists because a chord's job matters more than its letters. I tint every chord by its role in the harmony, using the same three brand tokens the apps use:

  • C is the tonic — home, resolution — and wears gold.
  • F is the subdominant — movement away — and wears teal.
  • G7 is the dominant — tension, drive — and wears rust.

That mapping is not a per-post decision. It is one entry in a token file, so a chord written in a theory post from a year ago looks identical to one written today, and both match the accent a reader sees inside the apps. It is deliberately two axes over one palette: inside an app the triad encodes harmonic function; at brand level the same three hues identify pillars and apps. A chord symbol on this blog is a small, honest instance of that system. If you want to see the colours doing real analytical work rather than sitting still as examples, the ii–V–I post is built almost entirely out of them.

A component map, not hand-written HTML

None of the brand styling lives in the MDX. It lives in a single component map that every element and custom tag resolves through at compile time. Markdown headings keep the id that rehype-slug injects, so in-page anchors still resolve; links are hardened; and Chord, Roman, Notation, and the rest are exposed by name so authors use them in .mdx without ever writing an import.

Mapping components instead of hand-writing HTML is the whole reason consistency is free. An author describes intent — "this is a dominant chord" — and the renderer owns exactly how that looks. There is no styling surface in the content layer for anyone to get wrong, including me at 11pm.

There is one MDX v3 nuance worth stating plainly, because it drives the next section. Raw HTML in an MDX body is parsed as JSX, not as sanitisable HTML, and a raw intrinsic tag like <script> compiles to a literal string tag that bypasses the component map entirely. So the map is the right place for styling and for hardening the tags I do render, but it cannot be the place I stop dangerous tags — a map override never runs for a tag that never consults the map.

The safety pass runs before React sees the tree

Because the post body is author-controlled and the same render helper feeds both the article page and the RSS/JSON feeds, it runs through a small rehype plugin before anything is rendered. Walking the hast tree, it removes dangerous elements outright — <script>, <iframe>, <object>, <embed>, <style> — strips every on* event-handler attribute, and drops any href or src that uses an unsafe scheme such as javascript:. It sweeps both node shapes MDX can produce: ordinary hast elements from Markdown, and the JSX nodes from raw HTML in the body.

Stripping the node from the tree is the only thing that works, precisely because of the compile nuance above: by the time MDX emits _jsx("script", …) it is too late for the map. Do the removal earlier, on the tree, and the tag is simply never emitted. The link override is defence in depth on top of that — external links still get rel="noopener noreferrer nofollow" and open in a new tab. The brand components and the heading ids are left untouched; the pass only removes what has no business rendering.

Notation, without shipping a picture

Chords are inline text. A full staff is not, and this is where authoring in MDX earns its keep. A <Notation> block takes ABC source and renders a real staff:

The notes of a C major chord, low to high: C, E, G, C.

A C major triad, spelled out on the staff

The engine that draws that staff, abcjs, is about half a megabyte unminified. It is never in the initial bundle. It is pulled in with a dynamic import('abcjs') inside an effect, so the bundler splits it into its own async chunk that only downloads on pages that actually mount a <Notation>. A reader landing on a prose-only post never pays for it.

The caption and the text fallback are rendered by React, not by abcjs, so they exist in the server HTML. They stay visible until a staff has genuinely been drawn, which means no-JS readers, pre-hydration readers, search crawlers, and anyone whose engine fails to load all still get the music as words. The staff target is exposed as role="img" with the caption as its accessible name, so a screen reader announces one labelled image instead of a tangle of SVG paths. And because abcjs fills with currentColor inside an always-light paper island, the notation resolves to the pinned paper ink in both the light and dark reading canvases — it never washes out when the page flips to dark.

Why MDX beats an image every time

The tempting shortcut for all of this is to draw each chord and staff once, export a PNG, and drop the image into the post. I have never regretted refusing to.

Text is searchable and indexable — a chord symbol in a sentence is content a crawler and a reader can both parse. It is diffable, so a chord change is one character in a git diff instead of a re-exported binary. It is accessible by default, because it is real text and real ARIA rather than pixels a screen reader has to guess at. And it is theme-aware for free: the colour of a chord lives in a token, so when I moved this whole site onto the Real Book palette, every chord in every post recoloured itself without my touching a single article. An image would have meant re-exporting every asset by hand, and the alt text would have drifted out of date the first time I edited the prose around it.

That is the same instinct that runs through the apps: the colour of a chord is supposed to mean something, and meaning should be authored once and reused, not baked into pixels. If you want to hear the functions these colours stand for — that gold C pulling a rust G7 home — that pull is exactly what I built Homechord to let you sketch and play.