jixoai-ui
the terminal design language
loading the style sheet…
Skip to content
docs · print medium: screen

registry:ui · General

theme-toggle — light / dark / system, four variants

One shared theme contract (localStorage “theme”, the .dark class, colorScheme on the root), four ways to reach it. full is the segmented selector that sets a mode directly; compact, icon, and text are cycling buttons walking light → dark → system. Icons are inline SVG — no icon-library dependency — and the chrome adapts to its container through currentColor.

4 variants full sets · rest cycle inline SVG icons no-flash bootstrap pairing

theme-toggle

light / dark / system in four variants. All of them below control the same live theme — click any one and the whole site re-themes. The playground drives the lower instance.

driven by the playground
theme-toggle-usage.svelte svelte
<script lang="ts">
  import ThemeToggle from '@ui/theme-toggle.svelte';
</script>

<!-- pair with the no-flash inline bootstrap in app.html (localStorage
     "theme" light|dark|system, .dark class, colorScheme, html.js) -->
<ThemeToggle variant="full" />
<ThemeToggle variant="compact" />
<ThemeToggle variant="icon" />
<ThemeToggle variant="text" />

demo

The no-flash bootstrap

The toggle is only the visible half. This exact inline script runs in app.html before first paint: it reads localStorage, resolves system through prefers-color-scheme, and applies the .dark class + colorScheme on the root — so prerendered loads never flash the wrong theme. The toggle writes the same key and re-applies through the same path.

src/app.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <link rel="icon" href="%sveltekit.assets%/icon.svg" />
    <script>
      (() => {
        // No-flash theme bootstrap + the html.js liveness flag. Reveal is
        // scroll-driven CSS now, but the SURFACE family's no-JS fallback
        // branch still gates on it (jixoai.css html:not(.js)) — keep it.
        const root = document.documentElement;
        root.classList.add("js");
        const isTheme = (value) => value === "light" || value === "dark" || value === "system";
        let theme = "system";
        try {
          const stored = window.localStorage.getItem("theme");
          if (isTheme(stored)) theme = stored;
        } catch {
          theme = "system";
        }
        const prefersDark =
          typeof window.matchMedia === "function" &&
          window.matchMedia("(prefers-color-scheme: dark)").matches;
        root.classList.toggle("dark", theme === "dark" || (theme === "system" && prefersDark));
        root.style.colorScheme = root.classList.contains("dark") ? "dark" : "light";
      })();
    </script>
    %sveltekit.head%
  </head>
  <body>
    <div style="display: contents">%sveltekit.body%</div>
  </body>
</html>

law

The shared theme contract

One key, one apply function, two writers. Bootstrap and toggle both go through it, so there is no second source of truth to drift.

  • storage key "theme": light | dark | system; absent means system
  • application = .dark class + colorScheme on the document root — one function, used by bootstrap and toggle alike
  • system follows prefers-color-scheme live: the media listener stays attached while system is current
  • full exposes hideLabels for an icons-only segmented row; every variant carries its mode in aria-label or aria-pressed

types

Types

Four variants, two behaviors: full sets a mode directly; the rest cycle light → dark → system.

theme-toggle · variants

full · sets
segmented group — one click, one mode
compact · cyclesthe default cycling button
icon · cyclesicon only — aria-label carries the mode
text · cyclesthe word alone
full · localized
labels payload — zh vocabulary, the stored value stays light|dark|system
theme-toggle-types-demo.svelte svelte
<script lang="ts">
  import ThemeToggle from '@ui/theme-toggle.svelte';
</script>

<div class="flex flex-wrap items-start gap-6">
  <div class="flex flex-col gap-3 border border-border p-4"><span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">full · sets</span><ThemeToggle variant="full" /><span class="text-muted-foreground text-[12.5px]">segmented group — one click, one mode</span></div>
  <div class="flex flex-col gap-3 border border-border p-4"><span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">compact · cycles</span><ThemeToggle variant="compact" /><span class="text-muted-foreground text-[12.5px]">the default cycling button</span></div>
  <div class="flex flex-col gap-3 border border-border p-4"><span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">icon · cycles</span><ThemeToggle variant="icon" /><span class="text-muted-foreground text-[12.5px]">icon only — aria-label carries the mode</span></div>
  <div class="flex flex-col gap-3 border border-border p-4"><span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">text · cycles</span><ThemeToggle variant="text" /><span class="text-muted-foreground text-[12.5px]">the word alone</span></div>
  <div class="flex flex-col gap-3 border border-border p-4"><span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">full · localized</span><ThemeToggle variant="full" labels={{ light: '浅色', dark: '深色', system: '系统', groupAriaLabel: '配色主题' }} /><span class="text-muted-foreground text-[12.5px]">labels payload — zh vocabulary, the stored value stays light|dark|system</span></div>
</div>

usage

Usage

Pair with the no-flash inline bootstrap in app.html — both write the same storage key.

ThemeToggle usage
<script lang="ts">
  import ThemeToggle from '@ui/theme-toggle.svelte';
</script>

<!-- pair with the no-flash inline bootstrap in app.html (localStorage
     "theme" light|dark|system, .dark class, colorScheme, html.js) -->
<ThemeToggle variant="full" />
<ThemeToggle variant="compact" />
<ThemeToggle variant="icon" />
<ThemeToggle variant="text" />

a11y

Accessibility

full is a labeled group with pressed state per option; the cycling buttons name their current mode.

Keyboard

KeyAction
TabReaches the toggle (one stop: the group or the cycling button)
Enter / SpaceSets the focused mode (full) or advances light → dark → system (cycling)

ARIA

AttributeValueDescription
rolegroupThe full variant group, aria-label "Color theme"
aria-pressedbooleanOn each full-variant option — the current mode reads pressed
aria-label"theme: {mode}"On the cycling variants; icon-only relies on it entirely
aria-hiddentrueOn the decorative inline SVG icons

theming

Theming

The toggle re-themes the page rather than being themed: chrome speaks currentColor, zero tokens of its own.

xs
sm
default
lg
TokenDefaultSource
currentColorinheritedcolor
storage key"theme": light | dark | systemstructural

axes

Universal props

The eight-axis surface (explicit-props): size · shape · radius · density · color · theme · elevation · motion — named steps, auto (inherit; stamps nothing), an exact number, or query(). The theme axis is tree-scoped paint by source (class:dark on the control own root — declared in the family, not served by a demo instance on this page); it is deliberately NOT the global flip — the §6 system lane keeps driving html.dark + localStorage through set/cycle, untouched by the axis surface. Flip the toggles below: the GLOBAL theme still moves.

theme-toggle · universal props

universal-props-demo.svelte svelte
<ThemeToggle size={18} />

api

API

Props from the ThemeToggle Props interface — a variant, one full-only modifier, and the localization payload.

Properties

PropertyTypeDefaultDescription
variant 'full' | 'compact' | 'icon' | 'text''compact' · Own default, not ambientfull sets a mode directly; the others cycle light → dark → system. Defaults: literal slot — own 'compact' (a structural selector, never a paint rung).
hideLabels booleanfalsefull variant only: hide the text labels, show icons alone.
labels { light: string; dark: string; system: string; groupAriaLabel?: string }English literalsLocalize the mode labels and the full variant's group aria name. Absent = the shipped English (byte-identical); the value domain and the localStorage "theme" contract are never localized.

Universal props

PropertyTypeDefaultDescription
size'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopethe base scale — root font-size; parts size in em
shape'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto' · ambient scopecorner geometry (CSS corner-shape; §14 degrade table)
radius'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopecorner size; auto = the concentric broadcast (§3)
density'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopespacing/leading scale over the kernel channels (§4)
color'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto' · ambient scopethe hue axis of the fixed oklch primary system (§5)
theme'light' | 'dark' | 'system' | 'auto''auto' · ambient scopelight/dark profile; system = the JS-mutable global (§6)
elevation'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto' · ambient scopeofficial M3 levels over the surface ladder (§7)
motion'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto' · ambient scopeintensity across the motion kernels (§8)
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h