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

Copy this source with the Free Proa UI components it imports; those component files remain available from their own source pages.

Licensed under MIT OR Apache-2.0. Third-party notices remain attached to the installed payload.

Open Markdown
FreeTheme Toggle

Light/dark mode switch with persistence.

Install with the Proa CLI
proa ui add theme_toggle

Installs 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 {

Search

Type at least 2 characters

Customize

Make it yours

Palette

Radius

Overview
Components
Settings

Build your own

Themed Proa UI surface

Preview
Components52
RecipesTyped
customer-portal