skeleton_rs/dioxus.rs
1#![doc = include_str!("../DIOXUS.md")]
2
3use crate::common::{Animation, Direction, Theme, Variant};
4use dioxus::prelude::*;
5use gloo_timers::callback::Timeout;
6use web_sys::js_sys;
7use web_sys::wasm_bindgen::JsCast;
8use web_sys::wasm_bindgen::prelude::*;
9use web_sys::window;
10use web_sys::{IntersectionObserver, IntersectionObserverEntry};
11
12/// Properties for the `Skeleton` component.
13#[derive(Props, PartialEq, Clone)]
14pub struct SkeletonProps {
15 /// Child elements to render inside the skeleton.
16 ///
17 /// If provided, the children will be wrapped with the skeleton styling and animation.
18 #[props(default)]
19 pub children: Element,
20
21 /// The visual variant of the skeleton.
22 ///
23 /// Variants control the shape or type of the skeleton placeholder, such as text or circle.
24 /// Defaults to `Variant::Text`.
25 #[props(default)]
26 pub variant: Variant,
27
28 /// Animation style applied to the skeleton.
29 ///
30 /// Controls how the skeleton animates, e.g., pulse, wave, etc.
31 /// Defaults to `Animation::Pulse`.
32 #[props(default)]
33 pub animation: Animation,
34
35 /// Direction of the animation direction and background color gradient.
36 #[props(default)]
37 pub direction: Direction,
38
39 /// The theme of the skeleton appearance.
40 ///
41 /// Allows switching between light or dark themes.
42 /// Defaults to `Theme::Light`.
43 #[props(default)]
44 pub theme: Theme,
45
46 /// The width of the skeleton.
47 ///
48 /// Accepts any valid CSS width value (e.g., `100%`, `200px`, `10rem`). Defaults to `"100%"`.
49 #[props(default = "100%")]
50 pub width: &'static str,
51
52 /// The height of the skeleton.
53 ///
54 /// Accepts any valid CSS height value. Defaults to `"1em"`.
55 #[props(default = "1em")]
56 pub height: &'static str,
57
58 /// Optional font size for the skeleton text.
59 ///
60 /// Used to size the placeholder in proportion to text elements. If not set, font size is not applied.
61 #[props(default)]
62 pub font_size: Option<&'static str>,
63
64 /// Border radius for the skeleton.
65 ///
66 /// Controls the rounding of the skeleton's corners. Accepts any valid CSS radius.
67 /// Defaults to `"4px"`.
68 #[props(default = "4px")]
69 pub border_radius: &'static str,
70
71 /// Display property for the skeleton.
72 ///
73 /// Determines the skeleton's display type (e.g., `inline-block`, `block`). Defaults to `"inline-block"`.
74 #[props(default = "inline-block")]
75 pub display: &'static str,
76
77 /// Line height of the skeleton content.
78 ///
79 /// This affects vertical spacing in text-like skeletons. Defaults to `"1"`.
80 #[props(default = "1")]
81 pub line_height: &'static str,
82
83 /// The CSS `position` property.
84 ///
85 /// Controls how the skeleton is positioned. Defaults to `"relative"`.
86 #[props(default = "relative")]
87 pub position: &'static str,
88
89 /// Overflow behavior of the skeleton container.
90 ///
91 /// Accepts values like `hidden`, `visible`, etc. Defaults to `"hidden"`.
92 #[props(default = "hidden")]
93 pub overflow: &'static str,
94
95 /// Margin applied to the skeleton.
96 ///
97 /// Accepts any valid CSS margin value. Defaults to `""`.
98 #[props(default)]
99 pub margin: &'static str,
100
101 /// Additional inline styles.
102 ///
103 /// Allows you to append arbitrary CSS to the skeleton component. Useful for quick overrides.
104 #[props(default)]
105 pub custom_style: &'static str,
106
107 /// Whether to automatically infer the size from children.
108 ///
109 /// If `true`, the skeleton will try to match the dimensions of its content.
110 #[props(default)]
111 pub infer_size: bool,
112
113 /// Whether the skeleton is currently visible.
114 ///
115 /// Controls whether the skeleton should be rendered or hidden.
116 #[props(default)]
117 pub show: bool,
118
119 /// Delay before the skeleton becomes visible, in milliseconds.
120 ///
121 /// Useful for preventing flicker on fast-loading content. Defaults to `0`.
122 #[props(default = 0)]
123 pub delay_ms: u32,
124
125 /// Whether the skeleton is responsive.
126 ///
127 /// Enables responsive resizing behavior based on the parent container or screen size.
128 #[props(default)]
129 pub responsive: bool,
130
131 /// Optional maximum width of the skeleton.
132 ///
133 /// Accepts any valid CSS width value (e.g., `600px`, `100%`).
134 #[props(default)]
135 pub max_width: Option<&'static str>,
136
137 /// Optional minimum width of the skeleton.
138 ///
139 /// Accepts any valid CSS width value.
140 #[props(default)]
141 pub min_width: Option<&'static str>,
142
143 /// Optional maximum height of the skeleton.
144 ///
145 /// Accepts any valid CSS height value.
146 #[props(default)]
147 pub max_height: Option<&'static str>,
148
149 /// Optional minimum height of the skeleton.
150 ///
151 /// Accepts any valid CSS height value.
152 #[props(default)]
153 pub min_height: Option<&'static str>,
154
155 /// Whether the skeleton animates on hover.
156 ///
157 /// When enabled, an animation will be triggered when the user hovers over the skeleton.
158 #[props(default)]
159 pub animate_on_hover: bool,
160
161 /// Whether the skeleton animates on focus.
162 ///
163 /// Useful for accessibility - triggers animation when the component receives focus.
164 #[props(default)]
165 pub animate_on_focus: bool,
166
167 /// Whether the skeleton animates on active (click or tap).
168 ///
169 /// Triggers animation when the skeleton is actively clicked or touched.
170 #[props(default)]
171 pub animate_on_active: bool,
172
173 /// Whether the skeleton animates when it becomes visible in the viewport.
174 ///
175 /// Uses `IntersectionObserver` to detect visibility and trigger animation.
176 #[props(default)]
177 pub animate_on_visible: bool,
178}
179
180/// Skeleton Component
181///
182/// A flexible and customizable `Skeleton` component for Dioxus applications, ideal for
183/// rendering placeholder content during loading states. It supports
184/// animations, visibility-based rendering, and responsive behavior.
185///
186/// # Properties
187/// The component uses the `SkeletonProps` struct for its properties. Key properties include:
188///
189/// # Features
190/// - **Viewport-aware Animation**: When `animate_on_visible` is enabled, the component uses `IntersectionObserver` to trigger animation only when the element is scrolled into view.
191///
192/// - **Delay Support**: Prevents immediate rendering using the `delay_ms` prop, useful for avoiding flicker on fast-loading content.
193///
194/// - **Responsive Layout**: With the `responsive` prop, skeletons scale naturally across screen sizes.
195///
196/// - **State-controlled Rendering**: You can explicitly show or hide the skeleton using the `show` prop or control visibility dynamically.
197///
198/// - **Slot Support**:
199/// You can pass children to be wrapped in the skeleton effect, especially useful for text or dynamic UI blocks.
200///
201/// # Examples
202///
203/// ## Basic Usage
204/// ```rust
205/// use dioxus::prelude::*;
206/// use skeleton_rs::dioxus::Skeleton;
207///
208/// fn App() -> Element {
209/// rsx! {
210/// Skeleton {
211/// width: "200px",
212/// height: "1.5em"
213/// }
214/// }
215/// }
216/// ```
217///
218/// ## Text Placeholder
219/// ```rust
220/// use dioxus::prelude::*;
221/// use skeleton_rs::dioxus::Skeleton;
222/// use skeleton_rs::Variant;
223///
224/// fn App() -> Element {
225/// rsx! {
226/// Skeleton {
227/// variant: Variant::Text,
228/// width: "100%",
229/// height: "1.2em"
230/// }
231/// }
232/// }
233/// ```
234///
235/// ## Responsive with Inferred Size
236/// ```rust
237/// use dioxus::prelude::*;
238/// use skeleton_rs::dioxus::Skeleton;
239///
240/// fn App() -> Element {
241/// rsx! {
242/// Skeleton {
243/// infer_size: true,
244/// responsive: true,
245/// p { "Loading text..." }
246/// }
247/// }
248/// }
249/// ```
250///
251/// ## Animate When Visible
252/// ```rust
253/// use dioxus::prelude::*;
254/// use skeleton_rs::dioxus::Skeleton;
255/// use skeleton_rs::Variant;
256///
257/// fn App() -> Element {
258/// rsx! {
259/// Skeleton {
260/// variant: Variant::Text,
261/// animate_on_visible: true,
262/// width: "80%",
263/// height: "2em"
264/// }
265/// }
266/// }
267/// ```
268///
269/// # Behavior
270/// - With `animate_on_visible`, the animation begins only when the skeleton is in the viewport.
271/// - When `show` is false, the component stays hidden until external or internal logic reveals it.
272/// - Most style attributes can be customized via props.
273///
274/// # Accessibility
275/// - Skeletons typically serve as non-interactive placeholders and are not announced by screen readers.
276/// - For better accessibility, use parent-level ARIA attributes like `aria-busy`, `aria-hidden`, or live regions.
277///
278/// # Notes
279/// - The component uses `NodeRef` internally to track visibility using `IntersectionObserver`.
280/// - Child content provided via the `children` prop is rendered and masked by the skeleton effect.
281///
282/// # See Also
283/// - [MDN IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)
284#[component]
285pub fn Skeleton(props: SkeletonProps) -> Element {
286 let mut visible = use_signal(|| !props.show);
287 let id = "skeleton-rs";
288
289 use_effect(move || {
290 if props.show {
291 visible.set(false);
292 } else if props.delay_ms > 0 {
293 Timeout::new(props.delay_ms, move || {
294 visible.set(true);
295 })
296 .forget();
297 } else {
298 visible.set(true);
299 }
300 });
301
302 if props.animate_on_visible {
303 use_effect(move || {
304 let window = web_sys::window().unwrap();
305 let document = window.document().unwrap();
306 if let Some(element) = document.get_element_by_id(id) {
307 let closure = Closure::wrap(Box::new(
308 move |entries: js_sys::Array, _obs: IntersectionObserver| {
309 for entry in entries.iter() {
310 let entry: IntersectionObserverEntry = entry.unchecked_into();
311 if entry.is_intersecting() {
312 visible.set(true);
313 }
314 }
315 },
316 )
317 as Box<dyn FnMut(js_sys::Array, IntersectionObserver)>);
318
319 let observer = IntersectionObserver::new(closure.as_ref().unchecked_ref()).unwrap();
320 observer.observe(&element);
321 closure.forget();
322 }
323 });
324 }
325
326 let background_color = match props.theme {
327 Theme::Light => "#e0e0e0",
328 Theme::Dark => "#444444",
329 Theme::Custom(color) => color,
330 };
331
332 let effective_radius = match props.variant {
333 Variant::Circular | Variant::Avatar => "50%",
334 Variant::Rectangular => "0",
335 Variant::Rounded => "8px",
336 Variant::Button => "6px",
337 Variant::Text | Variant::Image => props.border_radius,
338 };
339
340 let animation_style = match props.animation {
341 Animation::Pulse => "animation: skeleton-rs-pulse 1.5s ease-in-out infinite;".to_string(),
342 Animation::Wave => {
343 let angle = match props.direction {
344 Direction::LeftToRight => 90,
345 Direction::RightToLeft => 270,
346 Direction::TopToBottom => 180,
347 Direction::BottomToTop => 0,
348 Direction::CustomAngle(deg) => deg,
349 };
350
351 format!(
352 "background: linear-gradient({}deg, #e0e0e0 25%, #f5f5f5 50%, #e0e0e0 75%);
353 background-size: 200% 100%;
354 animation: skeleton-rs-wave 1.6s linear infinite;",
355 angle
356 )
357 }
358 Animation::None => "".to_string(),
359 };
360
361 let mut style = String::new();
362 if props.infer_size {
363 style.push_str(&format!(
364 "background-color: {background_color}; border-radius: {effective_radius}; display: {}; position: {}; overflow: {}; margin: {};",
365 props.display, props.position, props.overflow, props.margin
366 ));
367 } else {
368 style.push_str(&format!(
369 "width: {}; height: {}; background-color: {background_color}; border-radius: {effective_radius}; display: {}; position: {}; overflow: {}; margin: {}; line-height: {};",
370 props.width, props.height, props.display, props.position, props.overflow, props.margin, props.line_height
371 ));
372 }
373
374 if let Some(size) = props.font_size {
375 style.push_str(&format!(" font-size: {size};"));
376 }
377 if let Some(max_w) = props.max_width {
378 style.push_str(&format!(" max-width: {max_w};"));
379 }
380 if let Some(min_w) = props.min_width {
381 style.push_str(&format!(" min-width: {min_w};"));
382 }
383 if let Some(max_h) = props.max_height {
384 style.push_str(&format!(" max-height: {max_h};"));
385 }
386 if let Some(min_h) = props.min_height {
387 style.push_str(&format!(" min-height: {min_h};"));
388 }
389
390 style.push_str(&animation_style);
391 style.push_str(props.custom_style);
392
393 let mut class_names = "skeleton-rs".to_string();
394 if props.animate_on_hover {
395 class_names.push_str(" skeleton-hover");
396 }
397 if props.animate_on_focus {
398 class_names.push_str(" skeleton-focus");
399 }
400 if props.animate_on_active {
401 class_names.push_str(" skeleton-active");
402 }
403
404 let direction = props.direction.clone();
405 use_effect(move || {
406 let window = window().unwrap();
407 let document = window.document().unwrap();
408 if document.get_element_by_id("skeleton-rs-style").is_none() {
409 let style_elem = document.create_element("style").unwrap();
410 style_elem.set_id("skeleton-rs-style");
411
412 let wave_keyframes = match direction {
413 Direction::LeftToRight => {
414 r#"
415 @keyframes skeleton-rs-wave {
416 0% { background-position: 200% 0; }
417 25% { background-position: 100% 0; }
418 50% { background-position: 0% 0; }
419 75% { background-position: -100% 0; }
420 100% { background-position: -200% 0; }
421 }"#
422 }
423 Direction::RightToLeft => {
424 r#"
425 @keyframes skeleton-rs-wave {
426 0% { background-position: -200% 0; }
427 25% { background-position: -100% 0; }
428 50% { background-position: 0% 0; }
429 75% { background-position: 100% 0; }
430 100% { background-position: 200% 0; }
431 }"#
432 }
433 Direction::TopToBottom => {
434 r#"
435 @keyframes skeleton-rs-wave {
436 0% { background-position: 0 -200%; }
437 25% { background-position: 0 -100%; }
438 50% { background-position: 0 0%; }
439 75% { background-position: 0 100%; }
440 100% { background-position: 0 200%; }
441 }"#
442 }
443 Direction::BottomToTop => {
444 r#"
445 @keyframes skeleton-rs-wave {
446 0% { background-position: 0 200%; }
447 25% { background-position: 0 100%; }
448 50% { background-position: 0 0%; }
449 75% { background-position: 0 -100%; }
450 100% { background-position: 0 -200%; }
451 }"#
452 }
453 Direction::CustomAngle(_) => {
454 r#"
455 @keyframes skeleton-rs-wave {
456 0% { background-position: 200% 0; }
457 25% { background-position: 100% 0; }
458 50% { background-position: 0% 0; }
459 75% { background-position: -100% 0; }
460 100% { background-position: -200% 0; }
461 }"#
462 }
463 };
464
465 let css = format!(
466 r#"
467 @keyframes skeleton-rs-pulse {{
468 0% {{ opacity: 1; }}
469 25% {{ opacity: 0.7; }}
470 50% {{ opacity: 0.4; }}
471 75% {{ opacity: 0.7; }}
472 100% {{ opacity: 1; }}
473 }}
474
475 {}
476
477 .skeleton-hover:hover {{
478 filter: brightness(0.95);
479 }}
480
481 .skeleton-focus:focus {{
482 outline: 2px solid #999;
483 }}
484
485 .skeleton-active:active {{
486 transform: scale(0.98);
487 }}
488 "#,
489 wave_keyframes
490 );
491
492 style_elem.set_inner_html(&css);
493 if let Some(head) = document.head() {
494 head.append_child(&style_elem).unwrap();
495 }
496 }
497 });
498
499 if visible() {
500 rsx! {
501 div {
502 id: "{id}",
503 class: "{class_names}",
504 style: "{style}",
505 role: "presentation",
506 aria_hidden: "true"
507 }
508 }
509 } else {
510 rsx! {
511 {props.children}
512 }
513 }
514}
515
516#[derive(Props, PartialEq, Clone)]
517pub struct SkeletonGroupProps {
518 #[props(default)]
519 pub children: Element,
520
521 #[props(default)]
522 pub style: &'static str,
523
524 #[props(default)]
525 pub class: &'static str,
526}
527
528#[component]
529pub fn SkeletonGroup(props: SkeletonGroupProps) -> Element {
530 rsx! {
531 div {
532 class: "{props.class}",
533 style: "{props.style}",
534 {props.children}
535 }
536 }
537}