Command Palette

Search for a command to run...

Hectal
PHASE 10Intermediate ~22 min· topic 4 of 6

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 with var(--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.

Flip a theme with one class, then break a tokendiagram
Rendering diagram…

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. 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 to theme-blue, and run it. The dark card's bar fill becomes rgb(141, 169, 196), and the chart code did not change.

  2. 2

    Redraw the canvas from an event

    In the second example, wrap draw(token()) in a function repaint and call it after classList.add("dark"). The pixel after the class change is #4fd1c0 straight 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 same chart in a light card and a dark card, from tokens New tab

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

Press Run to see the page.

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)
Canvas and exported images do not follow CSS New tab

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

Press Run to see the page.

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) -> #000000

Break 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); }.

terminal
$ Console
── what you'll see ──
light bar fill: rgb(0, 0, 0) | label fill: rgb(29, 43, 54) | card background: rgb(255, 255, 255)
dark bar fill: rgb(0, 0, 0) | label fill: rgb(230, 237, 242) | card background: rgb(22, 34, 43)
after swapping one class, the first card's bar fill: rgb(0, 0, 0)
misspelled token, no fallback: rgb(0, 0, 0)
misspelled token, with a fallback: rgb(136, 136, 136)

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. getPropertyValue returns it with its original spacing, so .trim() it, and a token may hold rgb(...), a keyword or another var(). If a canvas needs the real colour of a keyword or var() chain, set it on an element and read getComputedStyle(el).color.

  • ▸

    A ramp has two ends, and one of them can vanish on a dark card. Viridis's first colour #440154 against the dark card #16222b is 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: currentColor is 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. 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. 2

    A token is a CSS custom property with a job. --chart-ink (text), --chart-muted, --chart-grid, --chart-accent, --chart-bg and the six series colours are defined once on :root, and a dark block redefines them. Everything that uses var(--chart-ink) changes when the values change. This is the whole mechanism.

  3. 3

    SVG can read tokens directly. fill="var(--chart-accent)" as a presentation attribute and style="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: currentColor uses the element's text colour, so an axis follows color.

  4. 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's theme.css both paths are written, and main.js sets the attribute from the button.

  5. 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. Write var(--chart-accent, #888) where a safe default is worth having, and look for black shapes when testing in both themes.

  6. 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 with getComputedStyle(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 its var() fills come out black: resolve the colours into the SVG text first.

Explain it without notes

01

Why does an SVG chart that uses var(--chart-accent) change theme with no redraw?

02

What happens to ctx.fillStyle = "var(--chart-accent)", and how do you colour a canvas by token?

03

Why do you need both @media (prefers-color-scheme: dark) and a data-theme rule?

Practice

01

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.

02

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 currentColor for 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.