Skip to main content

dioxus_ui_system/organisms/
hero.rs

1//! Hero Section organism component
2//!
3//! Prominent page header with CTA for landing pages.
4
5use crate::theme::use_theme;
6use dioxus::prelude::*;
7
8/// Hero section properties
9#[derive(Props, Clone, PartialEq)]
10pub struct HeroProps {
11    /// Main headline
12    pub title: String,
13    /// Subtitle/description
14    #[props(default)]
15    pub subtitle: Option<String>,
16    /// Background element (image, gradient, video)
17    #[props(default)]
18    pub background: Option<Element>,
19    /// Primary CTA button
20    #[props(default)]
21    pub primary_cta: Option<HeroCta>,
22    /// Secondary CTA button
23    #[props(default)]
24    pub secondary_cta: Option<HeroCta>,
25    /// Social proof element
26    #[props(default)]
27    pub social_proof: Option<Element>,
28    /// Feature highlights
29    #[props(default)]
30    pub features: Vec<String>,
31    /// Alignment
32    #[props(default = HeroAlign::Center)]
33    pub align: HeroAlign,
34    /// Size variant
35    #[props(default = HeroSize::Lg)]
36    pub size: HeroSize,
37    /// Additional CSS classes
38    #[props(default)]
39    pub class: Option<String>,
40}
41
42/// Hero CTA configuration
43#[derive(Clone, PartialEq, Debug)]
44pub struct HeroCta {
45    pub label: String,
46    pub on_click: Option<EventHandler<()>>,
47    pub href: Option<String>,
48}
49
50impl HeroCta {
51    pub fn new(label: impl Into<String>) -> Self {
52        Self {
53            label: label.into(),
54            on_click: None,
55            href: None,
56        }
57    }
58
59    pub fn with_on_click(mut self, handler: EventHandler<()>) -> Self {
60        self.on_click = Some(handler);
61        self
62    }
63
64    pub fn with_href(mut self, href: impl Into<String>) -> Self {
65        self.href = Some(href.into());
66        self
67    }
68}
69
70/// Hero alignment
71#[derive(Default, Clone, PartialEq, Debug)]
72pub enum HeroAlign {
73    #[default]
74    Center,
75    Left,
76    Right,
77}
78
79/// Hero size
80#[derive(Default, Clone, PartialEq, Debug)]
81pub enum HeroSize {
82    Sm,
83    Md,
84    #[default]
85    Lg,
86    Xl,
87}
88
89impl HeroSize {
90    fn to_padding(&self) -> &'static str {
91        match self {
92            HeroSize::Sm => "48px 24px",
93            HeroSize::Md => "80px 24px",
94            HeroSize::Lg => "120px 24px",
95            HeroSize::Xl => "160px 24px",
96        }
97    }
98}
99
100/// Hero component
101#[component]
102pub fn Hero(props: HeroProps) -> Element {
103    let theme = use_theme();
104
105    let class_css = props
106        .class
107        .as_ref()
108        .map(|c| format!(" {}", c))
109        .unwrap_or_default();
110
111    let (text_align, align_items) = match props.align {
112        HeroAlign::Center => ("center", "center"),
113        HeroAlign::Left => ("left", "flex-start"),
114        HeroAlign::Right => ("right", "flex-end"),
115    };
116
117    let padding = props.size.to_padding();
118
119    rsx! {
120        section {
121            class: "hero{class_css}",
122            style: "position: relative; padding: {padding}; display: flex; flex-direction: column; align-items: {align_items}; text-align: {text_align}; overflow: hidden;",
123
124            // Background
125            if let Some(background) = props.background {
126                div {
127                    class: "hero-background",
128                    style: "position: absolute; inset: 0; z-index: 0;",
129                    {background}
130                }
131            }
132
133            // Content
134            div {
135                class: "hero-content",
136                style: "position: relative; z-index: 1; max-width: 900px;",
137
138                // Title
139                h1 {
140                    class: "hero-title",
141                    style: "margin: 0 0 24px 0; font-size: clamp(36px, 5vw, 64px); font-weight: 800; line-height: 1.1; color: {theme.tokens.read().colors.foreground.to_rgba()}; letter-spacing: -0.02em;",
142                    "{props.title}"
143                }
144
145                // Subtitle
146                if let Some(subtitle) = props.subtitle {
147                    p {
148                        class: "hero-subtitle",
149                        style: "margin: 0 0 32px 0; font-size: clamp(18px, 2vw, 24px); line-height: 1.6; color: {theme.tokens.read().colors.muted.to_rgba()}; max-width: 640px;",
150                        "{subtitle}"
151                    }
152                }
153
154                // Features
155                if !props.features.is_empty() {
156                    ul {
157                        class: "hero-features",
158                        style: format!("list-style: none; padding: 0; margin: 0 0 32px 0; display: flex; flex-wrap: wrap; justify-content: {}; gap: 16px 32px;", if props.align == HeroAlign::Center { "center" } else { "flex-start" }),
159
160                        for feature in props.features.iter() {
161                            li {
162                                key: "{feature}",
163                                style: "display: flex; align-items: center; gap: 8px; font-size: 16px; color: {theme.tokens.read().colors.foreground.to_rgba()};",
164
165                                span {
166                                    style: "color: #22c55e; font-weight: 600;",
167                                    "✓"
168                                }
169
170                                "{feature}"
171                            }
172                        }
173                    }
174                }
175
176                // CTAs
177                if props.primary_cta.is_some() || props.secondary_cta.is_some() {
178                    div {
179                        class: "hero-ctas",
180                        style: format!("display: flex; flex-wrap: wrap; justify-content: {}; gap: 16px;", if props.align == HeroAlign::Center { "center" } else { "flex-start" }),
181
182                        {{
183                            let cta = props.primary_cta.clone();
184                            cta.map(|cta| {
185                                if let Some(href) = cta.href {
186                                    rsx! {
187                                        a {
188                                            href: "{href}",
189                                            class: "hero-cta hero-cta-primary",
190                                            style: "display: inline-flex; align-items: center; justify-content: center; padding: 16px 32px; font-size: 16px; font-weight: 600; color: white; background: {theme.tokens.read().colors.primary.to_rgba()}; border-radius: 8px; text-decoration: none; transition: transform 0.15s ease, box-shadow 0.15s ease;",
191                                            "{cta.label}"
192                                        }
193                                    }
194                                } else {
195                                    rsx! {
196                                        button {
197                                            type: "button",
198                                            class: "hero-cta hero-cta-primary",
199                                            style: "padding: 16px 32px; font-size: 16px; font-weight: 600; color: white; background: {theme.tokens.read().colors.primary.to_rgba()}; border: none; border-radius: 8px; cursor: pointer; transition: transform 0.15s ease, box-shadow 0.15s ease;",
200                                            onclick: move |_| {
201                                                if let Some(handler) = &cta.on_click {
202                                                    handler.call(());
203                                                }
204                                            },
205                                            "{cta.label}"
206                                        }
207                                    }
208                                }
209                            })
210                        }}
211
212                        {{
213                            let cta = props.secondary_cta.clone();
214                            cta.map(|cta| {
215                                if let Some(href) = cta.href {
216                                    rsx! {
217                                        a {
218                                            href: "{href}",
219                                            class: "hero-cta hero-cta-secondary",
220                                            style: "display: inline-flex; align-items: center; justify-content: center; padding: 16px 32px; font-size: 16px; font-weight: 600; color: {theme.tokens.read().colors.foreground.to_rgba()}; background: transparent; border: 2px solid {theme.tokens.read().colors.border.to_rgba()}; border-radius: 8px; text-decoration: none; transition: border-color 0.15s ease;",
221                                            "{cta.label}"
222                                        }
223                                    }
224                                } else {
225                                    rsx! {
226                                        button {
227                                            type: "button",
228                                            class: "hero-cta hero-cta-secondary",
229                                            style: "padding: 16px 32px; font-size: 16px; font-weight: 600; color: {theme.tokens.read().colors.foreground.to_rgba()}; background: transparent; border: 2px solid {theme.tokens.read().colors.border.to_rgba()}; border-radius: 8px; cursor: pointer; transition: border-color 0.15s ease;",
230                                            onclick: move |_| {
231                                                if let Some(handler) = &cta.on_click {
232                                                    handler.call(());
233                                                }
234                                            },
235                                            "{cta.label}"
236                                        }
237                                    }
238                                }
239                            })
240                        }}
241                    }
242                }
243
244                // Social proof
245                if let Some(social_proof) = props.social_proof {
246                    div {
247                        class: "hero-social-proof",
248                        style: "margin-top: 48px;",
249                        {social_proof}
250                    }
251                }
252            }
253        }
254    }
255}
256
257/// Hero with image properties
258#[derive(Props, Clone, PartialEq)]
259pub struct HeroWithImageProps {
260    /// Hero content
261    pub children: Element,
262    /// Image element
263    pub image: Element,
264    /// Image position
265    #[props(default = ImagePosition::Right)]
266    pub image_position: ImagePosition,
267    /// Additional CSS classes
268    #[props(default)]
269    pub class: Option<String>,
270}
271
272/// Image position
273#[derive(Default, Clone, PartialEq, Debug)]
274pub enum ImagePosition {
275    #[default]
276    Right,
277    Left,
278}
279
280/// Hero with image (split layout)
281#[component]
282pub fn HeroWithImage(props: HeroWithImageProps) -> Element {
283    let class_css = props
284        .class
285        .as_ref()
286        .map(|c| format!(" {}", c))
287        .unwrap_or_default();
288
289    let (content_order, image_order) = match props.image_position {
290        ImagePosition::Left => (2, 1),
291        ImagePosition::Right => (1, 2),
292    };
293
294    rsx! {
295        section {
296            class: "hero-split{class_css}",
297            style: "padding: 80px 24px; display: grid; grid-template-columns: 1fr 1fr; gap: 64px; align-items: center; max-width: 1200px; margin: 0 auto;",
298
299            div {
300                class: "hero-split-content",
301                style: "order: {content_order};",
302                {props.children}
303            }
304
305            div {
306                class: "hero-split-image",
307                style: "order: {image_order};",
308                {props.image}
309            }
310        }
311    }
312}
313
314/// Social proof bar properties
315#[derive(Props, Clone, PartialEq)]
316pub struct SocialProofBarProps {
317    /// Text (e.g., "Trusted by 10,000+ companies")
318    pub text: String,
319    /// Logos or avatars
320    pub items: Vec<Element>,
321    /// Additional CSS classes
322    #[props(default)]
323    pub class: Option<String>,
324}
325
326/// Social proof bar component
327#[component]
328pub fn SocialProofBar(props: SocialProofBarProps) -> Element {
329    let theme = use_theme();
330
331    let class_css = props
332        .class
333        .as_ref()
334        .map(|c| format!(" {}", c))
335        .unwrap_or_default();
336
337    rsx! {
338        div {
339            class: "social-proof-bar{class_css}",
340            style: "display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 16px;",
341
342            if !props.items.is_empty() {
343                div {
344                    class: "social-proof-items",
345                    style: "display: flex; align-items: center;",
346
347                    for (i, item) in props.items.iter().enumerate() {
348                        div {
349                            key: "{i}",
350                            style: if i > 0 {
351                                format!("margin-left: -12px; z-index: {}; border: 2px solid white; border-radius: 50%;", props.items.len() - i)
352                            } else {
353                                format!("margin-left: 0; z-index: {}; border: 2px solid white; border-radius: 50%;", props.items.len() - i)
354                            },
355                            {item.clone()}
356                        }
357                    }
358                }
359            }
360
361            p {
362                class: "social-proof-text",
363                style: "margin: 0; font-size: 14px; color: {theme.tokens.read().colors.muted.to_rgba()};",
364                "{props.text}"
365            }
366        }
367    }
368}