How to adjust font weight, letter-spacing & line-height for dark mode
- Step 1Enter your light-mode weight — The **Light weight** field is a number input (min 100, max 900, step 50, default 400). Put in whatever weight your body text uses today in light mode. This is the baseline the dark override is computed from — the tool subtracts 100 (or 50 if you enter 300 or less).
- Step 2Enter your light-mode line-height — The **Light line-height** field is a number input (min 1, max 2, step 0.05, default 1.5). Enter your current unitless line-height. The dark override will be this value plus 0.05 — so 1.5 becomes 1.55.
- Step 3Enter your light-mode letter-spacing — The **Light letter-spacing (em)** field is a number input (min −0.05, max 0.1, step 0.005, default 0). Enter your current tracking in em. The dark override adds 0.01em — so 0 becomes 0.01em, and −0.02 becomes −0.01.
- Step 4Generate the CSS — Run the tool. It returns a `.css` block: a `body {}` rule with your three light values, then `@media (prefers-color-scheme: dark) { body { ... } }` with the three computed dark values, preceded by an explanatory comment about halation.
- Step 5Paste into your stylesheet — Drop the whole block into your global stylesheet. The selector is `body` — if your body text lives on a different element, change the selector after pasting (the tool always emits `body`). Nothing else in your CSS needs to move.
- Step 6Verify on an OLED screen — Toggle your OS into dark mode and look at body copy on an OLED phone or laptop — that is where halation is strongest. The adjustment should be felt rather than seen: text reads at the same density in both modes. Check the metrics panel, which reports the light weight, the computed dark weight, and the two deltas.
The three inputs and their real bounds
Exactly what the tool accepts. These are the actual HTML number-input attributes and the handler defaults — there are no other controls (no presets, no variable-axis toggle, no colour or contrast fields, no custom selector).
| Input | Type | Min / Max / Step | Default | What it feeds |
|---|---|---|---|---|
| Light weight | number | 100 / 900 / 50 | 400 | The light-mode font-weight; the dark weight is derived from it |
| Light line-height | number | 1 / 2 / 0.05 | 1.5 | The light-mode unitless line-height; dark gets +0.05 |
| Light letter-spacing (em) | number | −0.05 / 0.1 / 0.005 | 0 | The light-mode letter-spacing in em; dark gets +0.01em |
Computed dark weight for every step
The weight rule is darkWeight = lightWeight <= 300 ? lightWeight − 50 : lightWeight − 100, then clamped with Math.max(100, …). This table is that rule evaluated at every 50-step value the input allows.
| Light weight | Rule branch | Dark weight | Note |
|---|---|---|---|
| 100 | − 50, clamp | 100 | Clamped — 100 − 50 = 50 floored back to 100 |
| 150 | − 50, clamp | 100 | 150 − 50 = 100 |
| 200 | − 50 | 150 | ≤ 300 branch |
| 250 | − 50 | 200 | ≤ 300 branch |
| 300 | − 50 | 250 | Boundary — 300 still uses the −50 branch |
| 350 | − 100 | 250 | > 300 branch |
| 400 | − 100 | 300 | Most common body case |
| 500 | − 100 | 400 | Medium → regular |
| 600 | − 100 | 500 | Semibold → medium |
| 700 | − 100 | 600 | Bold → semibold |
| 800 | − 100 | 700 | Extra-bold → bold |
| 900 | − 100 | 800 | Black → extra-bold |
Letter-spacing and line-height deltas
Both deltas are flat constants the tool adds, then rounds to three decimals. They do not scale with weight or with the input value.
| Property | Light value example | Delta applied | Dark value |
|---|---|---|---|
letter-spacing | 0 | +0.01em | 0.01em |
letter-spacing | 0.005em | +0.01em | 0.015em |
letter-spacing | −0.05em | +0.01em | −0.04em |
letter-spacing | 0.1em | +0.01em | 0.11em |
line-height | 1.5 | +0.05 | 1.55 |
line-height | 1 | +0.05 | 1.05 |
line-height | 2 | +0.05 | 2.05 |
Cookbook
The exact CSS this tool emits for the inputs people actually use. The output selector is always body; rename it after pasting if your body copy lives elsewhere. For multi-weight or token-driven theming see the design-tokens guide.
Default 400 / 1.5 / 0 body text
ExampleThe out-of-the-box inputs — a regular-weight body at 1.5 line-height, no tracking. This is the snippet most sites need verbatim.
Inputs: weight 400, line-height 1.5, letter-spacing 0
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;
}
}Already-light 300 body (the −50 branch)
ExampleWhen your light weight is 300 or lower, the tool only drops 50, not 100 — over-thinning a light weight would make it disappear in dark mode. Light 300 becomes dark 250.
Inputs: weight 300, line-height 1.6, letter-spacing 0
body {
font-weight: 300;
letter-spacing: 0em;
line-height: 1.6;
}
@media (prefers-color-scheme: dark) {
body {
font-weight: 250;
letter-spacing: 0.01em;
line-height: 1.65;
}
}Bold weight clamped behaviour at the floor
ExampleAt weight 100 the math would give 50, but the tool clamps to a hard floor of 100, so a Thin face stays Thin in dark mode. There is no thinner weight to go to.
Inputs: weight 100, line-height 1.5, letter-spacing 0
body {
font-weight: 100;
letter-spacing: 0em;
line-height: 1.5;
}
@media (prefers-color-scheme: dark) {
body {
font-weight: 100; /* clamped — 100 - 50 = 50, floored to 100 */
letter-spacing: 0.01em;
line-height: 1.55;
}
}Negative tracking shifts toward zero
ExampleIf your light mode uses negative tracking (common on large headings), the +0.01em delta moves it one step toward zero. The tool always adds, never reverses sign.
Inputs: weight 600, line-height 1.3, letter-spacing -0.02
body {
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.3;
}
@media (prefers-color-scheme: dark) {
body {
font-weight: 500;
letter-spacing: -0.01em;
line-height: 1.35;
}
}Retarget the selector for an article container
ExampleThe tool only ever writes body. If your reading column is .article-body, generate with your values and find/replace the selector once after pasting. The values stay identical.
Generated:
body { font-weight: 400; letter-spacing: 0em; line-height: 1.7; }
@media (prefers-color-scheme: dark) {
body { font-weight: 300; letter-spacing: 0.01em; line-height: 1.75; }
}
After rename to your column selector:
.article-body { font-weight: 400; letter-spacing: 0em; line-height: 1.7; }
@media (prefers-color-scheme: dark) {
.article-body { font-weight: 300; letter-spacing: 0.01em; line-height: 1.75; }
}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.
You expected font-variation-settings for a variable font
By design — static weight onlyThe tool never uploads a font and never detects whether your font is variable. It always emits font-weight: <number>. That number happens to land on non-standard values like 350 only when you enter a non-standard light weight — for the stepped inputs (400, 700, …) the dark weight is also a standard step (300, 600, …). If you want a variable wght axis tuned per mode (e.g. 'wght' 372), generate the weight here and wrap it yourself, or build axis-driven CSS with css-variable-generator-font and freeze instances with variable-font-freezer.
Light weight 100 produces no change in dark mode
Clamped — expected100 − 50 = 50, and the handler floors the dark weight at 100 with Math.max(100, …). So Thin (100) stays 100 in dark mode; there is nowhere thinner to go. Letter-spacing and line-height still get their +0.01em / +0.05 deltas. If a Thin face still looks heavy in dark mode, the fix is lower contrast text colour or a less extreme background, which this tool does not output.
You wanted contrast / colour / background adjustments too
Out of scope — not emittedHalation is also tamed by using off-white text (e.g. #EAEAEA instead of #FFFFFF) and an off-black background (e.g. #1C1C1E instead of #000). This tool only outputs font-weight, letter-spacing, and line-height — no color, no background-color. Add those by hand. The reasoning is covered in the halation explainer and the checklist.
The selector is always body, not :root or a custom class
By designEvery run writes a body {} rule and a body {} override inside the media query. There is no input to change the selector and it does not emit CSS custom properties. If you theme via tokens or need a :root variable form, rename the selector after pasting, or adapt the values into your token pipeline as shown in the tokens guide.
letter-spacing input is em-only
By designThe field is labelled em and the output is written as <value>em. There is no px or rem option. em is the right unit for tracking because it scales with the font size; px tracking would break at different sizes. If you truly need px, convert after pasting, but em is recommended.
Entering a weight that is not a multiple of 50
Accepted — math still runsThe input step is 50, but the handler does plain arithmetic, so an out-of-step value (e.g. typed 410) still computes 310 in dark mode. Browsers snap non-keyword font-weight numbers to the nearest weight the font actually ships, so a static font with only 400 and 700 will render 310 as 400 anyway. Stick to weights your font file contains.
Manual dark-mode toggle instead of the OS preference
Needs a follow-up editThe output keys entirely off @media (prefers-color-scheme: dark), which follows the OS setting. If you ship a manual theme switcher (a button that forces dark), duplicate the override under a [data-theme="dark"] selector as well so user choice beats system preference. The tool does not emit that variant.
line-height already at the 2.0 maximum
Accepted — exceeds slider cap in outputThe input maxes at 2.0, but the +0.05 delta means the emitted dark line-height can be 2.05 — slightly above what the slider itself accepts as an input. That is fine; 2.05 is a valid unitless line-height. It just means you cannot then re-feed 2.05 back into the tool as a light value.
Free tier, no upload, nothing leaves the browser
SupportedThis is a generative tool: needsFile: false, minTier: free. No font is read, no bytes are transferred, and there are no size or glyph limits to hit (those only apply to file-based font tools). The only thing recorded server-side for signed-in users is a usage counter with no content.
Frequently asked questions
What exactly does the tool change between light and dark?
Three properties. font-weight drops by 100 (or 50 if your light weight is 300 or lower), never below 100. letter-spacing gains exactly +0.01em. line-height gains exactly +0.05. It writes a body {} baseline with your light values and a @media (prefers-color-scheme: dark) override with the computed values. Nothing else — no colour, no background, no font-family.
Why reduce weight in dark mode at all?
Halation. Light pixels on a dark background bloom outward in the eye, so thin strokes look thicker than they are — a 400 body reads closer to 500 in dark mode. Dropping the weight class by 50–100 restores the same perceived density. Apple's Human Interface Guidelines and Material Design both recommend the principle. See the halation explainer for the perceptual detail.
Does it output font-variation-settings for variable fonts?
No. The output is always plain font-weight: <number>. The tool does not upload or inspect your font, so it cannot know whether it is variable. If you want a wght axis tuned per mode, take the number this tool gives you and write font-variation-settings: 'wght' <number> yourself, or use css-variable-generator-font for axis-driven token CSS.
Can I change the selector from body to my own class?
Not in the tool — it always writes body. Generate the CSS, then rename body to your selector (e.g. .prose, .article-body) in both the baseline rule and the media-query override. The numeric values do not change when you do this.
What's the dark weight for a light weight of 400?
300. The rule subtracts 100 for any light weight above 300. So 400 → 300, 500 → 400, 600 → 500, 700 → 600, 800 → 700, 900 → 800. At or below 300 it subtracts only 50: 300 → 250, 200 → 150, and 100 → 100 (clamped).
Why does 300 only drop to 250 instead of 200?
Light and already-thin weights bloom less and have less headroom — dropping a full 100 from a 300 would push it to 200, which can look anaemic in dark mode. The handler uses a smaller −50 step for any light weight of 300 or below, and clamps so the result is never under 100.
Does it adjust text colour or background?
No. Off-white text (#EAEAEA) and off-black backgrounds (#1C1C1E) also reduce halation, but this tool only emits the three typographic properties. Add colour and background by hand. The full set of recommendations is in the dark-mode typography checklist.
Will users actually notice the difference?
Not consciously, in most cases — the goal is invisible improvement. Text simply reads at the same density in both modes instead of looking heavier in dark. Put the two modes side by side and the difference becomes obvious; in normal use it just feels right.
Is letter-spacing in px or em?
em. The input is labelled em and the output is written as <value>em. em is correct for tracking because it scales with font size — a fixed px tracking would look wrong at different sizes. There is no px option.
Does anything get uploaded or sent to a server?
No. The tool is generative — it takes three numbers and returns CSS, entirely in your browser. There is no font upload and no content leaves the page. For signed-in users a single anonymous usage counter increments; that is all.
What if my body text uses a different element?
The tool emits body. If your readable text is in a container element, generate the CSS and find/replace body with that container's selector. Keep the media query intact so the dark values still apply when the OS is in dark mode.
Can I run this in a build pipeline?
Yes — it is deterministic, so the same three inputs always yield the same CSS, making it safe to script or commit. Hit GET /api/v1/tools/dark-mode-font-adjuster for the schema and POST the three options to the local runner at 127.0.0.1:9789/v1/tools/dark-mode-font-adjuster/run. The token-driven approach is covered in the build-tokens guide. For headings, run it again with your heading values and rename the body selector to h1, h2 — heavier heading weights bloom more, so they benefit from the −100 drop the most.
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.