Tailwind Typography Has a Dark-Mode Blind Spot
Every paragraph of bold emphasis on this site was, for a while, effectively invisible. Not dimmed. Not low-contrast-but-readable. Rendering at a measured contrast ratio of roughly 1:1 against the page background — dark gray ink on a near-black surface. The word was there in the HTML. It simply could not be seen.
Nobody caught it by eye. I read these posts. So do other people. Bold text is sparse enough — a few words per paragraph — that a single near-invisible <strong> doesn’t register as “broken,” it registers as “I guess that word wasn’t bold.” What caught it was the accessibility audit in our Lighthouse CI gate: the article template’s score slipped from 1.0 to 0.96, the color-contrast audit lit up, and it pointed at every <strong> in the body.
The fix was one CSS rule. The interesting part is the cascade that hid the bug — because it’s a trap that’s baked into how Tailwind’s typography plugin works, and it only springs in dark mode.
How .prose Gets Its Colors
We render article bodies with @tailwindcss/typography — the plugin that gives you the .prose class. You wrap your markdown-rendered HTML in <div class="prose"> and it styles headings, lists, links, code, blockquotes, and bold text with sensible defaults, so you don’t hand-write a stylesheet for raw content.
The plugin doesn’t hard-code colors into each element. It routes them through a set of CSS custom properties — north of a dozen of them — defined on .prose:
.prose {
--tw-prose-body: ...;
--tw-prose-headings: ...;
--tw-prose-links: ...;
--tw-prose-bold: ...;
--tw-prose-code: ...;
--tw-prose-quotes: ...;
/* ...and a dozen more */
}
Bold text, specifically, is styled like this in the compiled output:
.prose :where(strong):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
color: var(--tw-prose-bold);
font-weight: 600;
}
So the color of every bold word is whatever --tw-prose-bold resolves to. The default value the plugin ships is gray-900 — in Tailwind v4’s OKLCH palette, oklch(21% 0.034 264.665), which is about #101828. A very dark slate. Perfect on a white page. A problem on a dark one.
For dark mode, the plugin’s intended mechanism is the prose-invert modifier. Add dark:prose-invert to the element and, under your dark selector, it swaps every --tw-prose-* variable for a parallel --tw-prose-invert-* value:
.dark\:prose-invert:is([data-theme="dark"] *) {
--tw-prose-bold: var(--tw-prose-invert-bold); /* defaults to #fff */
/* ...all the others */
}
So in theory: light surface gets dark bold, dark surface gets white bold, and you never think about it again. Our article template does exactly what the docs say — class="prose ... dark:prose-invert". So how did bold end up dark-on-dark?
The Seam Where Bold Fell Through
The bug lives in the gap between three theming systems that don’t quite agree on when “dark” is true.
System one: the surface is unconditionally dark. Static Signal is a dark-first site. The base palette lives in :root and is never overridden away:
:root {
--surface-base: #0a0f1a;
--text-heading: #ffffff;
--text-body: #b0bec5;
}
body {
background: var(--surface-base); /* dark, always */
}
There is a [data-theme="light"] selector in the stylesheet, but it’s vestigial — it does nothing but opacity: 1. There is no real light theme. Whatever data-theme ends up being, the background stays #0a0f1a.
System two: data-theme is decided at runtime, and it isn’t always dark. The HTML ships with <html data-theme="dark"> hard-coded, but a no-flash inline script runs before paint and can change it:
var preference = window.localStorage.getItem('ss-theme')
var themeToSet = 'dark'
if (preference === 'dark' || preference === 'light') {
themeToSet = preference
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches === false) {
themeToSet = 'light' // <-- no stored preference + no OS dark mode
}
document.documentElement.setAttribute('data-theme', themeToSet)
A first-time visitor whose OS is in light mode gets data-theme="light". So does Lighthouse, which runs with no stored preference and doesn’t emulate dark mode by default. The surface is still dark — but the attribute now says light.
System three: bold’s dark treatment is gated on data-theme="dark". That dark:prose-invert rule only fires under a [data-theme="dark"] ancestor. The moment the script writes data-theme="light", prose-invert switches off and --tw-prose-bold snaps back to its base default — gray-900, #101828.
Now stack them up. Background: #0a0f1a (dark, unconditionally). Bold text: #101828 (dark, because prose-invert is off). Contrast between the two:
#101828 on #0a0f1a → ~1.1:1
WCAG AA wants 4.5:1 for normal text. We were delivering roughly 1:1. The bold was, for all practical purposes, painted in the background color.
So why did only bold break, and not the body text, the headings, the code, the links? Because of a fourth thing:
/* globals.css — hand-written overrides, applied unconditionally */
.prose { color: var(--text-body); }
.prose h2, .prose h3 { color: var(--text-heading); }
.prose a { color: var(--purple-light); }
.prose code { color: var(--purple-light); }
.prose blockquote { color: var(--text-muted); }
Every common prose element had a hand-written rule pointing at a themed token, with no dependence on data-theme at all. Those elements looked correct in every state. But there was no hand rule for strong or b. Bold was the one element still relying on the plugin’s variable — the one piece left exposed when prose-invert blinked off.
Why You Never See It in Development
This is the part that makes the bug nasty rather than dumb. It requires two conditions to manifest at once, and developers rarely hit both:
data-themeresolves to something other thandark— which means no stored preference and an OS not in dark mode.- You’re looking at
<strong>specifically, which is a handful of words per article.
Most developers building a dark-first site run their OS in dark mode. Under prefers-color-scheme: dark, the script sets data-theme="dark", prose-invert is active, and bold renders white — perfectly readable. You could build the entire site, review every post, and ship, and the bug would stay completely hidden from you, because your own machine never satisfies condition one.
And even when the page does render in the broken state, bold is sparse. A reader skims past one washed-out word and their brain autocorrects. There is no layout shift, no console error, no thrown exception. It is a silent, intermittent, content-dependent accessibility failure — exactly the category of bug that human review is worst at and an automated gate is best at.
That’s the whole argument for the gate, made concrete. The Lighthouse CI accessibility audit doesn’t get bored, doesn’t run its OS in dark mode, and computes the actual contrast ratio on the actual rendered pixels. A 0.04 dip in a score is not a number a person notices. It’s exactly the kind of thing a machine should be watching so you don’t have to.
The One-Line Fix
Give bold its own unconditional rule, pointed at a themed token that’s defined the same way the background is — in :root, with no data-theme gate:
/* Bold emphasis: the typography plugin's default --tw-prose-bold (#101828) is
never themed, so unstyled <strong> rendered dark-on-dark in dark mode
(contrast ~1.0, failing WCAG AA and the a11y gate). Pin it to the themed
heading token so bold text stays readable on every surface. */
.prose strong,
.prose b {
color: var(--text-heading);
font-weight: 600;
}
--text-heading is #ffffff, set in :root, never overridden. White on #0a0f1a is about 17:1 — well past AA, well past AAA. Because the rule doesn’t depend on data-theme, it holds in the dark state, the light state, and the headless-Chrome-with-no-preference state that Lighthouse runs. The font-weight: 600 just matches what the plugin was already applying, so nothing shifts visually except the one thing that was broken.
Accessibility score back to 1.0. Bold text visible again. Total diff: ten lines, eight of them a comment explaining the trap to the next person.
The Real Lesson: Don’t Theme Half the Variables
The plugin isn’t at fault here. --tw-prose-bold had a perfectly reasonable default for a light page, and prose-invert would have handled dark mode correctly if we’d let it own the whole job. The bug was self-inflicted, and it has a general shape worth internalizing:
When you take manual control of prose theming, you own every --tw-prose-* variable — including the ones you forget. The moment you start hand-writing .prose strong overrides, or re-pointing some prose variables at your own tokens, you’ve opted out of the plugin’s all-or-nothing dark treatment for those elements. Anything you don’t explicitly handle keeps a default tuned for a white page. On a white page, that’s invisible because it’s correct. On a dark page, it’s invisible because it’s broken.
Two ways to stay out of the trap:
- Let
prose-invertdo the whole job. If you use the plugin’s dark mechanism, use it consistently, and make sure the selector it’s gated on (data-theme="dark", a.darkclass, whatever) is actually present whenever your background is dark. Our mistake was an unconditional dark surface paired with a conditional dark type treatment — the surface and the text disagreed about when it was night. - If you hand-theme, theme everything. Don’t re-color the four elements you happened to think of and leave the rest to defaults. Enumerate the
--tw-prose-*variables and confirm each one resolves to a token from your palette, not a plugin default.
And the audit move that would have caught this on day one: render a single article that exercises every prose element — a paragraph with bold, italic, inline code, a link, a blockquote, a list, a table, an <hr> — in your dark state, and run a contrast checker over it. One representative post, exercised fully, surfaces every themed-vs-unthemed gap at once.
The bug wasn’t the framework. It was the seam between three systems that each had a slightly different definition of “dark.” Bold fell through the seam, one near-invisible word at a time, until a machine that reads contrast ratios instead of meaning told us it was gone.
That’s what the gate is for.