mode-watcher

SSR-friendly light and dark mode management for Svelte and SvelteKit apps, with zero flash-of-unstyled-content on load.

Library
npm
v1.1.0
609stars
MIT License

Repository Health

Pre-computed score based on development activity, maintenance, community, maturity, and trend momentum.How we score it →
45/100Fair
Development Activity0
Maintenance44
Community48
Maturity48
Momentum40

Technical Analysis

AI-assessed by reading the actual repository — architecture, code quality, innovation, and documentation.How we score it →
81/100Excellent
Architecture85
Code Quality85
Innovation78
Learning Curve75

mode-watcher is a small Svelte library that handles light/dark mode switching the way a full-stack SvelteKit app actually needs it handled: correctly under server-side rendering, with no flash of the wrong theme before hydration. Rather than relying purely on a client-side effect that flips classes after the page has already painted, it injects a tiny inline script into the document head that reads the stored preference (or the OS preference) and sets the dark class and color-scheme style before the rest of the page renders.

The library exposes a single <ModeWatcher /> component plus a handful of plain functions and reactive stores — toggleMode, setMode, resetMode, and a mode store that reflects the current state. Beyond the light/dark binary, it also supports arbitrary named themes via a data-theme attribute and setTheme/theme, and can manage the theme-color meta tag so mobile browser chrome matches the active theme.

Under the hood it’s built on Svelte 5 runes and the svelte-toolbelt box abstraction for reactive state, with runed providing the underlying media-query tracking for OS preference changes. Class and attribute updates are wrapped in a transition-suppression helper so theme switches don’t trigger jarring CSS transitions across the whole page.

It’s maintained under the svecosystem organization (the group behind bits-ui and other Svelte primitives), ships full TypeScript types, and is one of the more widely adopted theme utilities in the Svelte ecosystem — roughly the Svelte equivalent of next-themes in the React world.

What You Get

  • A single <ModeWatcher /> component that can be dropped into a root +layout.svelte to wire up everything automatically
  • An inline head-script injection strategy that eliminates flash-of-unstyled-content by setting the mode before hydration
  • Imperative functions — toggleMode, setMode, resetMode — plus a reactive mode store for reading the current state anywhere in the app
  • Support for arbitrary named themes (not just light/dark) via a data-theme attribute and setTheme/theme
  • Automatic tracking of the operating system’s color-scheme preference, with an option to disable it and pin a default mode instead
  • Optional management of the theme-color meta tag so the browser UI chrome matches the active theme on mobile

Common Use Cases

  • Adding a theme toggle button to a SvelteKit app’s navbar that switches between light and dark instantly with no flicker
  • Respecting a first-time visitor’s OS-level dark mode preference while still letting them override it and persist the choice
  • Building a docs or marketing site with multiple named color themes (not just light/dark) selectable by the user
  • Reading the current mode inside application logic (e.g. to pick a chart color palette or swap an image) via the exported mode store
  • Keeping the mobile browser’s address-bar color in sync with the active theme using the theme-color meta tag support

Under The Hood

Architecture The package separates concerns cleanly across a handful of focused modules under src/lib: mode-states.svelte.ts and theme-state.svelte.ts hold the raw user-preference and OS-preference reactive primitives (built on svelte-toolbelt’s box), states.svelte.ts derives the actual applied mode/theme via $derived.by and performs the DOM mutations (class list and color-scheme toggling) as a side effect of that derivation, and mode.ts exposes the small set of imperative functions (toggleMode, setMode, resetMode, setTheme) that just write into the preference state. The <ModeWatcher> component itself is a thin composition root that wires props into that shared module-level state and picks between a “full” variant (which injects the anti-FOUC head script) and a “lite” variant (disableHeadScriptInjection) for apps that want to manage the script themselves. This keeps the reactive core framework-agnostic-ish and the component layer as a deliberately small adapter, so what breaks if the core states.svelte.ts module changes is really just how side effects are scheduled, not the public API.

Tech Stack Built for Svelte 5 (peer dependency ^5.27.0) using runes ($derived.by, $effect.pre, untrack), with svelte-toolbelt supplying the box reactive-state wrapper and runed supplying OS media-query tracking primitives. It’s packaged with @sveltejs/package/svelte-package and validated with publint, developed inside a pnpm workspace that also hosts a full SvelteKit documentation site, and tested with Vitest plus @testing-library/svelte and Playwright for end-to-end coverage. Releases are automated via Changesets, with separate GitHub Actions workflows for CI, preview builds/releases, and production docs deploys.

Code Quality The library has real unit test coverage for its core toggling, custom-theme, and class-name behavior (src/index.test.ts, src/tests/mode.spec.ts) using rendered Svelte test components rather than pure logic mocks, though a few OS-preference-tracking tests are explicitly skipped pending a way to mock Svelte’s media-query primitive. TypeScript is configured in strict mode across the package, ESLint (flat config, typescript-eslint + eslint-plugin-svelte) and Prettier enforce style, and CI runs on every push via GitHub Actions. Naming is consistent and functions are narrowly scoped with JSDoc comments on public exports.

API Design The public surface is intentionally tiny — one component plus five functions and two stores — which keeps the getting-started cost close to zero (a single <ModeWatcher /> drop-in handles the common case with no configuration). Power users get incremental knobs (track, defaultMode, themeColors, custom class names, disableTransitions, disableHeadScriptInjection) without those options intruding on the simple path, and the anti-FOUC script is generated in a type-safe way via createInitialModeExpression rather than a hand-written string, which is a nice touch of internal API design bleeding into safety for consumers who need to inline it manually.

Join founders buildingwith open source

Opinionated takes, migration guides, cost-saving tips, and insights from the open source ecosystem.

Subscribe on Substack
Join 750+ subscribers

Search