Theming
Light and dark mode support in Visor — automatic system preference detection and manual theme toggle.
Overview
Visor ships light and dark themes out of the box via @loworbitstudio/visor-core. Both themes are CSS-only — no JavaScript required for the default behavior.
The system works at two levels:
- Automatic —
prefers-color-scheme: darkis respected by default. Users with dark system preferences get dark mode immediately. - Manual toggle — Apply a class or data attribute to override the system preference.
Quick Start
Import the full token bundle in your app's root CSS file:
@import '@loworbitstudio/visor-core';That's it. The bundle includes:
- All primitive tokens (colors, spacing, typography)
- All semantic tokens
- Light theme applied to
:root - Dark theme applied via
@media (prefers-color-scheme: dark)and manual selectors
Using Stock Themes
Four stock themes ship with @loworbitstudio/visor-core as CSS subpath exports. Import the one you want and apply its class to your root element:
@import '@loworbitstudio/visor-core';
@import '@loworbitstudio/visor-core/themes/blackout';<body class="blackout-theme">
<!-- your app -->
</body>Available themes:
| Import | Class |
|---|---|
@loworbitstudio/visor-core/themes/blackout | .blackout-theme |
@loworbitstudio/visor-core/themes/modern-minimal | .modern-minimal-theme |
@loworbitstudio/visor-core/themes/neutral | .neutral-theme |
@loworbitstudio/visor-core/themes/space | .space-theme |
Each stock theme is class-scoped — it only activates within elements that carry the theme class. Import the base @loworbitstudio/visor-core first; the stock theme import adds color, typography, and spacing overrides on top.
Automatic Dark Mode
No setup required. When a user's OS is set to dark mode, prefers-color-scheme: dark applies the dark token values automatically.
The dark theme activates on :root unless the consumer has explicitly forced light mode:
/* Applied automatically when system prefers dark */
@media (prefers-color-scheme: dark) {
:root:not(.light):not(.theme-light):not([data-theme="light"]) {
--text-primary: var(--color-gray-50);
--surface-page: var(--color-gray-950);
/* ...all adaptive tokens switch to dark values */
}
}Manual Theme Toggle
To override the system preference, apply one of the following to your <html> element (or any root container):
| Selector | Effect |
|---|---|
.dark | Force dark mode |
.theme-dark | Force dark mode (legacy alias) |
[data-theme="dark"] | Force dark mode |
.light | Force light mode (override dark system preference) |
.theme-light | Force light mode (legacy alias) |
[data-theme="light"] | Force light mode |
HTML Attribute Example
<!-- Force dark mode regardless of system preference -->
<html data-theme="dark">
<!-- Force light mode regardless of system preference -->
<html data-theme="light">
<!-- Let the system preference decide (default) -->
<html>Class Example
<html class="dark">
<!-- dark mode active -->
</html>JavaScript Theme Toggle
The @loworbitstudio/visor-core package exports utilities for toggling themes programmatically:
import { applyTheme, getSystemTheme } from '@loworbitstudio/visor-core';
// Apply a theme
applyTheme('dark'); // adds .dark to <html>
applyTheme('light'); // adds .light to <html>
// Read the system preference
const systemTheme = getSystemTheme(); // 'light' | 'dark'You can also pass a custom element as the second argument:
const container = document.getElementById('app');
applyTheme('dark', container);Persisting User Preference
Combine applyTheme with localStorage to persist the user's choice:
import { applyTheme, getSystemTheme, type Theme } from '@loworbitstudio/visor-core';
function initTheme() {
const stored = localStorage.getItem('theme') as Theme | null;
const theme = stored ?? getSystemTheme();
applyTheme(theme);
}
function toggleTheme() {
const current = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
const next: Theme = current === 'dark' ? 'light' : 'dark';
applyTheme(next);
localStorage.setItem('theme', next);
}React Hook Example
import { useState, useEffect } from 'react';
import { applyTheme, getSystemTheme, type Theme } from '@loworbitstudio/visor-core';
function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'light';
return (localStorage.getItem('theme') as Theme) ?? getSystemTheme();
});
useEffect(() => {
applyTheme(theme);
localStorage.setItem('theme', theme);
}, [theme]);
return { theme, setTheme };
}
// Usage
function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
{theme === 'dark' ? 'Switch to Light' : 'Switch to Dark'}
</button>
);
}Granular CSS Imports
Instead of the full bundle, you can import individual CSS files:
/* Primitive tokens only */
@import '@loworbitstudio/visor-core/primitives';
/* Semantic tokens only */
@import '@loworbitstudio/visor-core/semantic';
/* Light theme adaptive tokens */
@import '@loworbitstudio/visor-core/themes/light';
/* Dark theme adaptive tokens (manual selectors + media query) */
@import '@loworbitstudio/visor-core/themes/dark';This is useful when you want to compose the token layers manually or when your build system handles CSS concatenation.
Token Architecture
All components in Visor reference CSS custom properties — never hardcoded values. This means every component automatically responds to theme changes with zero extra code.
/* A component referencing adaptive tokens */
.card {
background: var(--surface-card); /* white in light, gray-900 in dark */
color: var(--text-primary); /* gray-900 in light, gray-50 in dark */
border: 1px solid var(--border-default); /* gray-200 in light, gray-700 in dark */
}The 3-tier token architecture:
Primitives Semantic Adaptive
--color-gray-50 --text-primary switches light ↔ dark
--color-gray-900 --surface-card based on theme class
--color-blue-500 --border-default or prefers-color-schemeConsumers can override any tier. Overriding a primitive cascades to all semantic and adaptive tokens that reference it.