Theme Toggle
A light/dark mode switch: moon while the page is light, sun while it is dark. The choice persists across visits.
Default
Click to flip this page between light and dark. The runtime toggles the dark class on the document root, mirrors color-scheme, and stores the choice under the theme localStorage key; with nothing stored it follows the OS preference.
Custom label
The accessible name doubles as the hover title, and extra classes fuse after the recipe classes.
Behavior
- The icon swap is pure CSS via the root
darkclass - the runtime never touches the icons, so there is no flash of the wrong icon. - Multiple toggles on one page stay in agreement: they all read and write the same document class and storage key.
- On pages that render a toggle, the runtime applies the stored or OS-preferred theme on load. Pages without one are left untouched.
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 →recipe.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Theme Toggle recipe — named slot classes for the theme toggle button.
use crate::theme::rounded;
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ThemeToggleRecipeProps;
/// Static named classes for every Theme Toggle part.
#[derive(Debug, Clone, Copy)]
pub struct ThemeToggleRecipe {
pub root: &'static str,
pub moon_icon: &'static str,
pub sun_icon: &'static str,
}
impl ThemeToggleRecipe {
Theme Toggle source
Browse and copy the reviewed source included with this Free component.
md.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Markdown rendering for the theme toggle component.
use super::ThemeToggle;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
/// A theme toggle is interactive page chrome with no Markdown equivalent —
/// a document has no theme to switch — so it renders nothing, like other
/// purely presentational controls.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf> MdRenderSync<Loader, B> for ThemeToggle {
fn render_md(self, _cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn theme_toggle_renders_nothing_in_markdown() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
ThemeToggle::new().render_md(&mut cx).unwrap();
assert!(buf.is_empty());
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Theme Toggle component - switches the document between light and dark.
//!
//! A single icon button: moon while the page is light, sun while it is dark.
//! Clicking flips the `dark` class on `<html>`, mirrors `color-scheme`, and
//! persists the choice under the `theme` localStorage key; with nothing
//! stored, the runtime follows `prefers-color-scheme`.
use crate::Anatomy;
mod md;
mod recipe;
mod theme_toggle;
/// Static anatomy contract shared by SSR and the delegated runtime.
pub const THEME_TOGGLE_ANATOMY: Anatomy =
Anatomy::new("theme-toggle", &["root", "moon-icon", "sun-icon"]);
pub use recipe::{
theme_toggle_recipe, ThemeToggleRecipe, ThemeToggleRecipeProps, ThemeToggleStyle,
THEME_TOGGLE_RECIPE,
};
pub use theme_toggle::ThemeToggle;
#[cfg(test)]
mod anatomy_tests {
use super::*;
#[test]
fn anatomy_declares_every_public_part() {
assert_eq!(THEME_TOGGLE_ANATOMY.scope(), "theme-toggle");
assert_eq!(
THEME_TOGGLE_ANATOMY.parts(),
&["root", "moon-icon", "sun-icon"]
);
}
}
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Theme Toggle recipe — named slot classes for the theme toggle button.
use crate::theme::rounded;
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ThemeToggleRecipeProps;
/// Static named classes for every Theme Toggle part.
#[derive(Debug, Clone, Copy)]
pub struct ThemeToggleRecipe {
pub root: &'static str,
pub moon_icon: &'static str,
pub sun_icon: &'static str,
}
impl ThemeToggleRecipe {
pub const fn resolve(&'static self, _props: ThemeToggleRecipeProps) -> ThemeToggleStyle {
ThemeToggleStyle { recipe: self }
}
}
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct ThemeToggleStyle {
recipe: &'static ThemeToggleRecipe,
}
impl AttrValue for ThemeToggleStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.root.as_bytes())
}
}
/// Ghost icon-button treatment matching shadcn's mode toggle.
const THEME_TOGGLE_ROOT: &str = tw_join!(
"inline-flex",
"h-9",
"w-9",
"shrink-0",
"items-center",
"justify-center",
"text-muted-foreground",
"transition-colors",
"hover:bg-accent",
"hover:text-accent-foreground",
"focus-visible:outline-none",
"focus-visible:ring-2",
"focus-visible:ring-ring",
"focus-visible:ring-offset-2",
"disabled:pointer-events-none",
"disabled:opacity-50",
rounded::MD
);
/// The moon invites dark mode, so it shows while the page is light; the sun
/// takes over under the root `.dark` class. Pure CSS — the runtime only flips
/// the class, never the icons.
const THEME_TOGGLE_MOON_ICON: &str = tw_join!("dark:hidden");
const THEME_TOGGLE_SUN_ICON: &str = tw_join!("hidden", "dark:block");
pub const THEME_TOGGLE_RECIPE: ThemeToggleRecipe = ThemeToggleRecipe {
root: THEME_TOGGLE_ROOT,
moon_icon: THEME_TOGGLE_MOON_ICON,
sun_icon: THEME_TOGGLE_SUN_ICON,
};
pub const fn theme_toggle_recipe() -> &'static ThemeToggleRecipe {
&THEME_TOGGLE_RECIPE
}
impl crate::variant_spec::ComponentSpec for ThemeToggleRecipe {
const NAME: &'static str = "ThemeToggle";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
Vec::new()
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::THEME_TOGGLE_ANATOMY)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipe_exposes_named_slots() {
let recipe = theme_toggle_recipe();
assert!(recipe.root.contains("inline-flex"));
assert!(recipe.moon_icon.contains("dark:hidden"));
assert!(recipe.sun_icon.contains("dark:block"));
}
#[test]
fn component_metadata_uses_canonical_anatomy() {
use crate::variant_spec::ComponentSpec;
assert_eq!(
THEME_TOGGLE_RECIPE.anatomy(),
Some(super::super::THEME_TOGGLE_ANATOMY)
);
assert!(THEME_TOGGLE_RECIPE.variant_metadata().is_empty());
}
}
theme_toggle.rsOpen raw ↗
// 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())"));
}
}
}