The variants below have buttery smooth animations. But this website's theming is overlapping with the variants, causing the theme toggle animations to appear to have framerate drops. This does not happen when you copy/paste to Webflow all – they look as smooth as the video above. See for yourself:
Click this link to see how these beautiful toggles perform on a real Webflow site.
Features
- Simple setup: add data attributes to buttons and you're done
- No flash of wrong theme on page load with inline anti-flash script
- All toggles styled with normal CSS – the script adds no inline styles
- Works with system preference out of the box for first-time visitors
- Extend beyond light/dark to any number of custom themes
- Multiple toggle groups on a page stay in sync automatically
Setup
How It Works
The script handles automatic theme detection from a user's system preferences. If the user selects a different theme on your site, the script handles "remembering" that preference for the future (by using the browser's localStorage).
There are two attributes added to both the <html> and <body> elements:
- A CSS class (e.g.
.light,.dark,.sepia, etc – but never "system") that marks the actual visual theme. - The
[data-theme]attribute that denotes what the user selected (e.g.[data-theme='system'],[data-theme='light'],, etc.)
Both elements receive the same class and attribute. The <html> element is set first by the head script for anti-flash (since it exists before <body>), then the main script keeps both in sync. This dual approach ensures CSS selectors work regardless of whether you're using CSS variables (which cascade from <html>) or applying styles directly to <body> (common in Webflow).
This is what enables CSS states and animations without any JavaScript like "show a different icon when the user chose system preference" or "move the toggle to the right when it's dark mode".
Preventing FOUC
The inline <head> script reads localStorage (or defaults to "system"), then immediately sets the theme class on <html>. Since <html> exists before <body> is even parsed, this prevents the flash of wrong-themed content (FOUC). The main script then syncs both <html> and once the page loads.
How Preferences Persist
First-time visitors (no localStorage) default to whatever their system preference is (like a Mac's dark mode or light mode). If they don't have any system preferences set, it falls back to light mode as the default.
When a user clicks an explicit theme toggle:
data-themechanges to their choice (e.g., "dark")- Theme no longer follows OS preference
- Choice is saved to
localStorage
To return to system preference, the user can simply click a "System" theme button – i.e. one with [data-theme-value='system'].
Variants
A reminder: The variants below have smooth animations, but this website's theming is conflicting with the variants, causing framerate drops that won't actually exist in your Webflow projects at all.
Click this link to see how these beautiful toggles perform on a real Webflow site.
Customization
Button Attributes
| Attribute | Value | Description |
|---|---|---|
data-theme-toggle | Cycles through all themes except "system" | |
data-theme-value | light, dark, system... | Sets theme to the specified value on click |
Smooth Theme Transitions
Add CSS transitions for smooth theme changes:
body {
transition: background-color 0.3s ease, color 0.3s ease;
}Styling Active Buttons
Use CSS to style active buttons based on the current state.
Two-button toggle (Light/Dark only):
/* Highlight the light mode button when light mode is active */
.light .theme-button.is-light {
background: var(--primary);
color: white;
}
/* Highlight the dark mode button when dark mode is active */
.dark .
Three-button toggle (Light/Dark/System):
When you have a "System" button, check the data-theme attribute instead:
/* Highlight the light mode button when light mode is active */
[data-theme='light'] .theme-button.is-light {
background: var(--primary);
color: white;
}
Detecting System Choices
Show different UI elements based on the user's choice:
/* Show when user chose system preference */
[data-theme='system'] .system-indicator {
display: block;
}
/* Show when user made explicit choice */
[data-theme='light'] .user-choice-indicator,
[data-theme
Multi-Theme Support
Extend beyond light/dark to any number of themes:
1. Extend the themes array:
ThemeManager.themes = ['light', 'dark', 'sepia', 'high-contrast'];2. Add CSS for new themes:
.sepia {
--bg: #f4ecd8;
--text: #5c4b37;
}
.high-contrast {
--bg: #000000;
--text: #ffffff;
--primary: #
3. Add buttons:
<button data-theme-value="light">Light</button>
<button data-theme-value="dark">Dark</button>
<button data-theme-value="sepia">Sepia</button>
JavaScript API
Properties
ThemeManager.current; // Effective theme (e.g., "light", "dark") - never "system"
ThemeManager.theme; // User's choice (can be "system", "light", "dark", etc.)
ThemeManager.themes; // Array of available themes
ThemeManager.initialized; // Boolean indicating init stateMethods
// Set theme explicitly
ThemeManager.setTheme('dark');
// Set to follow system preference
ThemeManager.setTheme('system');
// Get current effective theme
SPA Usage
For single-page applications where buttons may be added dynamically:
// After adding new buttons to the DOM
ThemeManager.refresh();
// Before unmounting/cleanup
ThemeManager.destroy();
// Re-initialize on new page
ThemeManager.init();Events
Listen for theme changes with the themechange event:
window.addEventListener('themechange', (e) => {
console.log('Theme:', e.detail.theme); // Effective theme
console
Event Detail Properties
| Property | Type | Description |
|---|---|---|
theme | string | Current effective theme |
previous | string | Previous effective theme |
userTheme | string | User's choice (can be "system") |
|