# Theme Toggle

A light/dark mode switch: moon while the page is light, sun while it is dark. The choice persists across visits.

- Tier: Free
- Page: /components/theme_toggle
- License: MIT OR Apache-2.0 (component source)

## Install

```sh
proa ui add theme_toggle
```

Installs the reviewed component source, dependencies, shared support files, and required legal notices.

## Usage

```rust
use proa_macros::html_sync;
use proa_ui::ThemeToggle;

let view = html_sync! {
    {ThemeToggle::new()}
};
```

## Examples

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

## Source

Free component source is dual licensed under MIT OR Apache-2.0. Use of the Proa framework is governed separately under AGPL-3.0-only or a commercial Proa license.

### md.rs

```rust
// 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.rs

```rust
// 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.rs

```rust
// 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.rs

````rust
// 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())"));
        }
    }
}
````

