Theme toggle

sitelo-ui resolves dark mode from prefers-color-scheme on its own — a site that is happy following the operating system needs nothing on this page. The toggle is for letting a reader override that.

It is one of the four components that need a script, because the choice lives in localStorage and only a script can read it. The button fetches that script itself, on the first press.

Setting it up

Two things in the head, and the button wherever it belongs:

import { styles, themeScript, themeToggle } from 'sitelo/ui'

head(
  themeScript(), // applies the stored choice before the first paint
  styles(),
)

body(
  appBar({ brand: 'My site' },
    appBarSpacer(),
    appBarActions(themeToggle()),
  ),
)

There is no third file. themeScript() is blocking and inline on purpose — anything deferred paints first, which is exactly the dark flash it exists to prevent — and the flip itself rides on the button:

<button data-su-theme-toggle
        onclick="import('/su/theme.js').then(m=>m.toggle(this))">

Pair the two. themeScript() is also what marks the toggle aria-pressed on load: nothing has been pressed yet, so the button itself cannot know which theme resolved.

The toggle

The icon is pure CSS, read straight off the theme attribute — so it is already correct on the first paint, before any script runs. It shows what a click will switch to.

stack({ direction: 'row', gap: 'md', align: 'center' },
  themeToggle(),
  themeToggle({ variant: 'soft' }),
  themeToggle({ variant: 'outline' }),
)

Those buttons work — this page loads the runtime. Clicking one sets data-su-theme on <html>, which is sitelo-ui’s own attribute, so only the sitelo-ui components on this page change. The rest of this site follows its own data-theme, set by the toggle in the top bar. On your site there would be only one of them.

In an app bar

sitelo
appBar({ brand: 'sitelo' },
  appBarNav(navLink({ href: '#docs', current: true }, 'Docs')),
  appBarSpacer(),
  appBarActions(
    themeToggle(),
    button({ size: 'sm' }, 'Get started'),
  ),
)

How the theme resolves

In order: an explicit data-theme or data-su-theme on any ancestor wins; failing that, prefers-color-scheme decides. Both attribute names are honoured so sitelo-ui can sit inside a site that already has its own theme switch — which is exactly what these docs do.

Driving it yourself

The runtime exports the same functions the button uses, for a custom control, or a three-way light / dark / system picker.

import { getTheme, setTheme, toggleTheme } from 'sitelo/ui/client'

getTheme()          // 'light' | 'dark' — resolved, not stored
toggleTheme()       // flip
setTheme('dark')    // pin
setTheme('system')  // clear the override and follow the OS again

Props

PropTypeDefaultDescription
labelstring'Toggle dark mode'Accessible name and tooltip.
variant'solid' | 'soft' | 'outline' | 'ghost' | 'link''ghost'Button variant.
color'primary' | 'neutral' | 'success' | 'warning' | 'danger''neutral'Which palette to draw from.

themeScript() takes an optional nonce, for a site with a content security policy.