dioxus_ui_system/organisms/
hero.rs1use crate::theme::use_theme;
6use dioxus::prelude::*;
7
8#[derive(Props, Clone, PartialEq)]
10pub struct HeroProps {
11 pub title: String,
13 #[props(default)]
15 pub subtitle: Option<String>,
16 #[props(default)]
18 pub background: Option<Element>,
19 #[props(default)]
21 pub primary_cta: Option<HeroCta>,
22 #[props(default)]
24 pub secondary_cta: Option<HeroCta>,
25 #[props(default)]
27 pub social_proof: Option<Element>,
28 #[props(default)]
30 pub features: Vec<String>,
31 #[props(default = HeroAlign::Center)]
33 pub align: HeroAlign,
34 #[props(default = HeroSize::Lg)]
36 pub size: HeroSize,
37 #[props(default)]
39 pub class: Option<String>,
40}
41
42#[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#[derive(Default, Clone, PartialEq, Debug)]
72pub enum HeroAlign {
73 #[default]
74 Center,
75 Left,
76 Right,
77}
78
79#[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#[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 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 div {
135 class: "hero-content",
136 style: "position: relative; z-index: 1; max-width: 900px;",
137
138 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 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 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 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 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#[derive(Props, Clone, PartialEq)]
259pub struct HeroWithImageProps {
260 pub children: Element,
262 pub image: Element,
264 #[props(default = ImagePosition::Right)]
266 pub image_position: ImagePosition,
267 #[props(default)]
269 pub class: Option<String>,
270}
271
272#[derive(Default, Clone, PartialEq, Debug)]
274pub enum ImagePosition {
275 #[default]
276 Right,
277 Left,
278}
279
280#[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#[derive(Props, Clone, PartialEq)]
316pub struct SocialProofBarProps {
317 pub text: String,
319 pub items: Vec<Element>,
321 #[props(default)]
323 pub class: Option<String>,
324}
325
326#[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}