Projects bibleweb Docs atmosphere-theme-switching.md

Skinnable / Themeable UI Architecture for Toggleable Visual Modes

Last modified March 28, 2026

Skinnable / Themeable UI Architecture for Toggleable Visual Modes

Research date: 2026-03-29 Context: BibleWeb SvelteKit app — adding a "Pixel Art" skin on top of the existing 4-theme (Dark/Light/Sepia/High-Contrast) + 3-comfort-profile system.


1. Two-Layer Model: Color Themes vs Visual Skins

Before getting into implementation, it is worth naming the distinction clearly.

The existing system is a color theme: every variation shares the same markup, layout, fonts, and component structure. Only the 23 CSS custom property values differ. This is cheap — a single class on <html> rewrites every color token at once.

A visual skin is a different category. It can change:

  • Font family (body copy, headings, UI labels)
  • Border radius (sharp pixel corners vs rounded modern)
  • Spacing scale
  • Shadow style (hard pixel drop shadows vs soft Gaussian)
  • Background imagery or animation
  • Iconography style (pixel sprites vs SVG icons)
  • Component-level markup (e.g. a pixel "window frame" wrapper around panels)

These two layers should be modelled separately in the state and in the CSS. A user should be able to use Pixel Art skin + Sepia color theme without conflict.

State:
  skin:  'clean' | 'pixel'          ← visual skin
  theme: 'dark' | 'light' | 'sepia' | 'high-contrast'  ← color palette

HTML attribute:
  <html data-skin="pixel" class="theme-sepia">

CSS selectors then compose both dimensions independently:

/* Color tokens — driven by class */
.theme-sepia { --color-background: rgb(242, 232, 215); }

/* Skin structural tokens — driven by data-skin */
[data-skin="pixel"] {
  --font-ui:         'm5x7', monospace;
  --radius-panel:    0px;
  --shadow-panel:    4px 4px 0 rgba(0,0,0,0.8);
  --border-width:    2px;
  --border-style:    solid;
}

/* Combined: color changes per-theme even inside pixel skin */
[data-skin="pixel"].theme-dark {
  --color-background: rgb(8, 4, 16);
}

2. Full Design Token Taxonomy

The existing themes.css covers 21 color tokens. A skin system needs to expand the token vocabulary beyond color into structural tokens:

:root {
  /* --- Typography --- */
  --font-body:           'Georgia', serif;
  --font-ui:             system-ui, sans-serif;
  --font-mono:           'Courier New', monospace;
  --font-size-base:      1.125rem;
  --font-weight-body:    400;
  --font-weight-heading: 600;
  --letter-spacing-ui:   0;

  /* --- Shape --- */
  --radius-sm:     4px;
  --radius-md:     8px;
  --radius-lg:     12px;
  --radius-panel:  8px;
  --radius-button: 6px;
  --radius-input:  4px;

  /* --- Elevation / Shadows --- */
  --shadow-sm:     0 1px 3px rgba(0,0,0,0.2);
  --shadow-md:     0 4px 12px rgba(0,0,0,0.3);
  --shadow-panel:  0 8px 24px rgba(0,0,0,0.4);

  /* --- Spacing scale --- */
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-6: 1.5rem;

  /* --- Borders --- */
  --border-width:  1px;
  --border-style:  solid;

  /* --- Transition --- */
  --transition-speed: 200ms;
  --transition-easing: ease;
}

Then the pixel skin overrides structural tokens only:

[data-skin="pixel"] {
  --font-ui:             'm5x7', monospace;
  --font-body:           'm5x7', monospace;
  --radius-sm:           0px;
  --radius-md:           0px;
  --radius-lg:           0px;
  --radius-panel:        0px;
  --radius-button:       0px;
  --radius-input:        0px;
  --shadow-sm:           2px 2px 0 rgba(0,0,0,0.9);
  --shadow-md:           4px 4px 0 rgba(0,0,0,0.9);
  --shadow-panel:        4px 4px 0 rgba(0,0,0,0.9);
  --border-width:        2px;
  --letter-spacing-ui:   0.05em;
  --transition-speed:    0ms;   /* instant — no smooth transitions in pixel skin */
}

The clean skin just stays at :root defaults, so no extra file is needed for it.


3. CSS Code Splitting — Loading Pixel Skin Lazily

The pixel art skin involves additional assets: a background image, possibly sprite sheets, atmospheric particle canvas. Loading all of this upfront is wasteful for the 90% of users who never activate it.

Pattern A: Vite ?inline Dynamic Import (Recommended)

Vite's ?inline suffix imports a CSS file as a string (running preprocessors but not injecting it). You can then inject/remove it manually:

// lib/skin-loader.ts
let pixelStyleEl: HTMLStyleElement | null = null;

export async function loadPixelSkin(): Promise<void> {
  if (pixelStyleEl) return; // already loaded
  const { default: css } = await import('$lib/theme/pixel-skin.css?inline');
  pixelStyleEl = document.createElement('style');
  pixelStyleEl.id = 'pixel-skin-styles';
  pixelStyleEl.textContent = css;
  document.head.appendChild(pixelStyleEl);
}

export function unloadPixelSkin(): void {
  pixelStyleEl?.remove();
  pixelStyleEl = null;
}

Pattern B: Dynamic <link> Tag

For very large CSS files (background art, sprite references), loading as a <link> lets the browser cache and stream it:

export async function loadPixelSkinStylesheet(): Promise<void> {
  if (document.getElementById('pixel-skin-link')) return;
  return new Promise((resolve, reject) => {
    const link = document.createElement('link');
    link.id = 'pixel-skin-link';
    link.rel = 'stylesheet';
    link.href = '/skins/pixel.css'; // placed in /static/skins/
    link.onload = () => resolve();
    link.onerror = reject;
    document.head.appendChild(link);
  });
}

For SvelteKit with Vite, files in /static/ are served as-is and excluded from the bundle. This is the right place for large theme assets.

What Goes in Each CSS File

/static/skins/pixel.css          ← background images, @font-face if not global,
                                    ::before/:after decoration, border-image rules
/src/lib/theme/pixel-skin.css    ← structural token overrides (tiny — use ?inline)

Keep structural token overrides (the [data-skin="pixel"] block) in the Vite-bundled source so they apply instantly. Put large asset references in the static file which loads asynchronously.


4. Font Switching — m5x7 On Demand

The existing fonts.css already defines @font-face for m5x7 and registers --font-pixel. The font is declared globally but only used if applied via --font-ui. The browser will not download the font file until something uses it.

This means m5x7 is already lazily loaded by default as long as nothing in the clean skin references --font-pixel. No extra work needed.

However, to guarantee the font is cached before the skin activates (avoiding a FOUT), pre-fetch it when the user hovers the skin toggle:

<button
  onmouseenter={prefetchPixelFont}
  onclick={activatePixelSkin}
>
  Pixel Art Mode
</button>
function prefetchPixelFont(): void {
  const link = document.createElement('link');
  link.rel = 'prefetch';
  link.href = '/fonts/m5x7.woff2';
  link.as = 'font';
  link.type = 'font/woff2';
  link.crossOrigin = 'anonymous';
  document.head.appendChild(link);
}

The font-display: swap on the @font-face (already set in fonts.css) means text stays visible during the swap period — the fallback monospace renders first, then m5x7 replaces it. At 16px+ scale, m5x7 is designed to be readable at any size since it is a bitmap-style font.


5. Preventing Flash of Wrong Skin (FOWT)

The existing app already uses initSettings() called from layout to restore settings.theme. The same mechanism needs to handle skin.

The critical rule: apply skin before first paint. An inline script in app.html runs during parsing, before any Svelte hydration:

<!-- src/app.html -->
<script>
  (function() {
    try {
      var s = JSON.parse(localStorage.getItem('bibleweb_settings') || '{}');
      if (s.skin === 'pixel') {
        document.documentElement.setAttribute('data-skin', 'pixel');
      }
      if (s.theme) {
        document.documentElement.classList.add('theme-' + s.theme);
      }
    } catch(e) {}
  })();
</script>

This is the same technique used by next-themes, svelte-themes, and the SvelteKit dark mode patterns documented at CaptainCodeman — inject a tiny synchronous script before styles load so the first painted frame is already correct.

For the structural token overrides that go in the bundled CSS (the [data-skin="pixel"] rules), they are included in the main CSS bundle so they are already available when the inline script sets data-skin.


6. State Management — Adding Skin to the Settings Store

The existing settings.svelte.ts is the right place. Add skin as a first-class setting:

// Addition to settings.svelte.ts

export type SkinName = 'clean' | 'pixel';

export const settings = $state({
  fontSize: 'm' as FontSize,
  language: 'en' as 'en' | 'nl',
  theme: 'dark' as ThemeName,
  skin: 'clean' as SkinName,        // new
  translation: 'BSB' as string,
  comfortProfile: 'standard' as ComfortProfile,
});

export async function setSkin(skin: SkinName): Promise<void> {
  if (skin === 'pixel') {
    await loadPixelSkin();           // loads CSS async
  }
  settings.skin = skin;
  document.documentElement.setAttribute('data-skin', skin);
  persist();
}

The SSR safety concern (global $state leaking between requests on the server) documented by Mainmatter does not affect skin because it is UI presentation state — it does not carry user-specific data and defaults to 'clean'. The inline script in app.html corrects the value before hydration.


7. Component-Level Theming — CSS vs Markup Variants

There are two strategies for making components look different under each skin:

Strategy A: CSS-only (preferred for most components)

The vast majority of components only need CSS changes: font, border-radius, shadow, colors. With the full token vocabulary in place, no Svelte changes are needed.

/* In component <style> */
.panel {
  background: var(--color-surface);
  border-radius: var(--radius-panel);
  box-shadow: var(--shadow-panel);
  border: var(--border-width) var(--border-style) var(--color-popup-border);
  font-family: var(--font-ui);
}

Under clean, this renders as a soft rounded card. Under pixel, it becomes a hard-edged box with a pixel drop shadow.

Strategy B: Nine-Slice Panel Borders (pixel-only decorative wrapper)

For authentic retro panels, CSS border-image with nine-slice slicing is the right approach:

[data-skin="pixel"] .panel {
  border-image: url('/skins/pixel-panel-border.png') 4 fill / 4px stretch;
  background: transparent;  /* border-image handles the background fill */
}

Kenney's Pixel UI Pack (CC0 license) provides ready-made nine-slice PNGs for buttons, panels, and scrollbars.

Strategy C: Conditional Markup in Svelte (only for structural differences)

Reserve this for cases where the pixel skin needs genuinely different DOM elements — e.g. a pixel art "window chrome" wrapper:

<!-- VersePanel.svelte -->
<script>
  import { settings } from '$lib/stores/settings.svelte';
</script>

{#if settings.skin === 'pixel'}
  <div class="pixel-window-frame">
    <div class="pixel-title-bar"><slot name="title" /></div>
    <div class="pixel-content"><slot /></div>
  </div>
{:else}
  <div class="panel">
    <slot />
  </div>
{/if}

Use this sparingly — every conditional in markup increases maintenance surface. Prefer CSS-only solutions until the visual difference genuinely requires different DOM structure.


8. Skin Transition — Smooth vs Instant

Clean → Pixel: Fade Out, Then Snap In

The pixel aesthetic is deliberately harsh and instantaneous. A good transition:

  1. Fade the page to black (0.2s opacity transition)
  2. Apply data-skin="pixel" + load the CSS
  3. Fade back in
async function activatePixelSkin(): Promise<void> {
  // Step 1: fade out
  document.documentElement.style.transition = 'opacity 0.2s ease';
  document.documentElement.style.opacity = '0';

  // Step 2: load and apply (happens during fade)
  await loadPixelSkin();
  document.documentElement.setAttribute('data-skin', 'pixel');

  // Step 3: fade back in
  await new Promise(r => setTimeout(r, 220));
  document.documentElement.style.opacity = '1';
  // Remove inline transition after it completes
  setTimeout(() => {
    document.documentElement.style.transition = '';
    document.documentElement.style.opacity = '';
  }, 400);
}

Pixel → Clean: Dissolve or Instant

Returning to clean mode can use a simple opacity crossfade. The WebGL-based Perlin noise dissolve described in the CSS-Tricks dissolve article would be spectacular for a "power off" effect but is complex. Start simple.

Disabling Component Transitions During Skin Switch

When switching skins, you temporarily want to suppress all existing transition CSS rules to avoid individual elements flickering in asynchronously. A utility class:

.no-transitions * {
  transition: none !important;
}
document.documentElement.classList.add('no-transitions');
// ... apply skin ...
requestAnimationFrame(() => {
  document.documentElement.classList.remove('no-transitions');
});

9. Performance — Avoiding Layout Thrash and Jank

Key rules drawn from MDN, SitePoint, and Addy Osmani's research:

Use data-attribute toggle on <html>, not inline style manipulation. A single attribute change on the root causes one cascade recalculation. Changing individual CSS properties on individual elements causes N recalculations.

Avoid background-attachment: fixed on pixel art backgrounds — it forces a repaint on every scroll frame. Use background-attachment: scroll or implement parallax via CSS transform: translateZ() on a pseudo-element instead.

Batch DOM reads before writes. When switching skins, do not interleave reading layout properties (like offsetHeight) and writing styles — this triggers forced reflow (layout thrash).

Use will-change: transform on particle canvas and animated atmospheric elements — promotes them to their own compositor layer so they do not repaint with the rest of the page.

Prefer opacity and transform for all animations — these are the only two CSS properties that do not trigger layout or paint and run on the GPU compositor thread.

Test with requestAnimationFrame timing to confirm theme switches complete within a single frame (~16ms). Profile with Chrome DevTools Paint flashing enabled.


10. Architecture Diagram

app.html (inline script)
  └── reads localStorage → sets data-skin + class on <html>

settings.svelte.ts ($state module)
  ├── skin: SkinName          ← new field
  ├── theme: ThemeName        ← existing
  ├── setSkin(skin)           ← loads CSS, sets data-skin, persists
  └── setTheme(theme)         ← existing

CSS Loading
  ├── /src/lib/theme/themes.css         ← always loaded (color tokens, bundled)
  ├── /src/lib/theme/pixel-skin.css     ← structural tokens (bundled via ?inline)
  └── /static/skins/pixel-assets.css   ← background images, sprites (lazy <link>)

Fonts
  ├── /src/lib/theme/fonts.css          ← @font-face declarations (always loaded)
  └── /static/fonts/m5x7.woff2         ← not downloaded until --font-ui references it

CSS Cascade
  <html data-skin="pixel" class="theme-sepia">
    ├── :root {}                 ← global defaults (clean skin)
    ├── .theme-sepia {}          ← color palette override
    ├── [data-skin="pixel"] {}   ← structural override
    └── [data-skin="pixel"].theme-sepia {} ← combined overrides if needed

Components
  ├── Most: CSS-only via token vars
  └── Rare structural diffs: {#if settings.skin === 'pixel'} markup variant

11. Real-World Examples of Dramatic Visual Mode Switching

Several apps demonstrate dual-mode aesthetics:

  • MakeReign Academy: Switches between professional marketing site and a retro pixel aesthetic — uses pixel fonts, custom pixelated cursor, and static overlays
  • Y-N10: Full pixel canvas site where the entire UI is rendered as isometric pixel art
  • Nokia tribute sites: Monochromatic pixel-constrained UIs with authentic retro interaction
  • Game dev tools like Aseprite and PICO-8: Their web presence uses pixel aesthetics matching their product

The consistent pattern in real-world implementations: the "dramatic" skin is always CSS-additive (it adds visual decoration on top of clean semantic markup) rather than rebuilding the component tree.


12. Recommended Implementation Order

  1. Expand CSS tokens — add structural tokens (--radius-*, --shadow-*, --font-ui, --border-width) to :root in themes.css. Components start consuming them.
  2. Add skin to settings store — add SkinName type, skin: 'clean' default, setSkin() function, include in persist/restore.
  3. Inline script in app.html — read skin from localStorage and set data-skin before paint.
  4. Create pixel-skin.css — structural token overrides for [data-skin="pixel"]. Bundle as ?inline.
  5. Create skin-loader.tsloadPixelSkin() / unloadPixelSkin() using ?inline import pattern.
  6. Update components one by one — replace hardcoded border-radius, font-family, box-shadow with token vars.
  7. Add pixel assets CSS — nine-slice borders, background, font-face for any additional pixel fonts, placed in /static/skins/.
  8. Skin transition — add fade-out/in wrapper in setSkin().
  9. Prefetch on hover — font + CSS prefetch when user hovers the skin toggle button.

Sources