Theme switch
When someone presses the theme toggle, the new theme grows from the toggle as a circle and covers the page in 720 ms, so the change reads as chosen, not as a flash. Where the browser cannot do that it cross-fades; under reduced motion it switches instantly. The header toggle of this site uses the same template.
Press the toggle
Issue the laser cutter
The new theme grows from the toggle in 720 ms. Under reduced motion it switches instantly.
The animated preview from the repository, light to dark and back:
Current rule (v2.0.1, 2026-11-13): The page-snapshot circle uses –sw-theme-duration = slow × 3 (720 ms) and standard easing. Repeated requests reverse the same mask animation from its current radius. clip-path is the documented snapshot exception. Without View Transitions the theme changes immediately, avoiding per-surface colour repaints. Reduced motion also changes immediately. This supersedes the retained feather/surface-fade recipe and hard-coded easing values. Supersedes the retained implementation/timing claims below; see docs/current-rules.md#cr-motion-01 and motion-flow.
The Space Works light/dark switch animation. When someone presses the theme toggle, the new theme grows from the toggle as a circle and covers the page in 720 ms, so the change reads as chosen, not as a flash. Where the browser cannot do that it cross-fades; under reduced motion it switches instantly.
Open demo.html in a browser for the real thing (a page, a round toggle and a Light / Dark / System
picker). It uses the design tokens from ../../tokens/tokens.css.
| File | What it is |
|---|---|
theme-reveal.css |
The View Transition rules, the registered --sw-reveal property for the soft edge, and the cross-fade fallback. |
theme-reveal.js |
Framework-free classic script. Exposes SwThemeReveal.switchTheme(next, originEl) and SwThemeReveal.applyTheme(next). |
demo.html |
Standalone sample page with a toggle and a segmented picker, including the no-flash pre-paint script. |
react/ |
The SpaceWorks app’s own files: themeTransition.ts and themeTransition.css (the reveal), theme.ts (preference, system, storage), themeReveal.ts (wrapper), ThemeToggle.tsx and ThemeChoice.tsx. See the import note below. |
previews/ |
The animated preview (theme-reveal.webp, .gif), generated by scripts/make-previews.mjs. |
What it does
Section titled “What it does”switchTheme("dark", button)finds the centre ofbutton.- With View Transitions, the browser snapshots the old page,
html.darkis toggled inside the transition callback, and the new snapshot is revealed through a circle that grows from the button’s centre to the farthest page corner, 720 ms,cubic-bezier(0.45, 0, 0.2, 1). The edge is soft (a 160 px feather) where a registered custom property can animate inside a mask; elsewhere it is a hardclip-pathcircle. The final frame is held (fill: "forwards") so the old theme never flashes back. - The preference is stored in
localStorageundermakerspace.theme(light,darkorsystem), the<meta name="theme-color">becomes#FFF4E8(light) or#000000(dark), andhtml.darkis the single source of truth. Storage failing (a private window) never stops the theme from changing for the session.
Timing and tokens
Section titled “Timing and tokens”Current rule (v2.0.1, 2026-11-13): The retained table/snippet below records the original app copy. Use the current executable files and the complete flow recipe for decision 399; see current rules.
| Part | Value |
|---|---|
| Reveal | 720 ms, cubic-bezier(0.45, 0, 0.2, 1), grows from the toggle’s centre |
| Soft edge | 160 px feather |
| Cross-fade fallback | 420 ms (class sw-theme-fade held for 460 ms) on background-color, border-color, color, fill, stroke, box-shadow |
| Colours | tokens/tokens.css: html.dark swaps every --color-* triplet; nothing else in the page changes |
| Browser chrome | #FFF4E8 light, #000000 dark |
The durations are deliberately longer than the 120 to 240 ms UI motion tokens: this is a page-level moment and never
blocks input (see docs/motion.md).
Reduced motion and support
Section titled “Reduced motion and support”prefers-reduced-motion: reduce: the theme changes instantly, with no reveal and no fade.- No
document.startViewTransition(Firefox before 144, older browsers): every colour cross-fades for 420 ms. - No
CSS.registerPropertyor mask support: hard-edged circle viaclip-pathinstead of the soft edge. - If the animation API throws, the theme is still applied. The reveal is decoration, never a dependency.
Usage without a framework
Section titled “Usage without a framework”<link rel="stylesheet" href="tokens/tokens.css"><link rel="stylesheet" href="theme-reveal.css"><script src="boot.js"></script> <!-- no-flash pre-paint script from docs/theming.md, synchronous, in <head> -->
<button id="theme" type="button" aria-label="Switch theme">...</button>
<script src="theme-reveal.js"></script><script> document.getElementById("theme").onclick = function (e) { var dark = document.documentElement.classList.contains("dark"); SwThemeReveal.switchTheme(dark ? "light" : "dark", e.currentTarget); };</script>switchTheme(next, originEl, options): next is "light", "dark" or "system"; originEl is the element the circle
grows from (omit it and the circle starts at the top right). Options: storageKey (default makerspace.theme),
onApply(isDark), called inside the transition when the theme is painted, for apps that mirror the theme somewhere
else (the design site sets data-theme there), and apply(next), which replaces the default painting entirely (the
Theming demo on the design site re-themes one container instead of <html>).
Pair it with the pre-paint script. The reveal only animates a change after the page is up. On first load the
synchronous boot.js in <head> (see docs/theming.md, section 3) must already have put
dark on <html>, or a returning dark-mode visitor sees a light flash before anything can animate.
Usage in React
Section titled “Usage in React”Copy react/ into the app (the SpaceWorks app keeps them at frontend/src/lib/ and frontend/src/components/):
import { useSyncExternalStore } from "react";import { currentTheme, subscribeTheme } from "./lib/theme";import { transitionTheme } from "./lib/themeTransition"; // imports ./themeTransition.css itself
function ThemeToggle() { const theme = useSyncExternalStore(subscribeTheme, currentTheme); return <button type="button" onClick={e => transitionTheme(theme === "dark" ? "light" : "dark", e.currentTarget)}> {theme === "dark" ? "Light" : "Dark"} </button>;}Call initializeTheme() from theme.ts once before createRoot(...).render.
Import note for the react/ copies
Section titled “Import note for the react/ copies”These are verbatim copies from the SpaceWorks app, so some imports point at app code that is not in this folder:
theme.tsimports./safeStorage(readStorage,writeStorage:localStoragewrapped intry/catch). The plaintry/catchversions indocs/theming.mdsection 4 are equivalent.themeReveal.tsis a thin wrapper kept for existing callers; new code can calltransitionThemedirectly.ThemeToggle.tsxandThemeChoice.tsxuse the app’s@linguitranslations,useDelight/useReducedMotion(components/delight/DelightProvider),icons,ui/IconButtonandui/SegmentedControl. Replace those with your own button and picker; the only theme-specific call istransitionTheme/revealTheme.themeTransition.tsalso clearsdata-sw-vt, an app attribute a route transition uses. Drop that line if you have no route transitions.
Source of truth
Section titled “Source of truth”The SpaceWorks app (frontend/src/lib/themeTransition.ts, themeTransition.css, lib/theme.ts). theme-reveal.js
and theme-reveal.css are its framework-free mirror; change them together. scripts/check-previews.mjs fails when
theme-reveal.css, theme-reveal.js or demo.html change without the preview being regenerated
(node scripts/make-previews.mjs theme-reveal).
Flow contract (decision 399)
Section titled “Flow contract (decision 399)”The page-snapshot circle uses –sw-theme-duration = slow × 3 (720 ms) and standard easing. Repeated requests reverse the same mask animation from its current radius. clip-path is the documented snapshot exception. Without View Transitions the theme changes immediately, avoiding per-surface colour repaints. Reduced motion also changes immediately. This supersedes the retained feather/surface-fade recipe and hard-coded easing values.
Complete rules, sequencing, keyboard and verification. The demo includes real static posters and explicit image Play/Pause controls.
Local token steps for batch 1B
Section titled “Local token steps for batch 1B”These aliases are local additions; global tokens are unchanged. Promote through the token batch when adopted.
| Local custom property | Definition |
|---|---|
--sw-local-duration-420 |
calc(var(--sw-dur-slow) * 1.75000000) |
--sw-theme-duration |
calc(var(--sw-dur-slow) * 3) |
Source
Section titled “Source”The template is copied from motion/theme-reveal/ on every build: theme-reveal.css, theme-reveal.js and a standalone demo.html.