Topic 10.4
Dark Mode and Theme Tokens
In one line
Put every chart colour in CSS custom properties (tokens), draw SVG with var() and currentColor, and a theme becomes one switch with no redraw. Canvas and exported images do not read CSS, so they must be given the resolved colour and redrawn.
Think of it like this
A hotel room with one master switch by the door. Press it and every lamp changes together, because every lamp is wired to the same circuit, not to its own switch. A chart whose colours are typed into each shape is a room with a hundred separate switches. A chart that reads colours from named tokens has one.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- custom property
- A CSS value you name yourself, written
--name. It is read withvar(--name). - token
- A custom property used as a shared design value, such as
--chart-ink. - currentColor
- A CSS keyword meaning "the text colour of this element". An SVG fill or stroke can use it.
- prefers-color-scheme
- A media query that is true when the visitor's system is set to dark (or light).
- theme
- One full set of token values: light or dark.
Step by step
01Define the tokens once, for both themes
The first example gives each card a class that defines the same four token names with different values: theme-light and theme-dark. The chart code never mentions a colour.
02Draw the same chart twice
The same function draws a four-bar chart into both cards. The computed fills show the light card's bar as rgb(31, 122, 111) and the dark card's as rgb(79, 209, 192).
03Flip a theme with one class, then break a token
Swapping one class changes the first card's bar fill without a redraw. A misspelled token with no fallback gives black, and with a fallback gives the fallback.
04Handle canvas and images by hand
The second example shows that a canvas ignores var(), does not repaint when the class changes, and needs a redraw with the resolved colour. An SVG drawn as an image comes out black.
Try it yourself
- 1
Add a third theme
In the first example's CSS add
.theme-blue { --chart-bg: #0b2545; --chart-ink: #eef4ed; --chart-grid: #134074; --chart-accent: #8da9c4; }, change the second card's class totheme-blue, and run it. The dark card's bar fill becomesrgb(141, 169, 196), and the chart code did not change. - 2
Redraw the canvas from an event
In the second example, wrap
draw(token())in a functionrepaintand call it afterclassList.add("dark"). The pixel after the class change is#4fd1c0straight away: the pattern for a real theme button is toggle the class, then repaint every canvas. (The line whose text says "canvas not redrawn" now prints the new colour, because you did redraw.)
Code & diagrams
The preview cannot switch the system's light or dark preference, so each card carries a class that sets the tokens. A real page writes the same values in `@media (prefers-color-scheme: dark)` and in `[data-theme="dark"]` (the build does). In the picture both cards are dark, because the code swapped the first card's class, and its top bar (Quay) is grey: that is the fallback for the misspelled token.
Preview
Console
Nothing logged yet.Expected console output
light bar fill: rgb(31, 122, 111) | label fill: rgb(29, 43, 54) | card background: rgb(255, 255, 255)
dark bar fill: rgb(79, 209, 192) | label fill: rgb(230, 237, 242) | card background: rgb(22, 34, 43)
after swapping one class, the first card's bar fill: rgb(79, 209, 192)
misspelled token, no fallback: rgb(0, 0, 0)
misspelled token, with a fallback: rgb(136, 136, 136)Failure cases, each silent: an invalid canvas colour is ignored (the old #123456 stays), a canvas does not repaint when the class changes (the pixel is still #1f7a6f), and an SVG drawn as an image has no access to the page's variables, so its fill is black (#000000). In the picture the four bars are the dark theme's teal on a dark background, because the code ended in dark mode.
Preview
Console
Nothing logged yet.Expected console output
fillStyle after assigning var(--chart-accent): #123456
bars drawn with the variable are: #123456
light theme, token #1f7a6f -> pixel #1f7a6f
class changed, canvas not redrawn -> pixel #1f7a6f
dark theme, redrawn with token #4fd1c0 -> pixel #4fd1c0
SVG drawn as an image, fill var(--chart-accent) -> #000000Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Misspell a token without a fallback
In the first example's CSS change .card .bar { fill: var(--chart-accent); } to .card .bar { fill: var(--chart-acent); }.
Myth vs fact
Myth
Dark mode is just inverting the colours.
Fact
Inverting turns a good palette into a bad one. A palette that works on white can sink into a dark card, and the reverse. Dark themes usually need lighter accent and text colours (this build's accent goes from #1f7a6f to #4fd1c0) and an ink that is soft white rather than pure white. The six district colours are chosen to pass 3:1 on both cards, so they are the same in both themes. Each theme is tested for contrast on its own (Topic 10.2).
Myth
Canvas will follow the theme like everything else on the page.
Fact
A canvas holds pixels. It must be redrawn, with the resolved colours, when the theme changes.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
A custom property's value is text.
getPropertyValuereturns it with its original spacing, so.trim()it, and a token may holdrgb(...), a keyword or anothervar(). If a canvas needs the real colour of a keyword orvar()chain, set it on an element and readgetComputedStyle(el).color. - ▸
A ramp has two ends, and one of them can vanish on a dark card. Viridis's first colour
#440154against the dark card#16222bis 1.06:1 (and 15.2:1 on white), so the quietest cells of the June heatmap almost disappear in dark mode, while the bright end stands out. For a heatmap that is often acceptable, because "quiet" reads as "nothing", but zero-ride cells use the grid colour for that reason, and a dark-theme chart that must show its low end can start the ramp later (t => d3.interpolateViridis(0.15 + 0.85 * t)). Recall from Topic 1.5:currentColoris the text colour of the element, which is why axes follow the theme. - ▸
Listen to the system's change with
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", repaint)so canvases repaint when the system switches at sunset. A chart that only paints once shows the old theme until reload.
Remember this
- 1
The problem this solves: at night Priya turned on dark mode. The page background and the cards went dark, but the bars kept their colours and the labels kept their dark ink, so the text vanished and the grid became bright lines. The charts were not wired to the theme.
- 2
A token is a CSS custom property with a job.
--chart-ink(text),--chart-muted,--chart-grid,--chart-accent,--chart-bgand the six series colours are defined once on:root, and a dark block redefines them. Everything that usesvar(--chart-ink)changes when the values change. This is the whole mechanism. - 3
SVG can read tokens directly.
fill="var(--chart-accent)"as a presentation attribute andstyle="fill: var(--chart-accent)"both resolve in the browser (first example). Better still, give shapes a class (bar) and write.bar { fill: var(--chart-accent) }in CSS. For text and axes,fill: currentColoruses the element's text colour, so an axis followscolor. - 4
Two ways to switch. The system's preference is a CSS media query,
@media (prefers-color-scheme: dark). A button needs an attribute on<html>(data-theme="dark") that a second rule reads. The preview cannot flip the system preference, so the example toggles a class, which is the same second path. In the project'stheme.cssboth paths are written, andmain.jssets the attribute from the button. - 5
A missing token fails quietly. A typo (
--chart-acent) with no fallback makes the declaration invalid, and the shape's fill becomes the initial value: black. On a dark card, black is invisible. Writevar(--chart-accent, #888)where a safe default is worth having, and look for black shapes when testing in both themes. - 6
Canvas and images do not follow CSS. A canvas gets a colour string when you draw.
ctx.fillStyle = "var(--chart-accent)"is not valid and is silently ignored: the old colour stays (second example). Read the token withgetComputedStyle(document.documentElement).getPropertyValue("--chart-accent"), draw with that, and redraw when the theme changes. An SVG turned into an image (for a PNG export) is a separate document with no access to your page's variables, so itsvar()fills come out black: resolve the colours into the SVG text first.
Explain it without notes
Why does an SVG chart that uses var(--chart-accent) change theme with no redraw?
What happens to ctx.fillStyle = "var(--chart-accent)", and how do you colour a canvas by token?
Why do you need both @media (prefers-color-scheme: dark) and a data-theme rule?
Practice
Write a repaint function that clears the canvas from the second example and draws the bars with the current --chart-accent, and call it from a button that toggles the dark class.
List which parts of the dashboard in Phase 7 hold a hard-coded colour (open stations-bar.js) and say which token each should use.
Trade-offs
- ↔
CSS-driven theming costs nothing at runtime but needs every colour in CSS. A chart that must also export a PNG needs a resolve-then-draw path as well.
Done when you can
I can write tokens for light and dark and use them from SVG.
I can use
currentColorfor text and axes.I can say why canvas and exported SVG images need the resolved colour and a redraw.
I can find a misspelled token by its black result.