Theme Switch

Auto, dark or light (or your own themes), remembered in a cookie, with no flash.

zweiundeins <sb-theme-switch> theme dark mode color scheme preferences Since 2026-09-22 MIT licence 2.9 kB1 Open in playground Edit on GitHub

Playground

Lets people pick a theme. The choice goes on <html> as an attribute (data-theme="dark" by default), and "auto" removes the attribute so your CSS can follow prefers-color-scheme. It is remembered in a cookie, so the next page can render the right theme before the first paint.

Examples

Auto, dark, light

<sb-theme-switch cookie="sb-theme-demo" attribute="data-demo-theme"></sb-theme-switch>

Compact

Icons only; the names stay available to screen readers and as tooltips.

<sb-theme-switch compact cookie="sb-theme-demo" attribute="data-demo-theme"></sb-theme-switch>

Your own themes

Any names work, and labels sets the visible names. variant="select" keeps a long list compact. Switches for the same cookie can list different themes, say a short one in the header and the full set on a settings page: when the current theme is not in a switch's list, that switch shows no choice and leaves the page alone.

<sb-theme-switch variant="select" cookie="sb-theme-demo2" attribute="data-demo-theme"
  themes='["auto", "deep-space", "nebula", "terminal", "daylight"]'></sb-theme-switch>

An icon button that opens a menu, for headers. The icon shows the current choice (a palette for themes other than auto, dark and light). Starbase's own header uses this.

<sb-theme-switch variant="menu" cookie="sb-theme-demo2" attribute="data-demo-theme"
  themes='["auto", "deep-space", "nebula", "terminal", "daylight"]'></sb-theme-switch>

No flash of the wrong theme

The theme has to be on <html> before the browser paints, so JavaScript that runs after the page loads is too late. There are two ways, both reading the cookie:

With a server (best, and no script at all): read the sb-theme cookie and render it into the page:

<html data-theme="dark">  <!-- the cookie's value; leave the attribute out for "auto" -->

Static sites: put this first in <head>, before the stylesheets. With a strict CSP, give it your nonce.

<script>
  const t = (document.cookie.match(/(?:^|; )sb-theme=([^;]*)/) || [])[1]
  if (t && t !== 'auto') document.documentElement.dataset.theme = decodeURIComponent(t)
</script>

Then style the themes, with "auto" as the absence of the attribute:

:root { color-scheme: light; /* light tokens */ }
@media (prefers-color-scheme: dark) { :root:not([data-theme]) { color-scheme: dark; /* dark tokens */ } }
[data-theme="dark"] { color-scheme: dark; /* dark tokens */ }

Also set <meta name="color-scheme" content="light dark">, so the browser's own background matches before your CSS arrives.

Across subdomains

domain writes the cookie for a whole domain, so one choice covers the marketing site, the app and anything else under it. Leave it out and the cookie stays on the host that set it.

<sb-theme-switch domain=".example.com"></sb-theme-switch>

Only a domain the page itself belongs to is accepted, and every subdomain can then read and overwrite the value. That is fine for a preference, so keep anything else out of this cookie. A domain the browser refuses would otherwise drop the choice without a word, so the component reads the cookie back and falls back to this host, reporting the mismatch through reportError.

Following the theme from a canvas

CSS follows a theme on its own. Code that draws (a canvas, WebGL, a chart library) resolved its colours once and has to be told to draw again, for two different reasons:

  • Someone picks a theme. The switch fires sb-theme-change on the window, with detail.scheme set to "light" or "dark": what the page now paints in, worked out from the theme's own color-scheme, so you never need a list of theme names.
  • The system flips while "auto" is chosen. Nothing is clicked, so no event fires. Listen to prefers-color-scheme as well.

Read your colours again when you repaint, and both cases are covered:

const repaint = () => requestAnimationFrame(paint)
addEventListener('sb-theme-change', repaint)
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', repaint)

Inside a Rocket component, add both listeners in setup and remove them in cleanup. A data-on:sb-theme-change__window attribute in the template is not removed with the element, and throws on the next pick.

A cookie reaches the server with the request, so the server can render the theme straight away. Local storage would need a script on every page. The cookie holds only the theme name, for a year, on the whole site (Path=/, SameSite=Lax, Secure on https, and Domain when domain is set). Several switches on one page stay in sync.

Styling

Style it from your page's CSS, without changing the component or importing anything into it. Custom properties, inherited properties and ::part() all reach into its shadow root.

  • Fonts: the options use your page's font.
  • Colours: the control is --sb-control-bg with a --sb-control-border edge. Options are --sb-text-2 (--sb-text-1 when hovered or chosen); the chosen one fills with --sb-brand-subtle inside a --sb-brand edge. The focus ring is --sb-brand-light, corners --sb-control-radius. The menu variant's list uses the same tokens, and the select's arrow is drawn in --sb-text-2, so it follows the theme.
  • Shadow: --sb-shadow-overlay sets the menu's drop shadow: one shadow without spread, such as 0 8px 16px rgb(0 0 0 / 0.3), or none.
  • Parts: group (the segmented control), select (with variant="select"), and button and menu (with variant="menu"). Every option in the segmented control and the menu is option, and the chosen one is also selected: ::part(option selected). Your page's ::part() rules win over the component's own, without !important.
<style>
  .my-switch { --sb-brand: var(--sb-accent); --sb-control-radius: 999px; }
  .my-switch::part(group) { padding: 4px; }
  .my-switch::part(option) { font-size: 0.875rem; }
  .my-switch::part(option selected) { font-weight: 700; }
</style>
<sb-theme-switch class="my-switch" cookie="sb-theme-demo" attribute="data-demo-theme"></sb-theme-switch>

Accessibility

The segmented variant and the menu are radio groups (arrow keys move between themes, and apply each one as they go); the select is a native <select>. All carry label ("Theme") as their accessible name; the menu button also says the current theme. The menu is a native popover: Enter, Space or a click on a theme closes it, and so do Escape and clicking outside; if focus was in the menu, it goes back to the button. In forced colours (Windows High Contrast) the icons use the text colour and the chosen theme is outlined in Highlight.

Installation

Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag: the autoloader imports each component the first time its tag appears, including tags a Datastar morph adds later. The Datastar here is v1.0.4 with Starbase's fixes until they are released; the components also run on the official release.

<script type="importmap">
  { "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>

<sb-theme-switch></sb-theme-switch>

No flash of undefined elements: put class="sb-cloak" on <html> and add .sb-cloak :not(:defined) { visibility: hidden } to your CSS. The autoloader removes the class once the page's components are defined.

Load just this component, pinned to this version: all its modules in one minified file with its integrity hash, so the browser refuses it if a single byte changes.

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/theme-switch@c0c8b777147f/theme-switch.bundle.min.js" integrity="sha384-aiUnjbtRR4wVylWFfD+4kWH0RY397ThVSl0X85kS9Wbm7XDWZ++YKvLq8N8AV3EP"></script>

<sb-theme-switch></sb-theme-switch>

Or the minified module files (one file, which the browser requests as it finds their imports), each pinned in the import map:

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
      "https://starbase.zweiundeins.gmbh/c/theme-switch@c0c8b777147f/theme-switch.min.js": "sha384-BaNLlmLpZE+Z7Rrj8B98eZFDgG9WoiWXbTOY1ZBEWjlBtgyS/w5RHpAoIGgNgOr7"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/theme-switch@c0c8b777147f/theme-switch.min.js" integrity="sha384-BaNLlmLpZE+Z7Rrj8B98eZFDgG9WoiWXbTOY1ZBEWjlBtgyS/w5RHpAoIGgNgOr7"></script>

<sb-theme-switch></sb-theme-switch>

In production, pin today's whole catalog: its autoloader and an import map with integrity hashes, so the browser refuses any file that changed. This map covers Datastar and this component; importmap.json has the hashes of every component, to merge into yours.

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
      "https://starbase.zweiundeins.gmbh/c/theme-switch@c0c8b777147f/theme-switch.bundle.min.js": "sha384-aiUnjbtRR4wVylWFfD+4kWH0RY397ThVSl0X85kS9Wbm7XDWZ++YKvLq8N8AV3EP"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@339249aca48f/autoloader.js" integrity="sha384-ZNpMayI1XShztIEX0iDHyCDDbhT8H+t+zEESUq4BCrN4b+7m931LQEl5x0vbY+Vo"></script>

<sb-theme-switch></sb-theme-switch>

Components import only datastar and their own files, so copying is enough: save each component's one file (and a file it loads on first use, keeping its folder), and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).

  • <sb-theme-switch>
    • theme-switch.bundle.min.js one file 2.9 kB brotli
<script type="importmap">
  { "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/theme-switch/theme-switch.bundle.min.js"></script>

<sb-theme-switch></sb-theme-switch>

Or copy the module files, keeping each component's folder, and load each component's module:

  • <sb-theme-switch>
<script type="importmap">
  { "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/theme-switch/theme-switch.min.js"></script>

<sb-theme-switch></sb-theme-switch>

Size

A page loads this component as one file, its bundle, compressed the way it is served (gzip -9, brotli -11): brotli is what it downloads. Datastar and Rocket are shared by every component and not counted.

FileOriginalgzipbrotli
theme-switch.bundle.min.js 8.2 kB3.4 kB2.9 kB
As module files: one request, 2.8 kB minified with brotli

For a page that loads the module files as they are, requesting each one as it finds its import, or bundles them itself. Each file compressed on its own; the minified one is what the page downloads, and the readable source is always there too.

FileOriginalgzipbrotliminified
theme-switch.js 13.3 kB5.2 kB4.4 kB2.8 kB

API reference

Props

AttributeTypeDefaultDescription
themesarray[ "auto", "dark", "light" ]Theme names, in order. "auto" follows the system (prefers-color-scheme).
labelsarray[]Visible names, in the order of themes (default: from the names).
attributestring"data-theme"Attribute set on <html> to the chosen theme. "auto" removes it.
cookiestring"sb-theme"Cookie that remembers the choice (a year, whole site). Servers can read it to render the theme, with no flash.
domainstring""Cookie domain, e.g. ".example.com" to share the choice with every subdomain. Default: this host only.
variant"segmented" | "select" | "menu""segmented"Radio buttons, a select, or an icon button with a menu (for headers).
compactbooleanfalseSegmented only: icons without text for auto, dark and light.
labelstring"Theme"Accessible name of the control.

Events

NameDescription
sb-theme-changeAfter the user picks a theme. detail: { theme, cookie, scheme }, where scheme is "light" or "dark": what the page now paints in.