Theme Toggle source
Inspect and copy the reviewed source included with this Free component.
Free source · 4 files
mod.rs
// 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"]
);
}
}
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())"));
}
}
}