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}