@codefusion-cc/theme
v0.1.1
Published
Light, dark or the system's theme, kept per browser and shown before first paint: the page never flashes the wrong one, follows the system while open, every open tab follows a change, and a switch can grow the new theme as a circle from its button
Downloads
1,113
Maintainers
Readme
@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. Other looks a person picks (a palette, an accent) can be kept and shown next to the mode.
npm install @codefusion-cc/themeThe 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.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).
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 }. 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';import { revealThemeChange } from '@codefusion-cc/theme/browser'
import { flushSync } from 'react-dom'
import { theme } from './theme.ts'
<button
onClick={(event) =>
revealThemeChange(() => flushSync(() => theme.toggle()), {
// A keyboard click has no pointer position: start from the button then.
origin: event.detail ? { x: event.clientX, y: event.clientY } : event.currentTarget,
})
}
/>- 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;flushSyncalso renders the components that readuseTheme(the button's icon and label) into that snapshot. originis a point or an element (the circle starts from its centre); without one, the middle of the viewport.duration(650 ms) andeasingare options.- 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.
