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 3.1 kB0Open 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.

<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.

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, and Secure on https). Several switches on one page stay in sync.

Accessibility

The segmented variant and the menu are radio groups (arrow keys move between themes); 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: Escape and clicking outside close it.

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 added later by a Datastar morph.

<!-- Once per page: Datastar with Rocket, and the Starbase autoloader.
     It loads every <sb-…> component the first time its tag appears. -->
<script type="importmap">
  { "imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>
<!-- Optional, no flash of undefined elements: class="sb-cloak" on <html>, and -->
<style>.sb-cloak :not(:defined) { visibility: hidden }</style>

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

<!-- In production, pin today's catalog instead of the latest: the browser then
     refuses any file that changed. Add "integrity" to the import map above: the
     hashes from https://starbase.zweiundeins.gmbh/c/@0a1ad1bf98a4/importmap.json and Datastar's, below. -->
<!--
<script type="importmap">
  { "imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" },
    "integrity": { "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js": "sha384-vUxZojLrF1Ar3de5h7VINqhJXBgjyZS4U49pHvGa87kum6j5Xn6JRziuARNw5ELG", "…": "…from importmap.json" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@0a1ad1bf98a4/autoloader.js" integrity="sha384-IvSCUjEWPS+wwXunU+48x96KmyWDx/Y4LeCwCL8cWFqJ/zHmTsTOsSMrzTOcpW3D"></script>
-->

<!-- Or load just this component, pinned to this version: -->
<!-- <script type="module" src="https://starbase.zweiundeins.gmbh/c/theme-switch@89ff42e54dc9/theme-switch.js" integrity="sha384-UfEk5d70hqU4CMr6jBrKHB5qSJ7aME5+v+A/2Wi7Xxhaa+fLWC2uUIKY06QtNCOk"></script> -->

Size

Each file compressed on its own, the way it is served (gzip -9, brotli -11). Datastar and Rocket are shared by every component and not counted.

FileOriginalgzipbrotli
theme-switch.js 9.7 kB3.7 kB3.1 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.
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 }.