Skip to main content

dioxus_bootstrap_css/
theme_vars.rs

1use dioxus::prelude::*;
2
3/// Runtime Bootstrap CSS variable theme overrides.
4///
5/// All fields default to "no override", so construct with struct-update syntax
6/// to stay forward compatible as new fields are added:
7///
8/// ```rust,no_run
9/// # use dioxus_bootstrap_css::prelude::*;
10/// let theme = BootstrapTheme {
11///     colors: ThemeColors { primary: Some("#165317".into()), ..Default::default() },
12///     ..Default::default()
13/// };
14/// ```
15#[derive(Clone, Debug, PartialEq, Default)]
16pub struct BootstrapTheme {
17    pub colors: ThemeColors,
18    pub surfaces: SurfaceColors,
19    pub dark: Option<ThemeModeTokens>,
20}
21
22/// Token overrides for a specific theme mode.
23#[derive(Clone, Debug, PartialEq, Default)]
24pub struct ThemeModeTokens {
25    pub colors: ThemeColors,
26    pub surfaces: SurfaceColors,
27}
28
29/// Semantic Bootstrap color slots.
30#[derive(Clone, Debug, PartialEq, Default)]
31pub struct ThemeColors {
32    pub primary: Option<SemanticColorScale>,
33    pub secondary: Option<SemanticColorScale>,
34    pub success: Option<SemanticColorScale>,
35    pub info: Option<SemanticColorScale>,
36    pub warning: Option<SemanticColorScale>,
37    pub danger: Option<SemanticColorScale>,
38    pub light: Option<SemanticColorScale>,
39    pub dark: Option<SemanticColorScale>,
40}
41
42/// Bootstrap surface and utility color variables.
43#[derive(Clone, Debug, PartialEq, Default)]
44pub struct SurfaceColors {
45    pub body_bg: Option<String>,
46    pub body_color: Option<String>,
47    pub secondary_bg: Option<String>,
48    pub secondary_color: Option<String>,
49    pub tertiary_bg: Option<String>,
50    pub tertiary_color: Option<String>,
51    pub border_color: Option<String>,
52    pub link_color: Option<String>,
53    pub link_hover_color: Option<String>,
54}
55
56/// Semantic Bootstrap color variables for one slot.
57#[derive(Clone, Debug, PartialEq)]
58pub struct SemanticColorScale {
59    pub base: String,
60    pub rgb: Option<(u8, u8, u8)>,
61    pub text_emphasis: Option<String>,
62    pub bg_subtle: Option<String>,
63    pub border_subtle: Option<String>,
64}
65
66impl SemanticColorScale {
67    /// Create a color slot from a base color (a hex string like `#0d6efd`, or
68    /// any CSS color / `var(...)` reference).
69    ///
70    /// Use the `with_*` methods to override derived tokens. Prefer them over a
71    /// struct literal so new fields can be added without breaking your code.
72    pub fn new(base: impl Into<String>) -> Self {
73        Self {
74            base: base.into(),
75            rgb: None,
76            text_emphasis: None,
77            bg_subtle: None,
78            border_subtle: None,
79        }
80    }
81
82    /// Set the `--bs-{color}-rgb` triplet explicitly (otherwise derived from a hex base).
83    pub fn with_rgb(mut self, rgb: (u8, u8, u8)) -> Self {
84        self.rgb = Some(rgb);
85        self
86    }
87
88    /// Set `--bs-{color}-text-emphasis` explicitly.
89    pub fn with_text_emphasis(mut self, color: impl Into<String>) -> Self {
90        self.text_emphasis = Some(color.into());
91        self
92    }
93
94    /// Set `--bs-{color}-bg-subtle` explicitly.
95    pub fn with_bg_subtle(mut self, color: impl Into<String>) -> Self {
96        self.bg_subtle = Some(color.into());
97        self
98    }
99
100    /// Set `--bs-{color}-border-subtle` explicitly.
101    pub fn with_border_subtle(mut self, color: impl Into<String>) -> Self {
102        self.border_subtle = Some(color.into());
103        self
104    }
105}
106
107impl From<&str> for SemanticColorScale {
108    fn from(value: &str) -> Self {
109        Self::new(value)
110    }
111}
112
113#[derive(Clone, Copy)]
114enum ThemeVariant {
115    Light,
116    Dark,
117}
118
119/// Overrides Bootstrap 5.3 color CSS variables at runtime for custom branding,
120/// by injecting a `<style>` block.
121///
122/// This is distinct from [`ThemeProvider`](crate::theme::ThemeProvider), which
123/// toggles light/dark *mode*. Use both together: `ThemeProvider` for the mode,
124/// `BootstrapThemeProvider` for the colors.
125#[derive(Clone, PartialEq, Props)]
126pub struct BootstrapThemeProviderProps {
127    pub theme: BootstrapTheme,
128}
129
130#[component]
131pub fn BootstrapThemeProvider(props: BootstrapThemeProviderProps) -> Element {
132    let css = build_theme_css(&props.theme);
133
134    if css.is_empty() {
135        return rsx! {};
136    }
137
138    rsx! {
139        style { "{css}" }
140    }
141}
142
143fn build_theme_css(theme: &BootstrapTheme) -> String {
144    let mut out = String::new();
145
146    push_mode_css(
147        &mut out,
148        ":root",
149        &theme.colors,
150        &theme.surfaces,
151        ThemeVariant::Light,
152    );
153
154    if let Some(dark) = &theme.dark {
155        push_mode_css(
156            &mut out,
157            r#"[data-bs-theme="dark"]"#,
158            &dark.colors,
159            &dark.surfaces,
160            ThemeVariant::Dark,
161        );
162    }
163
164    out
165}
166
167fn push_mode_css(
168    out: &mut String,
169    selector: &str,
170    colors: &ThemeColors,
171    surfaces: &SurfaceColors,
172    variant: ThemeVariant,
173) {
174    let mut block = String::new();
175
176    for (name, scale) in [
177        ("primary", colors.primary.as_ref()),
178        ("secondary", colors.secondary.as_ref()),
179        ("success", colors.success.as_ref()),
180        ("info", colors.info.as_ref()),
181        ("warning", colors.warning.as_ref()),
182        ("danger", colors.danger.as_ref()),
183        ("light", colors.light.as_ref()),
184        ("dark", colors.dark.as_ref()),
185    ] {
186        push_color_scale_css(&mut block, name, scale, variant);
187    }
188
189    for (name, value) in [
190        ("--bs-body-bg", surfaces.body_bg.as_deref()),
191        ("--bs-body-color", surfaces.body_color.as_deref()),
192        ("--bs-secondary-bg", surfaces.secondary_bg.as_deref()),
193        ("--bs-secondary-color", surfaces.secondary_color.as_deref()),
194        ("--bs-tertiary-bg", surfaces.tertiary_bg.as_deref()),
195        ("--bs-tertiary-color", surfaces.tertiary_color.as_deref()),
196        ("--bs-border-color", surfaces.border_color.as_deref()),
197        ("--bs-link-color", surfaces.link_color.as_deref()),
198        (
199            "--bs-link-hover-color",
200            surfaces.link_hover_color.as_deref(),
201        ),
202    ] {
203        push_var(&mut block, name, value);
204    }
205
206    if block.is_empty() {
207        return;
208    }
209
210    out.push_str(selector);
211    out.push_str(" {\n");
212    out.push_str(&block);
213    out.push_str("}\n");
214}
215
216fn push_color_scale_css(
217    out: &mut String,
218    name: &str,
219    scale: Option<&SemanticColorScale>,
220    variant: ThemeVariant,
221) {
222    let Some(scale) = scale else {
223        return;
224    };
225
226    push_var(out, &format!("--bs-{name}"), Some(scale.base.as_str()));
227
228    let parsed_rgb = parse_hex_color(&scale.base);
229    let rgb = scale.rgb.or(parsed_rgb);
230    let derived = parsed_rgb.map(|rgb| derive_scale(rgb, variant));
231
232    if let Some((red, green, blue)) = rgb {
233        let rgb_value = format!("{red}, {green}, {blue}");
234        push_var(out, &format!("--bs-{name}-rgb"), Some(&rgb_value));
235    }
236
237    let text_emphasis = scale.text_emphasis.as_deref().or_else(|| {
238        derived
239            .as_ref()
240            .map(|derived| derived.text_emphasis.as_str())
241    });
242    let bg_subtle = scale
243        .bg_subtle
244        .as_deref()
245        .or_else(|| derived.as_ref().map(|derived| derived.bg_subtle.as_str()));
246    let border_subtle = scale.border_subtle.as_deref().or_else(|| {
247        derived
248            .as_ref()
249            .map(|derived| derived.border_subtle.as_str())
250    });
251
252    push_var(out, &format!("--bs-{name}-text-emphasis"), text_emphasis);
253    push_var(out, &format!("--bs-{name}-bg-subtle"), bg_subtle);
254    push_var(out, &format!("--bs-{name}-border-subtle"), border_subtle);
255}
256
257fn push_var(out: &mut String, name: &str, value: Option<&str>) {
258    let Some(value) = value else {
259        return;
260    };
261
262    out.push_str("  ");
263    out.push_str(name);
264    out.push_str(": ");
265    out.push_str(value);
266    out.push_str(";\n");
267}
268
269#[derive(Clone)]
270struct DerivedScale {
271    text_emphasis: String,
272    bg_subtle: String,
273    border_subtle: String,
274}
275
276fn derive_scale((red, green, blue): (u8, u8, u8), variant: ThemeVariant) -> DerivedScale {
277    let base = (red, green, blue);
278    const BLACK: (u8, u8, u8) = (0, 0, 0);
279    const WHITE: (u8, u8, u8) = (255, 255, 255);
280
281    // Bootstrap 5.3 derives these with Sass shade-color (mix toward black) and
282    // tint-color (mix toward white) at these fixed weights.
283    let (text_emphasis, bg_subtle, border_subtle) = match variant {
284        ThemeVariant::Light => (
285            mix_rgb(base, BLACK, 0.60), // shade 60%
286            mix_rgb(base, WHITE, 0.80), // tint 80%
287            mix_rgb(base, WHITE, 0.60), // tint 60%
288        ),
289        ThemeVariant::Dark => (
290            mix_rgb(base, WHITE, 0.40), // tint 40%
291            mix_rgb(base, BLACK, 0.80), // shade 80%
292            mix_rgb(base, BLACK, 0.40), // shade 40%
293        ),
294    };
295
296    DerivedScale {
297        text_emphasis: rgb_to_hex(text_emphasis),
298        bg_subtle: rgb_to_hex(bg_subtle),
299        border_subtle: rgb_to_hex(border_subtle),
300    }
301}
302
303fn parse_hex_color(value: &str) -> Option<(u8, u8, u8)> {
304    let hex = value.strip_prefix('#')?;
305
306    match hex.len() {
307        3 => {
308            let red = parse_hex_byte(&hex[0..1].repeat(2))?;
309            let green = parse_hex_byte(&hex[1..2].repeat(2))?;
310            let blue = parse_hex_byte(&hex[2..3].repeat(2))?;
311            Some((red, green, blue))
312        }
313        6 => Some((
314            parse_hex_byte(&hex[0..2])?,
315            parse_hex_byte(&hex[2..4])?,
316            parse_hex_byte(&hex[4..6])?,
317        )),
318        _ => None,
319    }
320}
321
322fn parse_hex_byte(value: &str) -> Option<u8> {
323    u8::from_str_radix(value, 16).ok()
324}
325
326fn mix_rgb(from: (u8, u8, u8), to: (u8, u8, u8), amount: f32) -> (u8, u8, u8) {
327    (
328        mix_channel(from.0, to.0, amount),
329        mix_channel(from.1, to.1, amount),
330        mix_channel(from.2, to.2, amount),
331    )
332}
333
334fn mix_channel(from: u8, to: u8, amount: f32) -> u8 {
335    let blended = (from as f32 * (1.0 - amount)) + (to as f32 * amount);
336    blended.round().clamp(0.0, 255.0) as u8
337}
338
339fn rgb_to_hex((red, green, blue): (u8, u8, u8)) -> String {
340    format!("#{red:02x}{green:02x}{blue:02x}")
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn parses_short_and_long_hex_colors() {
349        assert_eq!(parse_hex_color("#165317"), Some((22, 83, 23)));
350        assert_eq!(parse_hex_color("#abc"), Some((170, 187, 204)));
351        assert_eq!(parse_hex_color("165317"), None);
352        assert_eq!(parse_hex_color("#abcd"), None);
353    }
354
355    #[test]
356    fn generates_light_and_dark_theme_css() {
357        let theme = BootstrapTheme {
358            colors: ThemeColors {
359                primary: Some(SemanticColorScale::new("#165317")),
360                dark: Some(SemanticColorScale::new("#092817")),
361                ..ThemeColors::default()
362            },
363            surfaces: SurfaceColors {
364                body_bg: Some("#f7fbf7".into()),
365                body_color: Some("#1d2b1f".into()),
366                ..SurfaceColors::default()
367            },
368            dark: Some(ThemeModeTokens {
369                colors: ThemeColors {
370                    primary: Some(SemanticColorScale::new("#4e9f53")),
371                    ..ThemeColors::default()
372                },
373                surfaces: SurfaceColors {
374                    body_bg: Some("#0b120c".into()),
375                    body_color: Some("#e6efe7".into()),
376                    ..SurfaceColors::default()
377                },
378            }),
379        };
380
381        let css = build_theme_css(&theme);
382
383        assert!(css.contains(":root {\n"));
384        assert!(css.contains(r#"[data-bs-theme="dark"] {"#));
385        assert!(css.contains("  --bs-primary: #165317;"));
386        assert!(css.contains("  --bs-primary-rgb: 22, 83, 23;"));
387        assert!(css.contains("  --bs-dark: #092817;"));
388        assert!(css.contains("  --bs-body-bg: #f7fbf7;"));
389        assert!(css.contains("  --bs-body-color: #1d2b1f;"));
390        assert!(css.contains("  --bs-primary: #4e9f53;"));
391        assert!(css.contains("  --bs-primary-rgb: 78, 159, 83;"));
392        assert!(css.contains("  --bs-body-bg: #0b120c;"));
393        assert!(css.contains("  --bs-body-color: #e6efe7;"));
394    }
395
396    #[test]
397    fn preserves_raw_values_and_explicit_overrides() {
398        let theme = BootstrapTheme {
399            colors: ThemeColors {
400                primary: Some(SemanticColorScale {
401                    base: "var(--brand-primary)".into(),
402                    rgb: None,
403                    text_emphasis: Some("#112233".into()),
404                    bg_subtle: None,
405                    border_subtle: Some("#ddeeff".into()),
406                }),
407                ..ThemeColors::default()
408            },
409            ..BootstrapTheme::default()
410        };
411
412        let css = build_theme_css(&theme);
413
414        assert!(css.contains("  --bs-primary: var(--brand-primary);"));
415        assert!(css.contains("  --bs-primary-text-emphasis: #112233;"));
416        assert!(css.contains("  --bs-primary-border-subtle: #ddeeff;"));
417        assert!(!css.contains("--bs-primary-rgb"));
418        assert!(!css.contains("--bs-primary-bg-subtle"));
419    }
420
421    #[test]
422    fn builder_sets_optional_tokens() {
423        let scale = SemanticColorScale::new("#0d6efd")
424            .with_rgb((13, 110, 253))
425            .with_text_emphasis("#084298")
426            .with_bg_subtle("#cfe2ff")
427            .with_border_subtle("#9ec5fe");
428        assert_eq!(scale.rgb, Some((13, 110, 253)));
429        assert_eq!(scale.text_emphasis.as_deref(), Some("#084298"));
430        assert_eq!(scale.bg_subtle.as_deref(), Some("#cfe2ff"));
431        assert_eq!(scale.border_subtle.as_deref(), Some("#9ec5fe"));
432    }
433
434    #[test]
435    fn derived_tokens_match_bootstrap_light() {
436        // Bootstrap 5.3's published --bs-primary tokens for #0d6efd (light mode).
437        let css = build_theme_css(&BootstrapTheme {
438            colors: ThemeColors {
439                primary: Some(SemanticColorScale::new("#0d6efd")),
440                ..Default::default()
441            },
442            ..Default::default()
443        });
444        assert!(
445            css.contains("--bs-primary-text-emphasis: #052c65;"),
446            "{css}"
447        );
448        assert!(css.contains("--bs-primary-bg-subtle: #cfe2ff;"), "{css}");
449        assert!(
450            css.contains("--bs-primary-border-subtle: #9ec5fe;"),
451            "{css}"
452        );
453    }
454}