I measured contrast for every major token role — keywords, types, strings, parameters, comments — across the most-installed dark themes on the VS Code marketplace. Comments get all the press, but they're just the loudest symptom. Once you look at the full role budget, it's clear these themes are leaking contrast everywhere.
I'm not here to dunk. Most of these themes were designed before anyone took dark-mode accessibility seriously, and their authors built something people clearly love. I've built one too — Hack The Box, 250k+ installs, and it was failing targets in places until I started measuring. This post is the post I wish someone had written at me three years ago.
Comment-vs-background contrast for five top marketplace themes, plotted against WCAG's AA and AAA thresholds. Every popular theme sits in the fail zone. The green dot is the Themery reference palette — same role, built against the tier floor.
A theme is a contrast budget
Your editor renders ~15 semantic roles every second: keywords, types, strings, identifiers, parameters, properties, punctuation, comments, line numbers, gutters, selections, highlights. Each role carries different informational weight — an error label matters more than a comment — so each role deserves a different contrast target.
“Contrast” here means the WCAG 2.1 ratio — a number between 1 and 21 derived from the relative luminance of two colors. In code, it's just this:
// WCAG 2.1 contrast between two hex colors — returns 1.0 .. 21.0 function relLum(rgb) { const f = v => (v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2]) } function contrast(a, b) { const la = relLum(a), lb = relLum(b) const hi = Math.max(la, lb), lo = Math.min(la, lb) return (hi + 0.05) / (lo + 0.05) } contrast([98, 114, 164], [40, 42, 54]) // Dracula comment vs bg → 2.94
The full WCAG 2.1 ruler in ten lines. Colored with the god-tier palette measured later in the post.
That single function is the whole ruler. Feed it any role color and the theme background and you get a number. The framework I use (and which I wrote up in detail here) buckets roles into five tiers, each with a target ratio:
| Tier | Target | Examples |
|---|---|---|
| Critical | 7.0 : 1 | Errors, primary labels |
| Structural | 5.5 : 1 | Keywords, operators |
| Semantic | 4.5 : 1 | Types, strings, numbers |
| Contextual | 3.5 : 1 | Parameters, properties |
| Ambient | 2.5 : 1 | Comments, indent guides |
Measuring a whole theme is then just a loop:
const TARGETS = { keyword: 5.5, type: 4.5, string: 4.5, parameter: 3.5, comment: 2.5 } function auditTheme(theme) { const bg = hexToRgb(theme.bg) return Object.entries(theme.roles).map(([role, hex]) => { const ratio = contrast(bg, hexToRgb(hex)) return { role, hex, ratio, target: TARGETS[role], pass: ratio >= TARGETS[role] } }) } auditTheme({ bg: '#282a36', roles: { keyword: '#ff79c6', type: '#8be9fd', comment: '#6272a4' }, }) // → [{ role: 'keyword', ratio: 4.83, pass: false }, ...]
The loop behind the matrix below — one ratio per role, compared to that role's tier target.
Here's what that audit looks like across every role, every theme:
Every role in every theme, measured against its tier target. Green ratios clear the floor; red ones miss. Keywords hold up; parameters and types quietly don't.
A few things jump out:
- Keywords are usually fine. Theme authors pick one “hero” color — the vibrant pink or purple you see in screenshots — and tune it aggressively. It lands.
- Types and strings are a coin flip. Secondary roles inherit less attention. Dracula's cyan types are great; One Dark's gold types sit right on the line.
- Parameters quietly fail. Several themes render parameters in the foreground color (
#abb2bfon One Dark) which is fine — or in a muted orange/tan that slips below the Contextual tier. - Comments fail in every popular dark theme. This is the canary. It's also where the per-role pattern is cleanest.
Zooming in on the canary
Comments deserve their own look because they're the single role where theme authors make the intentional choice to reduce contrast — they want comments to recede. That makes comments the clearest window into the gap between intent and perception. AA requires 4.5:1 for body text and 3:1 for large text. The comment target on my tier system is 2.5:1 — lower than AA body text on purpose, since the Ambient tier is explicitly meant to recede.
Even at that relaxed floor, most popular themes fall through it:
// computed at compile time const ratio = '4.5:1'
// computed at compile time const ratio = '4.5:1'
// computed at compile time const ratio = '4.5:1'
// computed at compile time const ratio = '4.5:1'
// computed at compile time const ratio = '4.5:1'
// computed at compile time const ratio = '4.5:1'
// computed at compile time const ratio = '4.5:1'
// computed at compile time const ratio = '4.5:1'
// computed at compile time const ratio = '4.5:1'
// computed at compile time const ratio = '4.5:1'
Each theme's comment color rendered as shipped, alongside the same hue lifted ~15% in lightness. The lift clears the Ambient floor without touching the theme's mood.
The canary isn't the point — it's the signal. If the most-sweated role in the theme is missing its target, the roles that didn't get the same attention almost certainly are too.
Why this happens
Designers pick colors by hue — muted purple, olive, slate — and then adjust lightness by eye. Human eyes are terrible at judging contrast ratios directly, especially on dark backgrounds where our perception compresses. A color that looks like a clearly-visible secondary on a dark bg will often measure below the accessibility floor.
The decoupling you want looks like this in OKLCH, where lightness, chroma, and hue are truly independent:
// Same hue, same chroma, different lightness → different contrast oklch(45% 0.08 260) // on #282a36 → 2.94:1 passes ambient (2.5) oklch(55% 0.08 260) // on #282a36 → 4.12:1 passes contextual (3.5) oklch(65% 0.08 260) // on #282a36 → 5.88:1 passes structural (5.5) // Pick the tier target first. Solve for lightness. Hue stays free.
Three lightness steps at identical hue and chroma. Each one lands in a different tier — choose the target, solve for L.
It compounds. A theme maker will squint at their laptop at 3pm and nudge the comment color two notches darker because “it was too loud.” A user opens the theme at 10pm on a different monitor, and the comment is gone. The same drift happens to parameters, punctuation, italics — every role the author didn't personally stare at.
// Parse a semver string into its three parts. // Returns null for anything that does not match. export function parseVersion(input: string) { const match = input.match(/^(\d+)\.(\d+)\.(\d+)$/) if (!match) return null // TODO: handle pre-release and build metadata return { major: +match[1] } }
#5c6370 on #282c342.32 : 1 · fails AAA real One Dark snippet at reading size. Squint: the comment measures 2.32:1 against the editor background — below the Ambient floor and well under AA body text.
WCAG 2 is the floor, not the ceiling
Every number in this post uses the WCAG 2.1 relative-luminance formula. That formula was derived in the 1980s for paper. It over-credits dark backgrounds — it tells you black-on-grey and white-on-grey have the same contrast, which is plainly false to any human.
APCA (Advanced Perceptual Contrast Algorithm) is the model that replaces it in the WCAG 3 draft. It reports every dark-mode role as less legible than WCAG 2 does. Dracula's comments score roughly Lc 45 on APCA where Lc 60is the recommended minimum for body text. The parameter roles that “barely pass” 3:1 under WCAG 2 fall well short under APCA.
Translation: the numbers above are the optimistic read. The pessimistic read is worse.
If you love your theme anyway
The fix is per-role, but the recipe is the same for each:
- Grab the theme's JSON out of its repo. Find the failing role.
- Convert the hex to OKLCH (or HSL). Lift lightness by ~10–15%. Keep hue and chroma alone.
- Re-measure against the role's tier target. Most roles clear their floor in one pass.
- Open a PR on the theme repo. Most maintainers will take it.
The whole thing fits in a function:
import { converter, formatHex } from 'culori' const toOklch = converter('oklch') const toRgb = converter('rgb') // Lift lightness until the role hits its tier target. function liftToTarget(hex, bgHex, target) { let c = toOklch(hex) while (contrast(rgbArr(toRgb(c)), rgbArr(toRgb(bgHex))) < target && c.l < 0.98) { c = { ...c, l: c.l + 0.01 } } return formatHex(c) } liftToTarget('#6272a4', '#282a36', 4.5) // Dracula comment → '#8593c2' // Same hue, same chroma, now passes AA body text.
Lift lightness in OKLCH until the role clears its tier target. Hue and chroma are preserved — the mood of the theme survives the fix.
Don't overcorrect. The goal is to hit the tier target, not to crank every role to AAA. Comments should recede. Parameters shouldsit below types. The ruler isn't “more contrast is better” — it's “each role at its intended weight.”
What I built
I built Themery because after three years of shipping Hack The Box I was tired of finding these bugs in production. Themery enforces a contrast floor per role tier at compile time, so this class of bug cannot ship. One palette, every major IDE, every role provably within its target.
// Lift a role's lightness until it clears its tier target. function liftToTier(hex: string, target: number) { const color = toOklch(hex) while (contrast(color, bg) < target) { color.l += 0.01 } return formatHex(color) } liftToTier('#6272a4', 4.5) // → '#8593c2'
One source palette, every major IDE — the per-role contrast floor is enforced at build time, so a failing role can't ship.
Methodology
- Contrast formula
- WCAG 2.1 relative-luminance ratio. APCA figures use
apca-w3v0.1.9. - Color source
- Hex values from each theme's upstream repo, not any IDE's local override or user customization.
- Rounding
- Ratios reported to two decimal places. No gamma correction beyond what the WCAG formula specifies.
- Reproducibility
- Every number in this post is recomputed in-browser from the hexes on render — view-source is the dataset.
- keyword
keyword.control - type
entity.name.type,support.type - string
string.quoted - parameter
variable.parameter - comment
comment.line,comment.block