Theme SwitcherMOL
A dropdown that re-themes the page by loading one generated stylesheet into
<link id="theme-css"> - no JS token objects, no inline overrides.
Each theme is a .css file with :root + .dark
token blocks (the same shape as default-semantic-tokens.css), so dark
mode needs no re-apply.
On this page (6)
§Live
Pick any of the {themeFiles().length} tweakcn presets - the whole page re-themes by loading {'../theme/.css'}. Watch the page's stylesheets in DevTools: exactly one {''} appears and disappears.
§How the mechanism works
A theme is a plain CSS file - theme/<id>.css - generated from the tweakcn presets dataset and shipped next to the token file. Applying it is one DOM operation, no matter which UI triggers it:
// applylet link = document.getElementById('theme-css');if (!link) { link = Object.assign(document.createElement('link'), { id: 'theme-css', rel: 'stylesheet', href: 'theme/claude.css' }); document.getElementById('tokens-css').after(link); // right after the token sheet}// resetlink?.remove();This component wraps exactly that: trigger + popover menu + one <link> swap, plus persistence (a persisted() defuss-store store in localStorage - every store for that key on the page follows), the defuss-theme-change sync event, and a State API for agents/tests. On this repo the files are generated by scripts/build.ts (src/documentation/runtime/themes.ts → dist/theme/*.css); for your own themes, author files in the default-semantic-tokens.css shape (:root + .dark token blocks, tweakcn-compatible) and add one menu item each.
§States
Named states via the shared State API, driven per menu through the bound api:
default- closed (the authored state; the trigger toggles it)open- shown via the nativeshowPopover(), first theme item focused
The first demo carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/theme-switcher-{state}.png.
Machine contract - verified against theme-switcher.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
open | boolean | true, false | false | Shown - driven through the component's own State API. The listbox lists every tweakcn preset. |
§API
Generated from theme-switcher.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type ThemeSwitcherState = 'default' | 'open' - setState(name, config) takes the config of the state it names.
| State | Description |
|---|---|
default | The menu closed. No config. |
open | The menu shown (a popover, top layer). No config. |
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends ThemeSwitcherState>(name: S, config?: ThemeSwitcherStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: ThemeSwitcherState; config: ThemeSwitcherStateConfigs[ThemeSwitcherState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: ThemeSwitcherState; config: ThemeSwitcherStateConfigs[ThemeSwitcherState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: ThemeSwitcherState; config: ThemeSwitcherStateConfigs[ThemeSwitcherState] }> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |
Registry
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.themeSwitcherApi.setState<S extends ThemeSwitcherState>(el: HTMLElement, name: S, config?: ThemeSwitcherStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.themeSwitcherApi.getState(el: HTMLElement): { name: ThemeSwitcherState; config: ThemeSwitcherStateConfigs[ThemeSwitcherState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.themeSwitcherApi.render(state: { name: ThemeSwitcherState; config: ThemeSwitcherStateConfigs[ThemeSwitcherState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.themeSwitcherApi.store(el: HTMLElement): Store<{ name: ThemeSwitcherState; config: ThemeSwitcherStateConfigs[ThemeSwitcherState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.themeSwitcherApi.commit<S extends ThemeSwitcherState>(el: HTMLElement, name: S, config?: ThemeSwitcherStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.themeSwitcherApi.select(menu: HTMLElement, id: string): void | Apply a theme on the switcher owning `menu` (link swap, see above).
| ||||||||||||
df$.shadcn.themeSwitcherStates: ThemeSwitcherState[] | The declared states, 'default' first: default, open. |
§CSS view file
/* -- Theme Switcher component --------------------------------- Dropdown that swaps a <link id="theme-css"> stylesheet. Own popover + anchor rules (self-contained, mirrors .dropdown-content) + menu rows with theme color dots. */@layer components { .theme-switcher { position: relative; display: inline-flex; } .theme-switcher-trigger { display: inline-flex; align-items: center; gap: 0.5rem; max-width: 14rem; & .theme-switcher-dot { width: 0.75rem; height: 0.75rem; border-radius: 9999px; border: 1px solid var(--border); flex-shrink: 0; /* the active theme's primary color, set inline by theme-switcher.js */ background: var(--primary); } & .theme-switcher-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } & .theme-switcher-chevron { width: 1rem; height: 1rem; margin-inline-start: auto; flex-shrink: 0; opacity: 0.6; } } .theme-switcher-menu { background-color: var(--popover); color: var(--popover-foreground); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0.25rem; min-width: 12rem; max-height: 18rem; overflow-y: auto; overscroll-behavior: contain; box-shadow: 0 4px 16px oklch(0 0 0 / 0.12), 0 0 0 1px var(--border); /* Anchor positioning - the trigger names itself, the menu follows */ position: fixed; inset: auto; top: anchor(bottom); left: anchor(left); margin: 0; margin-top: 4px; position-try-fallbacks: flip-block; /* Enter/exit animation on the discrete display switch */ opacity: 0; transform: scale(0.96) translateY(-0.25rem); transition: opacity 150ms ease, transform 150ms ease, display 150ms allow-discrete; &:popover-open { opacity: 1; transform: scale(1) translateY(0); } } @starting-style { .theme-switcher-menu:popover-open { opacity: 0; transform: scale(0.96) translateY(-0.25rem); } } .theme-switcher-item { display: flex; align-items: center; gap: 0.5rem; width: 100%; padding: 0.375rem 0.5rem; border-radius: calc(var(--radius) * 0.6); font-size: 0.875rem; border: none; background: transparent; color: inherit; cursor: pointer; text-align: start; &:hover, &:focus-visible, &[data-highlighted] { background: var(--accent); color: var(--accent-foreground); outline: none; } &[aria-checked='true'] { font-weight: 500; & .theme-switcher-check { visibility: visible; } } & .theme-switcher-dots { display: inline-flex; gap: 2px; flex-shrink: 0; } & .theme-switcher-dots span { width: 0.5rem; height: 0.5rem; border-radius: 9999px; } & .theme-switcher-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } & .theme-switcher-check { width: 1rem; height: 1rem; margin-inline-start: auto; visibility: hidden; flex-shrink: 0; } } /* Accessibility */ @media (prefers-reduced-motion: reduce) { .theme-switcher-menu { transition: none; } } @media (forced-colors: active) { .theme-switcher-menu { border: 1px solid CanvasText; } }}§JavaScript view file
Popover wiring (aria-expanded sync, roving focus, selection) + the <link> swap, persisted through a defuss-store store, and the defuss-theme-change event.
// -- Theme Switcher --------------------------------------------// Dropdown that switches the color theme by swapping ONE stylesheet:// a <link id="theme-css"> pointing at a generated theme file// (theme/<id>.css - same token shape as default-semantic-tokens.css).// That is the entire mechanism: no JS token objects, no inline overrides —// consumers ship theme files and this component loads/unloads them. Each// theme file carries `:root` + `.dark` blocks, so dark-mode toggling needs// no re-apply.// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.// defussQuery: the callable runtime - trigger/item state reflects through// query scalar writes; the theme-sheet link is mounted via query .append(),// swatch dots render as markup in one morph pass instead of a// createElement+appendChild chain (§3 theme-switcher row).import { defussGlobals, defussQuery, loadTheme, safeShowPopover, componentState, bindComponent, persisted } from '../../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type// this file's API docs state - arguments, return values, event details - holds// against its code: a wrong one is a new type error and fails the build.const themeSwitcherStates = ['default', 'open'];/** setState() configs per state - the theme switcher's states take none. */export interface ThemeSwitcherStateConfigs { /** The menu closed. */ default: {}; /** The menu shown (a popover, top layer). */ open: {};}const STORAGE_KEY = 'defuss-shadcn-color-theme';const LINK_ID = 'theme-css';const THEME_EVENT = 'defuss-theme-change';/** * The remembered theme id - a persisted store (AGENTS.md "State through * stores"): memory when storage is blocked, a raw id written by older * versions adopted, and every other store for this key on the page (the * docs header's) follows each write. Made on first use. */let chosen: ReturnType<typeof persisted<string>> | undefined;const remembered = () => (chosen ??= persisted(STORAGE_KEY, 'default'));/** * Why: where theme files live is derived, not configured - the shipped * layout puts them one folder ABOVE the token file (dist/theme/<id>.css * beside dist/theme/utils/default-semantic-tokens.css), so they resolve as * `<tokens-dir>/../<id>.css` relative to the loaded token sheet. * `data-theme-base` on the .theme-switcher root overrides (explicit folder). */function themeHref(root: HTMLElement, id: string): string { if (root.dataset.themeBase) return `${root.dataset.themeBase}/${id}.css`; const tokens = dfDollar('#tokens-css').get(0) || dfDollar('link[href*="default-semantic-tokens.css"]').get(0); // link.href (the property) is absolute → URL resolution is exact, incl. // the jsDelivr CDN URLs the docs mirror rewrites to if (tokens) return new URL(`../${id}.css`, (tokens as HTMLLinkElement).href).href; return `${id}.css`;}/** Apply a theme id by (re)loading its stylesheet. 'default' unloads it. */function applyThemeId(root: HTMLElement, id: string) { let link = dfDollar<HTMLLinkElement>('#' + CSS.escape(LINK_ID)).get(0); if (!id || id === 'default') { link?.remove(); remembered().set('default'); // drop any theme resources (fonts) the active theme had mounted loadTheme('default').catch(() => undefined); syncTrigger(root, 'default'); document.dispatchEvent(new CustomEvent(THEME_EVENT, { detail: { id: 'default' } })); return; } remembered().set(id); if (link && link.dataset.themeId === id) { syncTrigger(root, id); // already loaded - idempotent return; } link?.remove(); link = document.createElement('link'); link.id = LINK_ID; link.rel = 'stylesheet'; link.dataset.themeId = id; link.href = themeHref(root, id); const tokens = dfDollar('#tokens-css').get(0) || dfDollar('link[href*="default-semantic-tokens.css"]').get(0); // insert right after the token sheet (later source order ⇒ the theme // overrides it); without a token sheet, append at the end of <head> // (§5.1: both branches ride query's exact insertion ops) if (tokens) dfDollar(tokens).after(link); else dfDollar(document.head).append(link); // the theme's runtime resources (font <link>s from theme/<id>.json) ride // with the stylesheet - fire-and-forget: fonts are progressive enhancement // and the loader swallows missing sidecars (404 = theme declares none) // the sidecar lives beside the stylesheet (data-theme-base, or the token sheet's folder). // VERIFIED: (tests/theme-links.test.ts) without the href a page without a token sheet asked its own folder loadTheme(id, themeHref(root, id).replace(/\.css(?=$|[?#])/, '.json')).catch(() => undefined); syncTrigger(root, id); document.dispatchEvent(new CustomEvent(THEME_EVENT, { detail: { id } }));}/** Reflect the active id in trigger dot/label + aria-checked across items. */function syncTrigger(root: HTMLElement, id: string) { const $root = dfDollar(root); const trigger = $root.find('.theme-switcher-trigger')[0] as HTMLElement | undefined; const items = Array.from($root.find('.theme-switcher-item')); const active = items.find((i) => (i as HTMLElement).dataset.themeId === id); items.forEach((i) => dfDollar(i).attr('aria-checked', i === active ? 'true' : 'false')); if (!trigger) return; const dot = dfDollar(trigger).find('.theme-switcher-dot')[0]; const label = dfDollar(trigger).find('.theme-switcher-label')[0]; const first = active?.dataset.themeColors?.split(',')[0]?.trim(); // 'default' (or unknown): no inline dot color - the CSS default IS --primary if (dot) dfDollar(dot).css('background', first || ''); if (label && (active || id === 'default')) dfDollar(label).text(active?.dataset.themeLabel || 'Default'); root.dataset.themeId = id; // State API marker stays dataset.*}/** * The markup of a state: none - 'open' lives in the top layer * (:popover-open), not in an attribute, so every state renders the authored * markup. render() stays the State API's markup function all the same. */function applyMarkup(_el, _stateName) {}/** * UI side of setState: 'default' hides the menu, 'open' shows it. * Open/close mechanics stay native (Popover API). */function triggerStateChange(menu: HTMLElement, stateName: string, _config?: Record<string, unknown>) { switch (stateName) { case 'default': try { menu.hidePopover(); } catch { /* already closed */ } break; case 'open': // deferred show (safeShowPopover): showPopover() mid-exit crashes the // headless renderer (same guard as dropdown) safeShowPopover(menu); break; }}/** Registry-level API; pass the menu element explicitly. Unknown names throw. */export const themeSwitcherApi = Object.assign(componentState({ component: 'theme-switcher', states: themeSwitcherStates, apply: (menu, state) => triggerStateChange(menu, state.name, state.config), markup: (el, state) => applyMarkup(el, state.name),}), { /** * Apply a theme on the switcher owning `menu` (link swap, see above). * @param menu - the switcher's menu (any element inside its .theme-switcher root) * @param id - the theme id, one of the switcher's options */ select(menu: HTMLElement, id: string): void { const root = menu.closest('.theme-switcher') as HTMLElement | null; if (!root) throw new Error('theme-switcher: menu is not inside a .theme-switcher root'); applyThemeId(root, id); },});df$.themeSwitcherApi = themeSwitcherApi;df$.themeSwitcherStates = themeSwitcherStates;function init() { (dfDollar('.theme-switcher-menu:not([data-init])').toArray() as HTMLElement[]).forEach((menu) => { menu.dataset.init = ''; const root = menu.closest('.theme-switcher') as HTMLElement | null; // trigger = inside the root, or the declarative popovertarget owner const trigger = ((root ? dfDollar(root).find('.theme-switcher-trigger').get(0) : undefined) ?? (menu.id && dfDollar(`[popovertarget="${menu.id}"]`).get(0))) as HTMLElement | null; const getItems = () => Array.from((dfDollar(menu).find('.theme-switcher-item').toArray() as HTMLElement[])); // CSS anchor positioning - trigger names itself, menu follows if (trigger) { const anchorId = `--theme-switcher-${menu.id || 'menu'}`; dfDollar(trigger).css('anchorName', anchorId); dfDollar(menu).css('positionAnchor', anchorId); } // aria-expanded rides the popover's own toggle event menu.addEventListener('toggle', () => { if (trigger) dfDollar(trigger).attr('aria-expanded', menu.matches(':popover-open') ? 'true' : 'false'); if (menu.matches(':popover-open')) { const first = getItems()[0]; first?.focus(); // the native popover show-command re-focuses the anchor AFTER this // handler; one rAF re-focus if it (or a sibling menu's light-dismiss // restore) won the race - guarded so a quick Tab-away isn't stolen if (first) requestAnimationFrame(() => { if (menu.matches(':popover-open') && document.activeElement === trigger) first.focus(); }); } }); // dots visualized from data-theme-colors (keeps authored markup lean): // swatches ride IN the item's markup - one morph pass fills the holder // instead of a createElement+appendChild chain (§3 theme-switcher row) getItems().forEach((item) => { const holder = dfDollar(item).find('.theme-switcher-dots').get(0); if (holder && !holder.childElementCount) { const spans = (item.dataset.themeColors || '') .split(',') .slice(0, 5) .map((c) => c.trim()) .filter(Boolean) .map((c) => `<span style="background:${c}"></span>`) // token colors come from data-theme-colors (consumer-authored, §5.2 sink rule) .join(''); dfDollar(holder).html(spans); } }); // selection: click / Enter (buttons dispatch click natively for both) menu.addEventListener('click', (e) => { const item = (e.target as HTMLElement).closest<HTMLElement>('.theme-switcher-item'); if (!item || !root) return; applyThemeId(root, item.dataset.themeId || 'default'); menu.hidePopover(); trigger?.focus(); }); // WAI-ARIA menu pattern: roving arrows, Home/End, Escape is native menu.addEventListener('keydown', (e) => { const items = getItems(); const idx = items.indexOf(document.activeElement as HTMLElement); let next = -1; if (e.key === 'ArrowDown') next = idx < 0 ? 0 : (idx + 1) % items.length; else if (e.key === 'ArrowUp') next = idx < 0 ? 0 : (idx - 1 + items.length) % items.length; else if (e.key === 'Home') next = 0; else if (e.key === 'End') next = items.length - 1; if (next >= 0) { e.preventDefault(); items[next].focus(); } }); // per-instance State API binding (state on the element, AGENTS.md) // el.store + el.api (AGENTS.md "State through stores") bindComponent(menu, themeSwitcherApi); // reflect the theme already active on the page (preloaded link or storage) if (root) { const initial = dfDollar('#' + CSS.escape(LINK_ID)).get(0)?.dataset.themeId || remembered().value || 'default'; if (initial !== 'default' || dfDollar('#' + CSS.escape(LINK_ID)).get(0)) syncTrigger(root, initial); } });}// one page-wide listener: any switcher (or the doc-site theme grid) may move// the active theme - keep every switcher's trigger honestdocument.addEventListener(THEME_EVENT, (e) => { const id = (e as CustomEvent<{ id?: string }>).detail?.id || 'default'; (dfDollar('.theme-switcher').toArray() as HTMLElement[]).forEach((root) => syncTrigger(root, id));});init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub