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"
This box follows the theme you pick. System follows your device's setting.
How it worksHow 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.
Related
- Styling, for custom properties,
:hostand parts. - Firelight.Context, to pass a theme value to components that render differently by theme, not only colour.