@zekee-senju/nocturne
v1.0.0
Published
Dependency-free theme transition animations for UI, powered by the View Transitions API.
Maintainers
Readme
@zekee-senju/nocturne
Dependency-free theme transition animations for UI, powered by the View Transitions API.
Nocturne handles the trigger — checking browser support and wrapping your theme switch in document.startViewTransition(). The actual look of the animation (circle reveal, blur, wipe, GIF mask, etc.) lives entirely in CSS, so you can swap or customize it without touching JS.
📦 Install
npm install @zekee-senju/nocturneNo runtime dependencies. React is an optional peer dependency — only needed if you use the /react entry point.
🚀 Quick start
Vanilla JS / TS
import { nocturne } from "@zekee-senju/nocturne";
import "@zekee-senju/nocturne/styles.css";
document.documentElement.classList.add("nocturne-circle");
button.addEventListener("click", () => {
nocturne(() => {
const isDark = document.documentElement.classList.toggle("dark");
localStorage.setItem("theme", isDark ? "dark" : "light");
});
});React
import { useState } from "react";
import { useNocturne } from "@zekee-senju/nocturne/react";
import "@zekee-senju/nocturne/styles.css";
function App() {
const [theme, setTheme] = useState<"light" | "dark">("light");
const toggleTheme = useNocturne(() => {
const next = theme === "light" ? "dark" : "light";
setTheme(next);
document.documentElement.classList.toggle("dark", next === "dark");
});
return <button onClick={toggleTheme}>Toggle theme</button>;
}Add a variant class to your root <html> element (see Choosing a variant below):
<html lang="en" class="nocturne-circle">That's the whole setup. Click the button — the theme flips with an animated reveal instead of an instant snap.
🧩 API
| Export | Where | What it does |
|---|---|---|
| nocturne(switchTheme) | @zekee-senju/nocturne | Runs switchTheme inside document.startViewTransition() if supported, otherwise calls it directly. Framework-agnostic — no DOM assumptions beyond what you do inside your own callback. |
| useNocturne(switchTheme) | @zekee-senju/nocturne/react | Thin useCallback wrapper around nocturne() for React. Returns a stable function you can wire to onClick. |
nocturne() and useNocturne() both take one argument: a function that actually performs your theme change (toggling a class, updating state, writing to storage — whatever your app needs). Nocturne doesn't know or care what "switching theme" means for your app; it only handles when and how that function runs relative to the transition.
nocturne(switchTheme: () => void): void
useNocturne(switchTheme: () => void): () => void🎨 Choosing a variant
Import the stylesheet once, then add exactly one variant class to your <html> element:
<link rel="stylesheet" />
<!-- or via JS import "@zekee-senju/nocturne/styles.css" -->
<html class="nocturne-circle">| Class | Effect |
|---|---|
| nocturne-circle | Clean circular reveal, expanding from center |
| nocturne-circle-blur | Same as above, with a soft blurred edge |
| nocturne-corner | Blurred circle reveal pivoted to the top-left corner — good if your toggle button lives there |
| nocturne-diagonal | Diagonal clip-path wipe, no image/mask needed |
| nocturne-curtain | Vertical wipe from the top, like a curtain dropping |
| nocturne-fade | Plain crossfade — no shape, just a smooth opacity transition |
| nocturne-iris | Circle reveal with a slight overshoot before settling, like a camera aperture |
| nocturne-gif | Reveal masked by an image/GIF you provide (see below) |
Only apply one variant class at a time — they all target the same ::view-transition-* pseudo-elements, so combining them will cause conflicting animations.
GIF variant setup
nocturne-gif doesn't ship with an image baked in (shipping someone else's GIF permanently in an npm package isn't great practice). Set the mask image yourself via a CSS custom property:
html.nocturne-gif {
--nocturne-gif-url: url('/your-image-or-gif.gif');
}Note: browsers generally rasterize an animated GIF used as a CSS mask to its first frame — you won't see the GIF itself animate, but its shape becomes the reveal mask, which is then scaled up by Nocturne's own @keyframes. It's a shape-based reveal using an arbitrary image, not "the GIF plays inside the transition."
⚠️ Setup nuances
The .dark class convention. Every variant's CSS is written expecting a .dark class on your document root (html.dark) to represent dark mode. If your project uses a different convention — data-theme="dark", a class on <body> instead of <html>, a different class name entirely — the shipped variants won't respond correctly out of the box. You'll need to either adjust your theme-switch logic to add .dark to <html>, or copy the relevant CSS block from styles.css and adjust the selector to match your setup.
Browser support. document.startViewTransition is currently supported in Chromium-based browsers (Chrome, Edge, Opera) and not in Firefox or Safari. Nocturne detects this automatically — in unsupported browsers, your switchTheme callback still runs, the theme still changes, it just happens instantly with no animation. Nothing breaks, nothing needs a fallback on your end.
The variant class lives on <html>, not in React state. Because ::view-transition-* pseudo-elements are evaluated against the DOM at the moment the transition fires, the variant class needs to be a real class present on the root element — not something conditionally rendered by React on every render. Set it once, statically, in your HTML (or via a useEffect that runs once on mount if you need it dynamic).
One switchTheme call per transition. Don't call your theme-switching logic both inside the nocturne()/useNocturne() callback and separately in a useEffect watching theme state — that causes the DOM class toggle to run twice per click, which can produce inconsistent or double-applied styles. Let the callback you pass to nocturne() be the single source of truth for the actual switch.
CSS import is separate from the JS import. nocturne/useNocturne only handles the trigger logic — importing @zekee-senju/nocturne/styles.css is a required, separate step. Forgetting it means nocturne() will still run and your theme will still switch, but with no animation, since there's no CSS for the browser to animate with.
📝 Notes
- Zero runtime dependencies.
- React is an optional peer dependency — plain JS/TS users don't need it installed.
- Import the CSS once, anywhere before first use.
- All animation logic lives in CSS — inspect
styles.cssdirectly if you want to build a custom variant on top of the existing ones.
License
ISC
