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.
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:
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);
}
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.
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.
?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;
}
<link> TagFor 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.
/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.
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.
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.
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.
There are two strategies for making components look different under each skin:
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.
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.
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.
The pixel aesthetic is deliberately harsh and instantaneous. A good transition:
data-skin="pixel" + load the CSSasync 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);
}
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.
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');
});
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.
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
Several apps demonstrate dual-mode aesthetics:
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.
--radius-*, --shadow-*, --font-ui, --border-width) to :root in themes.css. Components start consuming them.skin to settings store — add SkinName type, skin: 'clean' default, setSkin() function, include in persist/restore.app.html — read skin from localStorage and set data-skin before paint.pixel-skin.css — structural token overrides for [data-skin="pixel"]. Bundle as ?inline.skin-loader.ts — loadPixelSkin() / unloadPixelSkin() using ?inline import pattern.border-radius, font-family, box-shadow with token vars.font-face for any additional pixel fonts, placed in /static/skins/.setSkin().