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 (5)
§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 localStorage persistence, 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. |
§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 with localStorage persistence 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 } from '../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();const themeSwitcherStates = ['default', 'open'];const STORAGE_KEY = 'defuss-shadcn-color-theme';const LINK_ID = 'theme-css';const THEME_EVENT = 'defuss-theme-change';/** Safe in private mode (storage can throw on write). */function store(key?: string, value?: string) { try { if (key === undefined) return localStorage.getItem(STORAGE_KEY); if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value); } catch { /* private mode - theme just won't persist */ }}/** * 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 = document.getElementById('tokens-css') || document.querySelector('link[href*="default-semantic-tokens.css"]'); // 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 = document.getElementById(LINK_ID); if (!id || id === 'default') { link?.remove(); store(STORAGE_KEY, null); // 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; } store(STORAGE_KEY, 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 = document.getElementById('tokens-css') || document.querySelector('link[href*="default-semantic-tokens.css"]'); // 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) loadTheme(id).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.*}/** * 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 = { setState(menu: HTMLElement, stateName: string, config: Record<string, unknown> = {}) { if (!themeSwitcherStates.includes(stateName)) { throw new Error(`theme-switcher: unknown state "${stateName}" (supported: ${themeSwitcherStates.join(', ')})`); } triggerStateChange(menu, stateName, config); // state lives on the ELEMENT, not the module (multiple switchers per page) menu.dataset.stateName = stateName; menu._stateConfig = config; }, getState(menu: HTMLElement) { return { name: menu.dataset.stateName || 'default', config: menu._stateConfig ?? {} }; }, /** Apply a theme on the switcher owning `menu` (link swap, see above). */ select(menu: HTMLElement, id: string) { 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() { document.querySelectorAll<HTMLElement>('.theme-switcher-menu:not([data-init])').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?.querySelector('.theme-switcher-trigger') ?? (menu.id && document.querySelector(`[popovertarget="${menu.id}"]`))) as HTMLElement | null; const getItems = () => Array.from(menu.querySelectorAll<HTMLElement>('.theme-switcher-item')); // 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 = item.querySelector('.theme-switcher-dots'); 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) menu.api = { setState: (stateName, config) => themeSwitcherApi.setState(menu, stateName, config), getState: () => themeSwitcherApi.getState(menu), }; // reflect the theme already active on the page (preloaded link or storage) if (root) { const initial = document.getElementById(LINK_ID)?.dataset.themeId || store() || 'default'; if (initial !== 'default' || document.getElementById(LINK_ID)) 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'; document.querySelectorAll<HTMLElement>('.theme-switcher').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