How to halation: why light text on dark looks heavier — and how to compensate
- Step 1Understand the optics — Your eye's lens and the retina scatter incoming light. High-contrast edges — bright stroke against a dark field — produce more scatter than dark strokes on a bright field, because the bright pixels are the light source. The scattered light spreads the apparent edge outward, so strokes read thicker than their actual width.
- Step 2Recognise the display contribution — OLED pixels emit their own light and switch fully off for black, giving maximal local contrast at every stroke edge. Backlit LCDs leak some light through 'black' pixels, lowering the contrast and the bloom. So the same CSS looks heavier on an OLED phone than on an older LCD monitor — design for the OLED worst case.
- Step 3Counter the bloom with weight — Reduce the weight class by 100 for normal body weights (50 if you are already at 300 or below). A character at dark-mode weight 300 reads with the density of light-mode weight 400. This is the single biggest lever; do this even if you do nothing else.
- Step 4Counter stroke-merge with letter-spacing — When strokes bloom they encroach on their neighbours and adjacent letters can visually fuse. A small +0.01em of tracking restores the gap. It is subtle on its own but compounds with the weight drop to keep words legible at speed.
- Step 5Counter row-bleed with line-height — Bloom is vertical as well as horizontal. Ascenders and descenders of neighbouring lines glow toward each other. Adding +0.05 to line-height re-opens the vertical channel between rows. On dense reading interfaces this is the difference between calm and claustrophobic.
- Step 6Finish with colour, not just weight — The CSS adjuster handles weight, spacing, and leading. Pure white text on pure black maximises halation — pull text to an off-white (around #EAEAEA) and the background to an off-black (around #1C1C1E) to cut the bloom at its source. Those are separate declarations you add yourself.
Each perceptual cause and the property that counters it
Halation has three visible symptoms; each maps to one of the three properties the adjuster emits, plus colour which it does not.
| Symptom in dark mode | Cause | Counter-measure | Emitted by the tool? |
|---|---|---|---|
| Text looks bolder than the light version | Strokes bloom outward, reading as thicker | Lower font-weight by 50–100 | Yes |
| Adjacent letters fuse / words feel cramped | Horizontal bloom closes inter-letter gaps | Add +0.01em letter-spacing | Yes |
| Lines feel crowded / rows bleed | Vertical bloom closes inter-line gaps | Add +0.05 line-height | Yes |
| Whole page glares / strokes shimmer | Maximum contrast at pure white on pure black | Off-white text, off-black background | No — add by hand |
Perceived-density equivalence (weight)
How the −100 / −50 rule lines up perceptually. The dark-mode value on the right is engineered to read with the density of the light value on the left.
| Light weight (perceived) | Dark weight emitted | Reads in dark mode like |
|---|---|---|
| 400 Regular | 300 | ≈ light 400 |
| 500 Medium | 400 | ≈ light 500 |
| 600 Semibold | 500 | ≈ light 600 |
| 700 Bold | 600 | ≈ light 700 |
| 300 Light | 250 | ≈ light 300 (smaller −50 step) |
| 100 Thin | 100 | unchanged (clamped at floor) |
OLED vs LCD: why the same CSS looks different
The display technology changes how strong halation is, which is why the adjuster targets the OLED worst case rather than averaging across panels.
| Factor | OLED | Backlit LCD |
|---|---|---|
| Black pixels | Fully off, zero emission | Backlight leaks, dark grey |
| Edge contrast | Maximal | Reduced by leakage |
| Halation strength | Strong | Weaker |
| Effect of brightness | Grows sharply at high brightness | Grows, but capped by leakage |
| Design implication | Compensate fully — this is the worst case | Compensation is still correct, just less visible |
Cookbook
The same deltas seen through a 'why' lens — useful when documenting the rule for a team. The numbers come straight from the adjuster; the prose here is the justification you can paste into a design-system doc.
Body copy: the canonical case
ExampleThe textbook halation fix. A regular body at 400 reads heavy in dark mode; drop to 300 and add the two small deltas. This is the rationale plus the resulting CSS.
Why: light 400 blooms to ~500 in dark mode → drop 100.
Letters fuse → +0.01em. Rows bleed → +0.05 line-height.
body { font-weight: 400; letter-spacing: 0em; line-height: 1.5; }
@media (prefers-color-scheme: dark) {
body { font-weight: 300; letter-spacing: 0.01em; line-height: 1.55; }
}Headings bloom most — drop the full 100
ExampleHeavy weights bloom the hardest because there is more lit area per glyph. A 700 heading benefits most from the −100 drop. Negative tracking on big type also shifts toward zero.
Why: 700 has the most lit pixel area → blooms hardest → full -100.
h1 { font-weight: 700; letter-spacing: -0.01em; line-height: 1.1; }
@media (prefers-color-scheme: dark) {
h1 { font-weight: 600; letter-spacing: 0em; line-height: 1.15; }
}
(generated as body, then selector renamed to h1)Thin type has nowhere to go
ExampleAt weight 100 there is no thinner weight, so the rule clamps and the weight is unchanged. The two deltas still apply. The real fix for a too-bright Thin face is colour, not weight.
Why: 100 - 50 = 50, but font has no sub-100 weight → clamp to 100.
body { font-weight: 100; letter-spacing: 0em; line-height: 1.5; }
@media (prefers-color-scheme: dark) {
body { font-weight: 100; letter-spacing: 0.01em; line-height: 1.55; }
}
Fix the glare with colour instead: color: #EAEAEA on #1C1C1E.Pair the CSS adjustment with colour at the source
ExampleThe adjuster cannot emit colour, but halation is partly a contrast problem. Combine the generated typographic deltas with off-white text on an off-black background to attack the bloom from both sides.
/* from the adjuster */
@media (prefers-color-scheme: dark) {
body { font-weight: 300; letter-spacing: 0.01em; line-height: 1.55; }
}
/* added by hand — cuts halation at the source */
@media (prefers-color-scheme: dark) {
body { color: #EAEAEA; background: #1C1C1E; }
}Documenting the rule for contributors
ExampleDrop this annotated block into your design-system README so future contributors know why dark mode lowers weight rather than treating it as an arbitrary magic number.
Dark-mode typography rule (halation compensation): - font-weight: light - 100 (light - 50 if light <= 300), min 100 - letter-spacing: light + 0.01em - line-height: light + 0.05 Rationale: light strokes bloom on dark; these deltas restore light-mode perceived density. Generate with the JAD adjuster.
Edge cases and what actually happens
Every row below was probed against the live API. Some documented requirements (alphabetical axis order, numerical tuple order) are not actually enforced in practice — useful to know if you've been blaming the wrong thing for a 400.
Halation invisible on an old LCD monitor
Expected — panel dependentIf you design on a backlit LCD you may barely see the bloom, because the leaking backlight lowers edge contrast. The compensation is still correct — your OLED-phone users see the full effect. Always check the result on an OLED device before deciding the adjustment is 'too much'.
Adjustment looks excessive at low screen brightness
Expected — brightness dependentHalation scales with brightness: dominant in a bright room, near-invisible at minimum brightness in the dark. The fixed deltas target the high-brightness worst case, so at very low brightness the dark text can look a touch light. That trade-off favours the more common viewing condition; do not tune it away.
Thin (100) weight cannot be reduced
Clamped — by designThere is no font-weight below 100, so the rule clamps and Thin stays Thin in dark mode. If a Thin face still glares, the cause is contrast, not weight — switch to off-white text on an off-black background. The adjuster does not output colour.
Pure-white text amplifies the bloom you just compensated
Out of scope — add colour by handWeight, tracking, and leading only address the typography. #FFFFFF on #000000 is the maximum-contrast case and re-introduces glare the weight drop tried to remove. Pull text to ~#EAEAEA and the background to ~#1C1C1E. This is a colour declaration you add yourself.
High-contrast accessibility mode wants the opposite
Different audience — separate queryUsers who enable prefers-contrast: more want maximum legibility, which can mean keeping or increasing weight rather than reducing it. Halation compensation and high-contrast mode are different goals; handle them in separate media queries. The adjuster only emits the prefers-color-scheme: dark form.
Sub-pixel rendering differences across OSes
Minor — perceptual not measurablemacOS, Windows, and Linux render the same weight slightly differently due to anti-aliasing and hinting, which subtly changes how much a stroke appears to bloom. The −50/−100 rule is a robust average; do not try to branch per-OS. It is calibrated to read well across all three.
Very wide measure makes the line-height delta matter more
Expected — context dependentOn long lines (high characters-per-line), the eye returns across a glowing field of text, so the +0.05 line-height delta does more work keeping rows distinct. On narrow columns it matters less. The fixed delta is a safe default for both; widen further by hand for very long measures.
Variable fonts could hit a precise weight — the tool does not
By design — static numbersA variable font could be set to an arbitrary wght like 372 for an even smoother transition, but the adjuster emits whole font-weight numbers derived by subtraction (e.g. 400 → 300). For axis-precise dark weights, take the number it gives and write font-variation-settings: 'wght' … yourself, or generate axis CSS with css-variable-generator-font.
Frequently asked questions
What is halation, in one sentence?
The perceived blooming of light pixels on a dark background that makes thin strokes look thicker than they are, so light-coloured text reads heavier in dark mode than the same text in light mode.
Is halation worse on OLED?
Yes. OLED pixels emit their own light and switch fully off for black, producing maximal contrast at every stroke edge — which is exactly what drives the bloom. Backlit LCDs leak some light through black pixels, lowering the contrast and the effect. Design for the OLED worst case.
How much should I reduce the weight?
By 100 for normal body weights, and by 50 if you are already at 300 or below — never below 100. So 400 → 300, 700 → 600, 300 → 250, 100 → 100. That is the rule the adjuster applies automatically.
Why +0.01em letter-spacing and not more?
It is a deliberately subtle delta. Bloomed strokes encroach on their neighbours by a small amount, so a small +0.01em re-opens the gap without making the text feel spaced-out. More than that and the tracking becomes visible as a style choice rather than a correction.
Why +0.05 line-height?
Bloom is vertical too — ascenders and descenders of neighbouring lines glow toward each other. +0.05 re-opens the channel between rows. Like the tracking delta it is small on purpose: enough to relieve the crowding, not enough to change the page's rhythm.
Does monitor brightness change how much I need?
Yes. Halation dominates at high brightness and nearly disappears at low brightness in a dark room. Phones auto-adjust brightness, so the same page looks different at noon and at midnight. The fixed deltas target the bright worst case, which is the right call for the common condition.
Should I also change the text colour?
Ideally yes, but that is a separate step. Pure white on pure black maximises the contrast that causes halation. Off-white text (around #EAEAEA) on an off-black background (around #1C1C1E) cuts the bloom at its source. The adjuster emits only weight, spacing, and line-height — add colour by hand.
Do all sites need to do this?
Reading-heavy and brand-sensitive interfaces benefit most; a colour-only flip is acceptable for content-light UIs. But the weight drop is one line of CSS in a media query with negligible cost, so most sites should at least do that. See real-world implementations for the spectrum.
Is this the same as the contrast WCAG requirements?
No. WCAG is about minimum contrast for legibility; halation compensation is about perceived weight at high contrast. You can pass WCAG and still have text that looks too heavy in dark mode. They are complementary — the checklist covers both.
Will reducing weight hurt readability?
No, if you stay within the rule. The dark-mode weight is engineered to read with the same perceived density as the light weight — you are not actually making text lighter, you are cancelling the bloom that made it look heavier. Going further than −100 can make text look anaemic, which is why the tool stops there.
What about emoji and icons in dark mode?
Halation applies to any bright shape on a dark field, but the typographic deltas only affect text. Icon fonts and emoji are handled by the OS or by separate dark-mode asset swaps. The adjuster does not touch them.
How do I justify this to a sceptical reviewer?
Put the two modes side by side at identical weight and the dark one visibly looks bolder — that is halation, and it is documented in Apple's HIG and Material Design. Then show the compensated version reading at matched density. The documentation example above gives a paste-ready rationale block.
Privacy first
Every JAD Font tool runs entirely in your browser using opentype.js and the wawoff2 WASM Brotli encoder. Your fonts never leave your device — verified by zero outbound network requests during processing.