Your favorite dark theme leaks contrast. Here are the numbers.

I measured every major token role — keywords, types, strings, parameters, comments — across the most popular dark themes. Comments are the canary; parameters and types quietly miss too. A tier-based framework and the fix.

Research · WCAG 2.1A field audit of every major role across the top dark themes.
failpassAA · 4.5 : 1

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.

Harder to readEasier to read
One Dark2.32 : 1
Solarized Dark2.79 : 1
Dracula3.03 : 1
Monokai3.03 : 1
Night Owl3.87 : 1
Themery · God Tier5.68 : 1

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:

TierTargetExamples
Critical7.0 : 1Errors, primary labels
Structural5.5 : 1Keywords, operators
Semantic4.5 : 1Types, strings, numbers
Contextual3.5 : 1Parameters, properties
Ambient2.5 : 1Comments, 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:

ThemeKeywordtarget 5.5Typetarget 4.5Stringtarget 4.5Parametertarget 3.5Commenttarget 2.5
Draculaall roles pass
return5.97
string10.29
“hi”12.74
input8.36
// …3.03
One Dark2 of 5 miss target
return4.75
string8.10
“hi”6.94
input6.57
// …2.32
Monokai1 of 5 miss target
return3.93
string9.01
“hi”10.44
input6.81
// …3.03
Solarized Dark2 of 5 miss target
return4.69
string4.68
“hi”4.75
input3.26
// …2.79
Night Owlall roles pass
return7.62
string12.37
“hi”11.22
input7.98
// …3.87
Themery · God Tierall roles pass
return10.64
string9.28
“hi”9.73
input9.11
// …5.68

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 (#abb2bf on 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:

ThemeAs shippedAA?+15% lightnessAA?
Dracula
// computed at compile time
const ratio = '4.5:1'
3.03 : 1
// computed at compile time
const ratio = '4.5:1'
5.89 : 1
Monokai Classic
// computed at compile time
const ratio = '4.5:1'
3.03 : 1
// computed at compile time
const ratio = '4.5:1'
5.59 : 1
One Dark
// computed at compile time
const ratio = '4.5:1'
2.32 : 1
// computed at compile time
const ratio = '4.5:1'
4.40 : 1
Solarized Dark
// computed at compile time
const ratio = '4.5:1'
2.79 : 1
// computed at compile time
const ratio = '4.5:1'
5.21 : 1
Night Owl
// computed at compile time
const ratio = '4.5:1'
3.87 : 1
// computed at compile time
const ratio = '4.5:1'
7.04 : 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.

semver.ts — One Dark
// 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] }
}
One Dark · comment #5c6370 on #282c342.32 : 1 · fails AA

A 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:

  1. Grab the theme's JSON out of its repo. Find the failing role.
  2. Convert the hex to OKLCH (or HSL). Lift lightness by ~10–15%. Keep hue and chroma alone.
  3. Re-measure against the role's tier target. Most roles clear their floor in one pass.
  4. 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.

Themery · palette reportGod Tier
5.68Min ratio
8.89Avg ratio
5 / 5Roles pass
// 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'
KeywordStructural
10.64/ 5.5
TypeSemantic
9.28/ 4.5
StringSemantic
9.73/ 4.5
ParameterContextual
9.11/ 3.5
CommentAmbient
5.68/ 2.5
Emits to
VS Code.jsonJetBrains.iclsZed.jsonNeovim.luaSublime.tmThemeWindows Terminal.json

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-w3 v0.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.
Role → scope mapping
  • keywordkeyword.control
  • typeentity.name.type, support.type
  • stringstring.quoted
  • parametervariable.parameter
  • commentcomment.line, comment.block