@codefusion-cc/theme
Light, dark or the system's theme, kept per browser and shown before first paint. The page never flashes the wrong
theme, follows the system while it is open, and every open tab follows a change made in another. The look a person
picks (data-look, for @codefusion-cc/ui) and other choices (a palette, an accent) are kept and shown next to the
mode, and the look can follow a signed-in person to every browser.
npm install @codefusion-cc/theme
The config
One plain object, shared by the page and the Vite config, so the pre-paint script and the page can never disagree:
// src/themeConfig.ts
import { defineTheme } from '@codefusion-cc/theme'
export const themeConfig = defineTheme({
storageKey: 'myapp-theme',
// Tailwind's dark class…
html: { class: 'dark' },
// …or daisyUI's data-theme, a value for each:
// html: { attribute: 'data-theme', light: 'myapp-light', dark: 'myapp-dark' },
themeColor: '--color-base-100',
})
-
storageKey: where the mode is kept in localStorage. A storedlightordarkcounts; anything else (nothing, the system mode, a value from an older version, junk) is the system mode. The system mode and every default are kept as nothing, so a later default reaches everyone who never picked. -
html: how<html>shows the theme.color-schemeis always set too, so the browser's own controls and scrollbars match. -
themeColor: a CSS custom property holding the page's background. Each<meta name="theme-color">takes its value once the page has loaded and after every change, so an installed app's title bar and a phone's browser bar match the theme picked. Itsmediaattribute is dropped: a meta for the system's other mode would win over the pick. A colour that computes to another space (a registered colour property givesoklch(…)) is written as sRGB hex, which every browser's chrome takes. -
choices: other looks, each under its own key, with the values it can be and a default. A choice can show as its own data attribute (attribute: 'data-accent', absent while null), andhtml's values can name one as{name}:html: { attribute: 'data-theme', light: '{palette}', dark: '{palette}-dark' }, choices: { palette: { storageKey: 'myapp-palette', values: ['graphite', 'ember'], default: 'graphite' }, accent: { storageKey: 'myapp-accent', values: ['lime', 'sky'], default: null, attribute: 'data-accent' }, },
defineTheme throws a TypeError for a mistake in the config (no key, two things under one key, a default that is
not one of the values, a {name} that is no choice, a look name @codefusion-cc/ui refuses).
A stored value a choice cannot be (one the app has since removed, one from a newer version of the app open in another tab, junk) reads as the choice's default, before first paint, in the page and in every tab. It stays stored, so if the app has the value again (a rolled-back release), the person's pick comes back.
The look
The look is the set of values @codefusion-cc/ui compiles from the app's look modules: colours, fonts, radii. Its
stylesheet selects one by data-look on <html>, beside light and dark (color-scheme). lookChoice makes it a
choice shown as that attribute:
import { defineTheme, lookChoice } from '@codefusion-cc/theme'
export const themeConfig = defineTheme({
storageKey: 'myapp-theme',
html: { class: 'dark' },
choices: { look: lookChoice({ storageKey: 'myapp-look', values: ['kuznia', 'noc'], default: 'kuznia' }) },
})
- It is a choice like any other: shown before first paint, kept across reloads, followed by every open tab, kept for
the page only where storage is blocked.
theme.setChoice('look', 'noc')picks one, null the default;theme.get().choices.lookis the one shown, andrevealThemeChangecan animate a change of look too. defaultis the app's look: what everyone sees who never picked one, and anyone whose stored look the app no longer has (see above). It is never stored, so a new default reaches everyone who never picked.- The names are those of the look modules (
defineLook({ name })in@codefusion-cc/ui): lowercase letters, digits and hyphens, starting with a letter.defineThemerefuses others, and a look without a default. htmlcan name it too, for an app that still has per-look daisyUI themes:light: '{look}', dark: '{look}-dark'.
The look on the account
useLookSync (./react) keeps the look of a signed-in person on their account, so it follows them to every browser;
before sign-in it is kept per browser as above. Once, in the app's root:
import { useLookSync } from '@codefusion-cc/theme/react'
function App() {
const { user } = useSession()
const look = useLookSync(theme, {
// The look on the account: a name, null when it has none, undefined while signed out or loading.
account: user ? user.look : undefined,
save: (look) => api.put('/me/look', { look }),
})
// look.status: 'idle' | 'saving' | 'failed'; look.error; look.retry()
}
The rules, in order:
- Signing in to an account with a look shows it here (and keeps it in this browser for the next visit). An account with none takes the look picked in this browser before, if one was; the app's default is not saved.
- A pick made while signed in is saved. A pick made in another tab is saved by that tab only, and replaces a pick here still waiting to be saved or whose save failed.
- Saves go one at a time, in the order picked: picks made during a save wait, and only the last of them is saved.
- A failed save (
savethrew or rejected) keeps the look in this browser;statusisfailed,errorsays why, andretry()saves the look shown now. A newer pick replaces the failed one. - The account's look changing (picked in another browser) shows here, except during a save from this page: the last pick made here wins.
- A look the app does not have on the account is neither shown nor overwritten: this browser's look shows, which falls back to the app's default.
- Signing out keeps the look in this browser and saves nothing more; a save still under way finishes for the
account it began for. Switching accounts passes
undefinedin between (signed out, or loading the next one).
createLookSync (./browser) is the same without React: setAccount(look) each time the account's look is known,
get and subscribe for the status, stop(). Both take choice to keep another choice than look. On the server
the hook renders idle and saves nothing.
Before first paint
prePaintTheme from ./vite puts a small classic script first in each page's <head>. It reads the stored theme and
shows it on <html> before anything paints, and it never throws, whatever the storage does:
// vite.config.ts
import { prePaintTheme } from '@codefusion-cc/theme/vite'
import { themeConfig } from './src/themeConfig.ts'
export default defineConfig({ plugins: [react(), prePaintTheme(themeConfig)] })
It is served as /theme.js (fileName changes it), which a CSP with script-src 'self' allows. inline: true writes
it into the page instead (a CSP then needs its hash). Without Vite, themeScript(config) from . is its source.
In the page
Once, at startup, in a module the rest of the app imports:
// src/theme.ts
import { createTheme } from '@codefusion-cc/theme/browser'
import { themeConfig } from './themeConfig.ts'
export const theme = createTheme(themeConfig)
It shows the same stored theme as the script, and from then on:
- follows the system while the mode is
system, as it changes (also on Safari before 14); - follows a pick made in another tab of the app;
- keeps a pick in localStorage, or for this page only when storage is blocked, missing or full;
- switches every colour at once: transitions are off for the frame the theme changes in (through the CSSOM, which a
CSP's
style-srcdoesn't block), so buttons don't fade through the old theme's colours; - sets the theme-color meta.
theme.get() is what the page shows: { mode, resolved, systemDark, choices }. theme.subscribe(listener) calls the
listener after each change with what made it: pick (this page), system or elsewhere (another tab or program). Picks: theme.setMode('dark'),
theme.toggle() (a single light/dark button: it picks the other theme, or system when that is what the system
shows, so one click doesn't hold the page to one mode for good), theme.setChoice('accent', 'sky') (null for the
default). An unknown mode or choice value throws a TypeError: a programming error.
In React
import { useTheme } from '@codefusion-cc/theme/react'
import { theme } from './theme.ts'
function ThemeButton() {
const { resolved } = useTheme(theme)
return <button onClick={theme.toggle}>{resolved === 'dark' ? 'Light' : 'Dark'}</button>
}
Every component renders again on each change, from any source. No provider is needed.
The animated switch
revealThemeChange (./browser) runs a change inside a View Transition: the new theme grows as a circle from the
button over the old one. Browsers without View Transitions, visitors who asked for reduced motion, and a browser
that refuses to start a transition get the change at once.
Once, in the app's CSS (or import '@codefusion-cc/theme/reveal.css' in a module):
@import '@codefusion-cc/theme/reveal.css';
In React, revealThemeChangeFrom (./react) does it from the control:
import { revealThemeChangeFrom } from '@codefusion-cc/theme/react'
import { theme } from './theme.ts'
<button onClick={(event) => revealThemeChangeFrom(event, theme.toggle)} />
// A radio group's own handler, which knows the option's button but has no click (arrow keys):
<Choice onChange={(mode, button) => revealThemeChangeFrom(button, () => theme.setMode(mode))} />
- The circle starts at the click, or at the control's centre when the keyboard pressed it (a keyboard click has no
pointer position) or when the control itself is given; with
null, the middle of the viewport. - The pick runs inside
flushSync, so the components that readuseTheme(the button's icon and label) are in the new snapshot. - A tap takes 400 ms and a mouse click 650 ms, whichever pointer the device has first (a touchscreen laptop, an iPad
with a trackpad).
durationandeasingare options, as below.
Without React, or with an origin of your own, revealThemeChange (./browser):
import { revealThemeChange } from '@codefusion-cc/theme/browser'
revealThemeChange(() => theme.toggle(), { origin: button })
- The change must be on the page when the callback returns: the browser snapshots the new page right after it.
theme.toggle()andtheme.setMode()paint<html>at once; in React, wrap a change that renders components influshSync(revealThemeChangeFromdoes). originis a point or an element (the circle starts from its centre); without one, the middle of the viewport.durationandeasingare options. The circle takes 650 ms (REVEAL_MS), and 400 ms on a touch screen (pointer: coarse,TOUCH_REVEAL_MS), where the page takes no taps while it grows.- The promise resolves when the new theme is on the page for good and rejects only with an error the callback threw.
- Several clicks in a row are fine: the browser ends the running transition and starts the next one.
While the circle grows, the page is one snapshot: elements the app names for its own view transitions
(view-transition-name in a stylesheet or inline, React's <ViewTransition>) are flattened into it, so a fixed
header or a shared photo does not fade or jump on its own. The reveal finds them by their computed style and takes
their names off inline (a few milliseconds for thousands of elements), then puts each back as it was. A rule matching
every element would cost far more: WebKit restyles the whole page when such a rule starts and again when it stops
applying, each time as long as the theme change itself. The class cf-theme-reveal (REVEAL_CLASS) is on <html>
for the duration; reveal.css keys its rules (on the transition's pseudo-elements only) on it. Names are taken off
twice, before the old snapshot and right after the callback; a name that appears later (in an effect, a frame later)
is not flattened.
In an app's tests
@codefusion-cc/theme/testing is a browser for tests in Node, without a DOM: tabs of one site sharing its storage, a
system that switches between light and dark, and storage that can be blocked, missing or full. Each tab's
<html> keeps what was shown on it:
import { createTheme } from '@codefusion-cc/theme/browser'
import { testBrowser } from '@codefusion-cc/theme/testing'
const browser = testBrowser({ systemDark: true, stored: { 'myapp-theme': 'light' } })
const [first, second] = [browser.openTab(), browser.openTab()]
const mine = createTheme(themeConfig, first.env)
const theirs = createTheme(themeConfig, second.env)
mine.setMode('system')
expect(second.html.classes).toEqual(['dark'])
tab.runScript(themeScript(config)) runs the pre-paint script in a tab, tab.frame() runs its animation frames, and
browser.writeElsewhere(key, value) is a change made by another program.