Skip to main content

yew_hooks/hooks/
use_theme.rs

1use std::ops::Deref;
2use std::rc::Rc;
3
4use gloo::storage::{LocalStorage, Storage};
5use gloo::utils::{document, window};
6use wasm_bindgen::closure::Closure;
7use wasm_bindgen::JsCast;
8use wasm_bindgen::JsValue;
9use yew::prelude::*;
10
11use super::use_event_with_window;
12
13/// State handle for the [`use_theme`] hook.
14///
15/// This handle allows toggling between light/dark themes, forcing system theme,
16/// and syncing across tabs/windows.
17pub struct UseThemeHandle {
18    inner: UseStateHandle<String>,
19    is_system: UseStateHandle<bool>,
20    key: Rc<String>,
21}
22
23impl UseThemeHandle {
24    /// Toggle between light and dark. This will persist the user's choice to localStorage.
25    pub fn toggle(&self) {
26        if *self.is_system {
27            // If currently following system, toggling makes it explicit: choose opposite of current computed.
28            let current = computed_theme();
29            let next = if current == "dark" { "light" } else { "dark" }.to_string();
30            LocalStorage::set(&*self.key, next.clone()).ok();
31            self.inner.set(next);
32            self.is_system.set(false);
33        } else {
34            let next = if self.inner.as_str() == "dark" {
35                "light".to_string()
36            } else {
37                "dark".to_string()
38            };
39            LocalStorage::set(&*self.key, next.clone()).ok();
40            self.inner.set(next);
41            self.is_system.set(false);
42        }
43    }
44
45    /// Set theme to dark and persist choice.
46    pub fn set_dark(&self) {
47        LocalStorage::set(&*self.key, "dark".to_string()).ok();
48        self.inner.set("dark".to_string());
49        self.is_system.set(false);
50    }
51
52    /// Set theme to light and persist choice.
53    pub fn set_light(&self) {
54        LocalStorage::set(&*self.key, "light".to_string()).ok();
55        self.inner.set("light".to_string());
56        self.is_system.set(false);
57    }
58
59    /// Follow system preference. This removes any persisted preference and uses the OS setting.
60    pub fn set_system(&self) {
61        LocalStorage::delete(&*self.key);
62        let sys = computed_theme();
63        self.inner.set(sys);
64        self.is_system.set(true);
65    }
66
67    /// Returns true if the active theme is dark.
68    pub fn is_dark(&self) -> bool {
69        self.inner.as_str() == "dark"
70    }
71}
72
73impl Deref for UseThemeHandle {
74    type Target = String;
75
76    fn deref(&self) -> &Self::Target {
77        &self.inner
78    }
79}
80
81impl Clone for UseThemeHandle {
82    fn clone(&self) -> Self {
83        Self {
84            inner: self.inner.clone(),
85            is_system: self.is_system.clone(),
86            key: self.key.clone(),
87        }
88    }
89}
90
91impl PartialEq for UseThemeHandle {
92    fn eq(&self, other: &Self) -> bool {
93        *self.inner == *other.inner && *self.is_system == *other.is_system
94    }
95}
96
97/// Helper calling `window.matchMedia(query)` and returning optionally whether it matches.
98fn match_media_matches(query: &str) -> Option<bool> {
99    let w = window();
100    // call window.matchMedia via JS since web_sys types may not be available across versions
101    let mm = js_sys::Reflect::get(&w, &JsValue::from_str("matchMedia")).ok()?;
102    if mm.is_function() {
103        let func: js_sys::Function = mm.dyn_into().ok()?;
104        let mql = func.call1(&w.into(), &JsValue::from_str(query)).ok()?;
105        let matches = js_sys::Reflect::get(&mql, &JsValue::from_str("matches")).ok()?;
106        matches.as_bool()
107    } else {
108        None
109    }
110}
111
112/// Compute the system theme using the `prefers-color-scheme` media query.
113fn computed_theme() -> String {
114    match match_media_matches("(prefers-color-scheme: dark)") {
115        Some(true) => "dark".to_string(),
116        _ => "light".to_string(),
117    }
118}
119
120/// Apply or remove the `dark` class on the documentElement depending on theme.
121/// This matches typical Tailwind usage when `darkMode: "class"`.
122fn apply_theme_to_document(theme: &str) {
123    if let Some(doc_el) = document().document_element() {
124        // Read current `class` attribute and manipulate it manually so we don't rely on
125        // `class_list` which may not be present across web_sys versions.
126        let current = doc_el.get_attribute("class").unwrap_or_default();
127        let mut parts: Vec<&str> = current
128            .split_whitespace()
129            .filter(|s| !s.is_empty())
130            .collect();
131        let has_dark = parts.contains(&"dark");
132        if theme == "dark" {
133            if !has_dark {
134                parts.push("dark");
135            }
136        } else {
137            parts.retain(|&p| p != "dark");
138        }
139        let new = parts.join(" ");
140        let _ = if new.is_empty() {
141            doc_el.remove_attribute("class")
142        } else {
143            doc_el.set_attribute("class", &new)
144        };
145        // Also set data-theme for convenience
146        let _ = doc_el.set_attribute("data-theme", theme);
147    }
148}
149
150/// A hook to manage light/dark theme with persistence and system preference support.
151///
152/// # Example
153///
154/// ```rust
155/// # use yew::prelude::*;
156/// #
157/// use yew_hooks::prelude::*;
158///
159/// #[function_component(UseTheme)]
160/// fn theme() -> Html {
161///     let theme = use_theme("example_theme".to_string());
162///
163///     let onclick = {
164///         let theme = theme.clone();
165///         Callback::from(move |_| theme.toggle())
166///     };
167///
168///     html! {
169///         <>
170///             <button {onclick}>
171///                 { if theme.is_dark() { "Switch to light" } else { "Switch to dark" } }
172///             </button>
173///             <p>{ format!("Active theme: {}", *theme) }</p>
174///         </>
175///     }
176/// }
177/// ```
178///
179/// - `key`: the localStorage key to persist the user's preference. If not present, the hook follows system preference.
180///
181/// Returns a `UseThemeHandle` that can be used to read the current theme and switch it.
182#[hook]
183pub fn use_theme(key: String) -> UseThemeHandle {
184    // Determine initial state:
185    // If localStorage has a value, use it and mark not system.
186    // Otherwise, follow system and mark system = true.
187    let stored = LocalStorage::get::<String>(&key).ok();
188    let (initial_theme, initial_is_system) = match stored {
189        Some(s) if s == "dark" || s == "light" => (s, false),
190        _ => (computed_theme(), true),
191    };
192
193    let inner = use_state(|| initial_theme.clone());
194    let is_system = use_state(|| initial_is_system);
195    let key_rc = use_memo((), |_| key);
196
197    // Apply theme to document when theme changes
198    {
199        let inner = inner.clone();
200        use_effect_with(inner.clone(), move |theme| {
201            apply_theme_to_document(theme.as_str());
202            || ()
203        });
204    }
205
206    // Listen to storage events to sync across tabs/windows
207    {
208        let inner = inner.clone();
209        let is_system = is_system.clone();
210        let key = key_rc.clone();
211        use_event_with_window("storage", move |e: web_sys::Event| {
212            // The storage event exposes `key` property on the JS event object.
213            let js_e = JsValue::from(e);
214            let key_from_event = js_sys::Reflect::get(&js_e, &JsValue::from_str("key"))
215                .ok()
216                .and_then(|v| v.as_string());
217            if let Some(k) = key_from_event {
218                if k == *key {
219                    // Read latest value from localStorage. If missing, switch to system.
220                    if let Ok(v) = LocalStorage::get::<String>(&*key) {
221                        if v == "dark" || v == "light" {
222                            inner.set(v);
223                            is_system.set(false);
224                        } else {
225                            // unknown value => treat as system
226                            inner.set(computed_theme());
227                            is_system.set(true);
228                        }
229                    } else {
230                        inner.set(computed_theme());
231                        is_system.set(true);
232                    }
233                }
234            }
235        });
236    }
237
238    // Listen to system preference change and update only when following system.
239    // Use JS interop to attach an event listener on the MediaQueryList object.
240    {
241        let inner = inner.clone();
242        let is_system = is_system.clone();
243        use_effect_with((), move |_| {
244            // Obtain MediaQueryList via JS
245            let w = window();
246            let mm = js_sys::Reflect::get(&w, &JsValue::from_str("matchMedia")).ok();
247            let mql = mm
248                .and_then(|mm| mm.dyn_into::<js_sys::Function>().ok())
249                .and_then(|f| {
250                    f.call1(
251                        &w.into(),
252                        &JsValue::from_str("(prefers-color-scheme: dark)"),
253                    )
254                    .ok()
255                });
256
257            if let Some(mql_val) = mql {
258                // initial sync if following system
259                if *is_system {
260                    let matches = js_sys::Reflect::get(&mql_val, &JsValue::from_str("matches"))
261                        .ok()
262                        .and_then(|v| v.as_bool())
263                        .unwrap_or(false);
264                    inner.set(if matches {
265                        "dark".to_string()
266                    } else {
267                        "light".to_string()
268                    });
269                }
270
271                // add event listener for changes, only update when following system
272                // Cast to EventTarget to use add_event_listener_with_callback
273                if let Ok(target) = mql_val.clone().dyn_into::<web_sys::EventTarget>() {
274                    let inner_cl = inner.clone();
275                    let is_system_cl = is_system.clone();
276                    // keep mql_val clone inside closure so we can read `.matches`
277                    let mql_for_closure = mql_val.clone();
278                    let closure = Closure::wrap(Box::new(move |_ev: JsValue| {
279                        if *is_system_cl {
280                            let matches = js_sys::Reflect::get(
281                                &mql_for_closure,
282                                &JsValue::from_str("matches"),
283                            )
284                            .ok()
285                            .and_then(|v| v.as_bool())
286                            .unwrap_or(false);
287                            inner_cl.set(if matches {
288                                "dark".to_string()
289                            } else {
290                                "light".to_string()
291                            });
292                        }
293                    }) as Box<dyn FnMut(_)>);
294
295                    let _ = target.add_event_listener_with_callback(
296                        "change",
297                        closure.as_ref().unchecked_ref(),
298                    );
299                    // Leak the closure intentionally so it lives for the page lifetime (consistent with other hooks).
300                    closure.forget();
301                }
302            }
303
304            || ()
305        });
306    }
307
308    UseThemeHandle {
309        inner,
310        is_system,
311        key: key_rc,
312    }
313}