Theme
Design your own
On this page (6)
Component Skill — components/theme-switcher/component-skill.md

Native basis

popover dropdown (Popover API + CSS anchor positioning) whose selection swaps a <link rel="stylesheet"> - the theme is a file

Web Platform APIs

<link rel="stylesheet">Popover APICSS Anchor PositioningWAI-ARIA Menu-buttonCustomEvent

Classes

.theme-switcher.theme-switcher-trigger.theme-switcher-menu.theme-switcher-item

Data attributes

• data-theme-id (item) - theme id; becomes the link's data-theme-id + the filename <id>.css; default unloads

• data-theme-label (item) - trigger label when selected

• data-theme-colors (item) - up to 5 colors → swatch dots

• data-theme-base (root) - explicit theme-files folder (default: derived from the tokens-css link's href)

Notes

• The page must load its token stylesheet with id="tokens-css" - theme files resolve as siblings of that URL

• Persists through a persisted() store (defuss-shadcn-color-theme in localStorage; a raw id from older versions is adopted) and dispatches defuss-theme-change (event.detail.id) on every change

• <link> loads are async - measure theme tokens after the next frame, not synchronously after selection

§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:

// apply
let 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
}
// reset
link?.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 native showPopover(), 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:

StateTypeValuesDefaultDescription
openbooleantrue, falsefalseShown - 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.

StateDescription
default
The menu closed.

No config.

open
The menu shown (a popover, top layer).

No config.

Every element

MemberDescription
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.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?ThemeSwitcherStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

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 { name: ThemeSwitcherState; config: ThemeSwitcherStateConfigs[ThemeSwitcherState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

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.
ArgumentTypeDescription
state?{ name: ThemeSwitcherState; config: ThemeSwitcherStateConfigs[ThemeSwitcherState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

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

MemberDescription
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.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?ThemeSwitcherStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

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.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: ThemeSwitcherState; config: ThemeSwitcherStateConfigs[ThemeSwitcherState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

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.
ArgumentTypeDescription
state{ name: ThemeSwitcherState; config: ThemeSwitcherStateConfigs[ThemeSwitcherState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.themeSwitcherApi.store(el: HTMLElement): Store<{ name: ThemeSwitcherState; config: ThemeSwitcherStateConfigs[ThemeSwitcherState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: ThemeSwitcherState; config: ThemeSwitcherStateConfigs[ThemeSwitcherState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

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.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?ThemeSwitcherStateConfigs[S]its config
df$.shadcn.themeSwitcherApi.select(menu: HTMLElement, id: string): void
Apply a theme on the switcher owning `menu` (link swap, see above).
ArgumentTypeDescription
menuHTMLElementthe switcher's menu (any element inside its .theme-switcher root)
idstringthe theme id, one of the switcher's options
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 honest
document.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