Skip to content

Multi-Window Sync

One theme across every tab

When a user changes the theme in one tab, every other open tab should reflect the change instantly. Theme Kit provides adapters that handle cross-tab synchronization with automatic fallbacks — no manual localStorage listener wiring required.

1The Problem

Each browser tab runs its own runtime instance — without synchronization the selections drift apart.
  • Each tab creates its own ThemeRuntime with independent state. A theme switch in tab A does not propagate to tab B.
  • The persistence adapter stores the selection in localStorage, but reading it only happens at bootstrap — tabs opened after a change get the right theme, but tabs already open stay stale.
  • Users expect consistency: switching to dark mode in one tab should darken all tabs. Without sync they see a jarring split where half the UI is dark and half is light.
  • Race conditions are possible if multiple tabs write to storage simultaneously — the sync adapters serialize updates through a single channel.

2BroadcastChannel

BroadcastChannel is the default transport — messages reach every same-origin tab without touching the DOM.
createMultiWindowSync tries BroadcastChannel first. If the browser supports it (all modern browsers do), every .post() call is broadcast to all other tabs on the same origin that have subscribed to the same channel name.
core — BroadcastChannel (default adapter)
ts
1import { createMultiWindowSync } from "@theme-kit/core";
2
3const sync = createMultiWindowSync({
4  prefer: "auto", // "broadcast" | "sharedworker" | "auto"
5  onFallback(strategy) {
6    console.warn("Falling back:", strategy);
7  },
8});
9
10// Every tab receives the update
11sync.subscribe((selection) => {
12  console.log("Theme changed in another tab:", selection);
13});
14
15// Post a change — all other tabs update instantly
16sync.post({ mode: "dark", family: "slate" });
Channel name defaults to "theme-selection" | Pass { channelName: 'admin' } to isolate scopes — see Custom Channels below.
If you need the raw broadcast adapter without the auto-fallback logic, use createThemeSelectionBroadcast directly:
core — createThemeSelectionBroadcast directly
ts
1import { createThemeSelectionBroadcast } from "@theme-kit/core";
2
3const channel = createThemeSelectionBroadcast({
4  channelName: "my-app-theme",
5});
6
7if (channel) {
8  channel.post({ mode: "dark", family: "slate" });
9
10  channel.subscribe((state) => {
11    // state.family and state.mode are both available
12    applyTheme(state);
13  });
14}

3StorageEvent Fallback

For environments where BroadcastChannel is blocked (some WebView contexts, older browsers), a localStorage-based fallback keeps everything in sync.
When BroadcastChannel is unavailable, Theme Kit falls back to writing the selection to localStorage and listening for the browser's native storage event. This event fires in every other tab whenever localStorage is modified — effectively giving you cross-tab messaging through the storage API.
core — StorageEvent fallback
ts
1import { createStorageEventSync } from "@theme-kit/core";
2
3// Uses localStorage "storage" event to sync across tabs
4// Works in all browsers, no BroadcastChannel needed
5const sync = createStorageEventSync("theme-selection-state");
6
7sync.post({ mode: "dark", family: "slate" });
8
9sync.subscribe((selection) => {
10  // Fires in other tabs when localStorage changes
11  runtime.selection.setMode(selection.mode);
12  runtime.selection.setFamily(selection.family);
13});
StorageEvent only fires in other tabs | The writing tab does not receive its own storage event. The sync adapter applies the change locally when .post() is called, before writing to storage.

4Cross-Tab Persistence

localStorage is the persistence layer — the sync adapters react to storage changes and push updates into the runtime.
Persistence and sync work together but solve different problems:
Persistence vs. Sync
PersistenceSurvives page reload — reads from localStorage at bootstrap
SyncPropagates changes while the app is running — reacts to storage events or BroadcastChannel messages
TogetherA theme change is written to storage (persistence) and broadcast to other tabs (sync) in the same tick
The ThemeSelectionState object contains both mode (light/dark/system) and family (the color palette). Both values are synchronized — switching palettes in one tab switches palettes in every tab.

5Setup in Every Framework

A thin hook or composable wraps the adapter lifecycle — mount creates, unmount destroys.
The pattern is the same across frameworks: create the sync adapter on mount, subscribe to incoming changes, and clean up on unmount.
React — useMultiWindowSync hook
tsx
1import { useEffect } from "react";
2import { createMultiWindowSync } from "@theme-kit/core";
3
4export function useMultiWindowSync(runtime) {
5  useEffect(() => {
6    const sync = createMultiWindowSync();
7
8    const unsub = sync.subscribe((selection) => {
9      runtime.selection.setMode(selection.mode);
10  runtime.selection.setFamily(selection.family);
11    });
12
13    return () => {
14      unsub();
15      sync.destroy();
16    };
17  }, [runtime]);
18}
Vue — composable
ts
1import { onMounted, onUnmounted } from "vue";
2import { createMultiWindowSync } from "@theme-kit/core";
3
4export function useMultiWindowSync(runtime) {
5  let sync;
6  let unsub;
7
8  onMounted(() => {
9    sync = createMultiWindowSync();
10    unsub = sync.subscribe((selection) => {
11      runtime.selection.setMode(selection.mode);
12  runtime.selection.setFamily(selection.family);
13    });
14  });
15
16  onUnmounted(() => {
17    unsub?.();
18    sync?.destroy();
19  });
20}
Svelte — onMount action
ts
1import { onMount, onDestroy } from "svelte";
2import { createMultiWindowSync } from "@theme-kit/core";
3
4export function syncTheme(runtime) {
5  let sync;
6  let unsub;
7
8  onMount(() => {
9    sync = createMultiWindowSync();
10    unsub = sync.subscribe((selection) => {
11      runtime.selection.setMode(selection.mode);
12  runtime.selection.setFamily(selection.family);
13    });
14  });
15
16  onDestroy(() => {
17    unsub?.();
18    sync?.destroy();
19  });
20}
SharedWorker is also an option | If you set { prefer: 'sharedworker' }, the adapter uses a SharedWorker instead of BroadcastChannel. Useful in contexts where BroadcastChannel messages are throttled.

6Custom Channels

Different channel names create isolated sync scopes — admin and storefront can maintain independent theme selections.
By default all tabs share the theme-selection channel. If your app has multiple independently-themed regions (admin panel, embedded widget, storefront), give each one a unique channel name. Messages on different channels never cross.
core — isolated channel names
ts
1import { createMultiWindowSync } from "@theme-kit/core";
2
3// Two separate scopes — admin panel vs. storefront
4const adminSync = createMultiWindowSync({
5  channelName: "admin-theme",
6});
7
8const storeSync = createMultiWindowSync({
9  channelName: "storefront-theme",
10});
11
12// Changing admin theme does NOT affect storefront tabs
13adminSync.post({ mode: "dark", family: "slate" });
14storeSync.post({ mode: "light", family: "neutral" });
Multi-Window Sync — Theme Kit