Nightwave
Zero-dependency wave theme toggle
A 2 KB module that ripples outward from your toggle button using the View Transition API. Handles persistence, system sync, media switching, and reduced-motion fallback - with zero dependencies.
Installation
Drop two files in. No build step, no npm, no bundler.
<!-- In <head> -->
<link rel="stylesheet" href="nightwave.css">
<!-- Before </body> -->
<script src="nightwave.js"></script>
That’s it. No package manager. No bundler. No module system required - though it works as a plain ES5 script in any environment.
Quick Start
The minimal setup. Working wave toggle in under a minute.
<!-- 1. Add CSS -->
<link rel="stylesheet" href="nightwave.css">
<!-- 2. Your theme tokens -->
<style>
[data-theme="dark"] { --bg: #0a0a0f; --text: #e8e8f2; }
[data-theme="light"] { --bg: #f5f5ff; --text: #0a0a18; }
</style>
<!-- 3. A toggle button -->
<button id="themeToggle" aria-label="Toggle colour scheme">
Toggle Theme
</button>
<!-- 4. Init -->
<script src="nightwave.js"></script>
<script>
new Nightwave({
toggleSelector: '#themeToggle',
storageKey: 'my-site-theme'
}).init();
</script>
How It Works
When the toggle is clicked, Nightwave calls document.startViewTransition() to snapshot the current and next states. A clip-path circle expands from the button’s centre, revealing the new theme beneath. Duration: 620 ms, ease-in-out.
If the browser doesn’t support View Transition API, or the user has prefers-reduced-motion enabled, the wave is skipped entirely. The theme still switches - instantly, with the smooth CSS color transitions from nightwave.css. Graceful degradation. Never a broken toggle.
Theme preference is stored under storageKey in localStorage. Stored value takes priority on load. Without a stored value, Nightwave reads prefers-color-scheme and syncs live with the system.
API Reference
Constructor Options
const nw = new Nightwave({
toggleSelector: '#themeToggle', // default
storageKey: 'avfolio-theme', // default
darkThemeColor: '#08080f', // meta theme-color
lightThemeColor: '#f3f2ff', // meta theme-color
enableSystemSync: true, // follow OS preference
enableWave: true, // VT API wave animation
mediaSelector: '[data-theme-light],[data-theme-dark]'
});
| Option | Default | Description |
|---|---|---|
| toggleSelector | '#themeToggle' | CSS selector for the toggle button. |
| storageKey | 'avfolio-theme' | localStorage key for persisting the active theme. |
| darkThemeColor | '#08080f' | meta[name=“theme-color”] value in dark mode. |
| lightThemeColor | '#f3f2ff' | meta[name=“theme-color”] value in light mode. |
| enableSystemSync | true | Follow prefers-color-scheme changes when no manual choice has been made. |
| enableWave | true | Use the View Transition API wave. false = instant switch only. |
| mediaSelector | '[data-theme-light],…' | Selector for elements with data-theme-* attributes to swap on change. |
Methods
nw.init();
nw.toggle();
nw.applyTheme('dark');
nw.applyTheme('light', { persist: false, updateMedia: true });
nw.clearUserPreference();
nw.destroy();
| Method | Description |
|---|---|
| init() | Reads saved preference, applies initial theme, wires up event listeners. Call once. |
| toggle() | Switches to the opposite theme, triggering the wave if supported. |
| applyTheme(theme, cfg?) | Applies ‘dark’ or ‘light’ directly. Second argument: { persist: bool, updateMedia: bool }. |
| clearUserPreference() | Removes the stored preference and reverts to the system default. |
| destroy() | Removes all event listeners. Call before replacing the instance. |
Media Switching
Nightwave can swap images, video posters, and background images automatically when the theme changes. Add both sources on the same element - Nightwave handles the rest, including a smooth 220 ms cross-fade.
Images
<img
src="dark-hero.jpg"
data-theme-dark="dark-hero.jpg"
data-theme-light="light-hero.jpg"
alt="Hero image"
>
Background Images
<div
data-theme-bg-dark="url('dark-bg.jpg')"
data-theme-bg-light="url('light-bg.jpg')"
></div>
Videos
<video
poster="dark-poster.jpg"
data-theme-dark="dark-poster.jpg"
data-theme-light="light-poster.jpg"
autoplay muted loop
>
<source
src="dark-video.mp4"
data-theme-dark="dark-video.mp4"
data-theme-light="light-video.mp4"
>
</video>
Flash Prevention
Without this snippet, users with a saved preference will see a flash of the wrong theme on page load. Paste it in <head> before your CSS - it reads localStorage and sets data-theme before the first paint.
<!-- Paste BEFORE your CSS. No dependencies. -->
<script>
(function(){
var s = localStorage.getItem('my-site-theme');
var p = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light';
document.documentElement.dataset.theme = s || p;
}());
</script>
Use the same value as your storageKey passed to new Nightwave().
Accessibility
Reduced Motion
Nightwave checks window.matchMedia('(prefers-reduced-motion: reduce)') before every wave transition. If the user has that setting enabled, the clip-path animation is skipped entirely - the theme still switches, instantly, using only the CSS color transitions from nightwave.css. No one will ever contact you because the toggle failed for them.
var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!supportsVT || reduceMotion || !this.opts.enableWave) {
// instant switch - no wave, no clip-path, no animation
this.applyTheme(nextTheme, { persist: true, updateMedia: true });
return;
}
Keyboard & Screen Readers
The toggle must be a native <button> element. It is keyboard-focusable and activatable with Space or Enter by default. Add an aria-label to describe its purpose. No other ARIA attributes are required.
<!-- Minimal accessible toggle -->
<button id="themeToggle" type="button"
aria-label="Toggle colour scheme">
☀
</button>
Browser Support
Wave effect
Fallback
Support is detected at runtime. The fallback - instant theme switch + CSS color transitions - works in every browser that supports CSS custom properties, i.e. everything shipped since 2017.
CSS Variables
Override these at :root to match your timing preferences:
:root {
/* Color transition duration */
--nw-duration: 420ms;
/* Color transition easing */
--nw-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Wave clip-path animation duration */
--nw-wave-duration: 620ms;
}
The wave duration is also hardcoded as duration: 620 in the _runWaveTransition method inside nightwave.js. Edit the constant there to change it globally.
Changelog
- Initial release
- View Transition API + clip-path circle wave
- Automatic reduced-motion fallback
- System sync via prefers-color-scheme
- Media switching: images, videos, backgrounds
- localStorage persistence
- meta[name=theme-color] support (iOS Safari chrome bar)
- ES5-compatible, no transpiling required
Common Issues
Flash of wrong theme on first load
You’re missing the flash-prevention snippet. Paste it in <head> before your stylesheet - see the Flash Prevention section above.
The wave animation doesn’t play
View Transition API is only available in Chrome 111+, Edge 111+, and Safari 18.2+. In all other browsers Nightwave falls back to an instant switch automatically - no wave, but the toggle still works. Check enableWave: true is set.
Clicking the button does nothing
Check that toggleSelector matches an element that exists in the DOM when init() is called. Also verify nightwave.js is loaded before the init() call.
Switching theme has no visible effect
Nightwave sets data-theme=”dark” or ”light” on <html>. Your CSS must define variables under [data-theme=”dark”] and [data-theme=”light”] (or :root defaults). See the Quick Start for the minimal token setup.