Light and dark themes

The theme supports a light and a dark mode. By default it follows the operating system preference, and users can override it with a switch, their choice being remembered across pages and visits.

How the theme is chosen

Bulma 1 already generates both palettes as CSS variables, and picks one depending on:

  1. the data-theme attribute of the <html> element (light or dark), if set;

  2. otherwise, the prefers-color-scheme media query, which reflects the operating system setting.

The Teklia theme keeps this logic, and teklia.js only manages the data-theme attribute:

flowchart TD load[Page load: teklia.js runs in head] --> stored{Choice stored in localStorage?} stored -- yes --> attr[Set data-theme on html] stored -- no --> os[No attribute: follow prefers-color-scheme] attr --> paint[First paint with the right theme] os --> paint click[Click on a data-theme-switch element] --> toggle[Swap light / dark, set data-theme and store the choice]

Bulma’s theme-light and theme-dark classes on <html> work too, and take the same precedence as the attribute.

A softer dark mode

Bulma’s default dark palette is almost black, with very bright white titles: high contrast but tiring to read. The theme lifts the backgrounds to a slate grey and narrows the gap between the text shades:

Variable Bulma Teklia theme

--bulma-scheme-main-l (page background lightness)

9%

17%

--bulma-background-l (secondary background lightness)

14%

22%

--bulma-text-l (text lightness)

71%

80%

--bulma-text-title-l (title lightness)

100%

92%

Because the background is lighter, the "text-safe" variant of each color (--bulma-primary-on-scheme, --bulma-danger-on-scheme…) is recomputed against it. Links use their on-scheme variant too, as the Teklia link blue is too dark to be read on a dark background.

The theme switch

Load teklia.js synchronously in the <head>, before the stylesheet:

<head>
  <script src="teklia.js"></script>
  <link rel="stylesheet" href="teklia.css">
</head>

This way, a stored choice is applied before the page is first drawn. With an async or defer script, or a script at the end of the <body>, a user who chose the light theme on a system in dark mode would see the page flash in dark first.

Then, any element with a data-theme-switch attribute switches between both themes when clicked:

<button type="button" class="button" data-theme-switch title="Switch between light and dark theme">
  <span class="icon is-light-theme-only"><i class="fas fa-moon"></i></span>
  <span class="icon is-dark-theme-only"><i class="fas fa-sun"></i></span>
</button>

It works in a navigation bar too:

<a class="navbar-item" data-theme-switch title="Toggle dark mode">
  <span class="icon is-light-theme-only"><i class="fa-solid fa-moon"></i></span>
  <span class="icon is-dark-theme-only"><i class="fa-solid fa-sun"></i></span>
</a>

A few details on the behavior:

  • The choice is stored in the localStorage of the browser, under the teklia_theme key, so it is shared by all the pages of the same domain.

  • The click listener is registered on the document, not on the switches: switches added to the page later (by htmx, Vue…) work without any extra code.

  • If the storage is unavailable (private browsing, blocked cookies), the switch still works for the current page.

  • There is no way back to "follow the system" from the switch: once clicked, the choice is fixed. Remove the teklia_theme key from the localStorage to reset it.

Writing your own switch

In a single page application, you may prefer to manage the theme from your framework. Keep the same storage key, so the choice is shared with the other Teklia pages of the same domain:

const KEY = 'teklia_theme'
const root = document.documentElement

// Current theme, whether it was chosen or comes from the system
export function currentTheme () {
  return root.dataset.theme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
}

export function setTheme (theme) {
  root.dataset.theme = theme
  try {
    localStorage.setItem(KEY, theme)
  } catch (e) {}
}

Still apply the stored theme from a small synchronous script in the <head>, as teklia.js does: the framework code usually runs too late to avoid the flash described above.

Elements displayed in one theme only

Two helper classes hide an element in the other theme:

  • is-light-theme-only: only displayed in the light theme;

  • is-dark-theme-only: only displayed in the dark theme.

They are typically used for the icons of the theme switch, or for a logo with a light and a dark variant:

<a class="navbar-item" href="/">
  <img class="is-light-theme-only" src="logo.svg" alt="Teklia">
  <img class="is-dark-theme-only" src="logo-white.svg" alt="Teklia">
</a>

These classes are pure CSS, following the same rules as the theme itself (data-theme first, then the system preference). The right element is therefore displayed on the first paint, without waiting for any JavaScript.

Writing styles that follow the theme

Sass color variables, such as $primary or $grey-lighter, are compiled into fixed values: they stay the same in both themes. In your own styles, use Bulma’s CSS variables instead, which change with the theme:

.my-panel {
  // Wrong: always a light grey, even in dark mode
  // background-color: $grey-lighter;

  background-color: var(--bulma-scheme-main-bis);
  border: 1px solid var(--bulma-border);
  color: var(--bulma-text);

  .highlight {
    // The variant of the color readable on the current background
    color: var(--bulma-primary-on-scheme);
  }
}

The most useful variables are:

Variable Usage

--bulma-scheme-main, --bulma-scheme-main-bis, --bulma-scheme-main-ter

Page background, then two slightly different shades for nested surfaces.

--bulma-background

Background of secondary areas (e.g. code blocks).

--bulma-border, --bulma-border-weak

Borders.

--bulma-text, --bulma-text-weak, --bulma-text-strong

Regular, secondary and emphasized text.

--bulma-<color>-on-scheme

A color (primary, link, danger…) readable as text on the current background.

The theme also provides has-text-weak and has-text-strong helpers, and makes has-text-primary, has-text-danger… use the readable variants. See Text colors.