Cookbook

Dark and light themes

Build a theme picker with System, Light and Dark choices, that keeps the choice for the next visit.

Pick Dark, then reload the page: the box stays dark. System follows your device's setting.

module Snippets.ThemePicker

open Fable.Core
open Browser
open Firelight
open type Firelight.Lit

let private key = "theme"

let private choices = [ "system", "System"; "light", "Light"; "dark", "Dark" ]

[<AttachMembers>]
type ThemePicker() =
    inherit LitElement()

    static member properties =
        PropertyDeclarations.create [ "theme", PropertyDeclaration<string>(state = true) ]

    static member styles =
        css
            $$"""
        :host { display: grid; gap: 0.75rem; justify-items: start; }
        fieldset { display: flex; gap: 1rem; border: 1px solid var(--border); border-radius: 0.5rem; }
        /* The palette: each colour has a light and a dark value, picked by color-scheme. */
        .preview {
            --paper: light-dark(#fffdf9, #17151a);
            --ink: light-dark(#1f1d1a, #ece8e3);
            --line: light-dark(#e8e1d8, #3d3840);
            --accent: light-dark(#c2410c, #ff8a3d);
            color-scheme: light dark;
            background: var(--paper); color: var(--ink);
            border: 1px solid var(--line); border-radius: 0.5rem; padding: 0.75rem 1rem; width: 16rem;
        }
        .preview.light { color-scheme: light; }
        .preview.dark { color-scheme: dark; }
        .preview p { margin: 0 0 0.5rem; }
        .preview a { color: var(--accent); }
        """

    member val theme = "system" with get, set

    member this.Choose(theme: string) =
        this.theme <- theme

        try
            localStorage.setItem (key, theme)
        with _ ->
            ()

    member this.Restore() =
        try
            match localStorage.getItem key with
            | "light"
            | "dark" as theme -> this.theme <- theme
            | _ -> ()
        with _ ->
            ()

    // Once the first update is done, so the first render matches the prerendered HTML, and the
    // change starts an update of its own.
    override this.firstUpdated _ =
        promise {
            let! _ = this.updateComplete
            this.Restore()
        }
        |> Promise.start

    override this.render() =
        let choice (value: string, label: string) =
            html
                $"""
            <label>
                <input type="radio" name="theme" value={value} .checked={this.theme = value}
                    @change={fun _ -> this.Choose value}> {label}
            </label>"""

        html
            $"""
        <fieldset>
            <legend>Theme</legend>
            {choices |> List.map choice}
        </fieldset>
        <div class="preview {this.theme}">
            <p>This box follows the theme you pick. System follows your device's setting.</p>
            <a href="#how-it-works">How it works</a>
        </div>"""

defineElement<ThemePicker> "my-theme-picker"

How it works

The colours are CSS custom properties, each with a light and a dark value: --paper: light-dark(#fffdf9, #17151a). light-dark() picks one by the element's color-scheme. color-scheme: light dark means "whichever the device prefers", and light or dark forces one. So a theme is one property: the component's render adds the class light or dark, or neither, and every colour follows. color-scheme also gives form controls and scroll bars the matching look. The Styling guide covers custom properties across shadow DOM.

The choice is stored as a string. Anything other than light or dark reads as System, so a stale or edited value can't break the page. Remember state across visits covers storing more than a string.

Theming a whole page

This demo themes a box, since the page around it has its own theme. For an app, put the palette on :root in the page's stylesheet, and have the picker set color-scheme on the <html> element:

open Browser

let applyTheme (theme: string) =
    document.documentElement.style.setProperty ("color-scheme", if theme = "system" then "" else theme)

    try
        localStorage.setItem ("theme", theme)
    with _ ->
        ()

Custom properties inherit into every shadow root, so each component reads the palette with var(--paper).

A component's code runs after the page has painted, so a stored dark theme would first show light, then switch. Set it before the first paint with a small script in the page's <head>, reading the same key:

<script>
  try {
    const theme = localStorage.getItem("theme");
    if (theme === "light" || theme === "dark") document.documentElement.style.colorScheme = theme;
  } catch {}
</script>

An inline script needs a hash or a nonce under a Content Security Policy that forbids inline scripts. Prerendering can't help here: the build doesn't know which theme a visitor chose.

This site's theme button, in the header, works the same way, but in plain JavaScript in that head script rather than as a component: a Firelight component in the header would load Lit on every page, and pages without demos load no JavaScript but that script. It stores its choice under its own key, so this demo's choice doesn't change the site's theme.