Theme Toggle source
Inspect and copy the reviewed source included with this Free component.
Free source · 4 files
theme_toggle.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND (ISC AND MIT)
//! Theme Toggle component implementation
//!
//! SSR markup for the light/dark theme toggle button. The JavaScript runtime
//! handles the click: it flips the `dark` class on `<html>`, mirrors
//! `color-scheme`, and persists the choice under the `theme` localStorage key
//! (falling back to `prefers-color-scheme` when nothing is stored). The icon
//! swap is pure CSS via the root `.dark` class, so the button needs no
//! per-instance state.
//!
//! The moon and sun glyphs embed SVG geometry adapted from Lucide
//! (Feather-derived icons, ISC AND MIT).
use super::recipe::{ThemeToggleRecipeProps, THEME_TOGGLE_RECIPE};
use super::THEME_TOGGLE_ANATOMY;
use proa_core::{WebContext, WebRenderSync, WriteBuf, WriteError};
use proa_macros::html_sync;
/// ThemeToggle - a button that switches the document between light and dark.
///
/// # Example
///
/// ```ignore
/// use proa_ui::ThemeToggle;
/// use proa_macros::html_sync;
///
/// html_sync! {
/// {ThemeToggle::new()}
/// }
/// ```
pub struct ThemeToggle {
/// Accessible label; also used as the hover title.
pub aria_label: &'static str,
/// Additional classes fused after the recipe classes.
pub class: Option<&'static str>,
}
impl ThemeToggle {
pub const fn new() -> Self {
Self {
aria_label: "Toggle theme",
class: None,
}
}
}
impl Default for ThemeToggle {
fn default() -> Self {
Self::new()
}
}
impl<B: WriteBuf, L: proa_core::DataLoader> WebRenderSync<L, B> for ThemeToggle {
fn render(self, cx: &mut WebContext<L, B>) -> Result<(), WriteError> {
let style = THEME_TOGGLE_RECIPE.resolve(ThemeToggleRecipeProps);
html_sync! {
<button
type="button"
data-theme-toggle-button
data-scope={THEME_TOGGLE_ANATOMY.scope()}
data-part="root"
data-slot="theme-toggle"
aria-label={self.aria_label}
title={self.aria_label}
class={proa_macros::text!("{} {}", style, self.class)}
>
<svg
data-scope={THEME_TOGGLE_ANATOMY.scope()}
data-part="moon-icon"
data-slot="theme-toggle-moon-icon"
class={THEME_TOGGLE_RECIPE.moon_icon}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M20.99 12.79A9 9 0 1 1 11.21 3.01 7 7 0 0 0 20.99 12.79Z" />
</svg>
<svg
data-scope={THEME_TOGGLE_ANATOMY.scope()}
data-part="sun-icon"
data-slot="theme-toggle-sun-icon"
class={THEME_TOGGLE_RECIPE.sun_icon}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
</button>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
#[test]
fn theme_toggle_renders_with_data_attributes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
ThemeToggle::new().render(&mut cx).unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-theme-toggle-button"));
assert!(html.contains(r#"data-scope="theme-toggle""#));
assert!(html.contains(r#"data-part="root""#));
assert!(html.contains(r#"data-slot="theme-toggle""#));
assert!(html.contains(r#"aria-label="Toggle theme""#));
assert!(html.contains(r#"type="button""#));
}
#[test]
fn theme_toggle_icons_swap_via_dark_class_only() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
ThemeToggle::new().render(&mut cx).unwrap();
let html = String::from_utf8(out).unwrap();
// Moon shows in light mode, sun under `.dark` — both statically
// rendered so the runtime never touches the icons.
assert!(html.contains(r#"data-part="moon-icon""#));
assert!(html.contains(r#"data-part="sun-icon""#));
assert!(html.contains("dark:hidden"));
assert!(html.contains("dark:block"));
}
#[test]
fn theme_toggle_fuses_custom_class_and_label() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
ThemeToggle {
aria_label: "Switch color scheme",
class: Some("custom-toggle"),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains(r#"aria-label="Switch color scheme""#));
assert!(html.contains("custom-toggle"));
assert!(html.contains("inline-flex"));
}
/// The component must never share the marketing site's `data-theme-toggle`
/// attribute: pages that load both the site chrome script and `proa.js`
/// would toggle twice per click, which reads as a broken button.
#[test]
fn theme_toggle_uses_a_distinct_hook_attribute() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
ThemeToggle::new().render(&mut cx).unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-theme-toggle-button"));
assert!(!html.contains(r#"data-theme-toggle ""#));
assert!(!html.contains(r#"data-theme-toggle=""#));
}
#[test]
fn runtime_wires_theme_persistence_and_click_dispatch() {
// Free component sources may only reference the Free runtime; the
// Pro runtime carries the same section via the shared generator.
for js in [include_str!("../../../runtime/proa-free.js")] {
assert!(js.contains("const ThemeToggle"));
assert!(js.contains("[data-theme-toggle-button]"));
assert!(js.contains("storageKey: 'theme'"));
assert!(js.contains("localStorage.getItem(this.storageKey)"));
assert!(js.contains("localStorage.setItem(this.storageKey"));
assert!(js.contains("prefers-color-scheme: dark"));
assert!(js.contains("classList.toggle('dark'"));
assert!(js.contains("ThemeToggle.handleClick(target)"));
assert!(js.contains("initializeOnReady(() => ThemeToggle.init())"));
}
}
}
FreeTheme Toggle
Light/dark mode switch with persistence.
Install with the Proa CLI
proa ui add theme_toggleInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup