Skip to main content

herogpui_components/
skeleton.rs

1//! Skeleton — port of `@heroui/skeleton` (v3).
2//!
3//! `animationType` selects between the sweeping shimmer, a pulse, and no
4//! motion; it defaults to the theme's `--skeleton-animation` token.
5
6use std::time::Duration;
7
8use gpui::{
9    div, prelude::*, px, Animation, AnimationExt, AnyElement, App, ElementId, IntoElement,
10    ParentElement, Pixels, RenderOnce, Styled, Window,
11};
12use herogpui_theme::{ActiveTheme, SkeletonAnimation};
13
14/// Loading placeholder (`Skeleton`).
15#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
16#[derive(IntoElement)]
17pub struct Skeleton {
18    id: ElementId,
19    w: Option<Pixels>,
20    h: Option<Pixels>,
21    /// `animationType`. `None` defers to `--skeleton-animation`.
22    animation_type: Option<SkeletonAnimation>,
23    /// The corner radius, in place of the owning `hairline_radius` helper.
24    radius: Option<Pixels>,
25    children: Vec<AnyElement>,
26    /// The `sx` slot, refined over the root style at the end of render.
27    sx: Option<Box<gpui::StyleRefinement>>,
28}
29
30impl Skeleton {
31    /// Creates a skeleton.
32    pub fn new() -> Self {
33        Self {
34            id: "skeleton".into(),
35            w: None,
36            // A leaf keeps the pinned 24px fallback. A composed Skeleton
37            // sizes itself from its children unless the caller supplies an
38            // explicit height, which is how the upstream parent shimmer can
39            // cover a profile/card/list shape without collapsing it to one
40            // line.
41            h: None,
42            animation_type: None,
43            radius: None,
44            children: Vec::new(),
45            sx: None,
46        }
47    }
48
49    /// Distinct id per skeleton; required when several animate on one page.
50    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
51        self.id = id.into();
52        self
53    }
54
55    /// Sets the width.
56    pub fn w(mut self, v: impl Into<Pixels>) -> Self {
57        self.w = Some(v.into());
58        self
59    }
60
61    /// Sets the height.
62    pub fn h(mut self, v: impl Into<Pixels>) -> Self {
63        self.h = Some(v.into());
64        self
65    }
66
67    /// Sets the animation type.
68    pub fn animation_type(mut self, animation: SkeletonAnimation) -> Self {
69        self.animation_type = Some(animation);
70        self
71    }
72
73    /// The corner radius, in place of the owning `hairline_radius` helper. Not
74    /// a v3 prop; the removed v2 `radius` prop is prohibited and this is a
75    /// per-component repository extension.
76    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
77        self.radius = Some(radius.into());
78        self
79    }
80
81    /// The one slot for caller-owned low-level styling: GPUI's styling methods
82    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
83    /// applied to the skeleton's root element after every value the component
84    /// and the active theme chose, so they win.
85    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
86        crate::util::refine_sx(&mut self.sx, style);
87        self
88    }
89}
90
91impl Default for Skeleton {
92    fn default() -> Self {
93        Self::new()
94    }
95}
96
97impl ParentElement for Skeleton {
98    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
99        self.children.extend(elements);
100    }
101}
102
103impl RenderOnce for Skeleton {
104    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
105        let colors = cx.colors();
106        let has_children = !self.children.is_empty();
107        // `.skeleton` is `bg-surface-tertiary/70`, not the solid token: the
108        // placeholder is meant to read as a tint of whatever it sits on. This
109        // painted it opaque, so every skeleton came out the full tertiary fill
110        // -- (234,234,235) on the light page against v3's (237,237,238).
111        let base_color = colors.surface_tertiary.alpha(0.7);
112        // Reduced motion collapses every animation type to `None`.
113        let animation = if ActiveTheme::reduce_motion(cx) {
114            SkeletonAnimation::None
115        } else {
116            self.animation_type
117                .unwrap_or(cx.layout().skeleton_animation)
118        };
119
120        // Hoisted so the shimmer band below can share the resolved value:
121        // vanilla GPUI clips `overflow_hidden()` to the rectangle, so the
122        // band carries this same radius (see `util::inner_fill_radius`).
123        let base_radius = match self.radius {
124            Some(radius) => radius,
125            None => crate::util::hairline_radius(cx),
126        };
127        let base = div()
128            .bg(base_color)
129            .rounded(base_radius)
130            .overflow_hidden()
131            .when_some(self.w, |el, w| el.w(w))
132            .when_some(
133                self.h.or_else(|| (!has_children).then(|| px(24.))),
134                |el, h| el.h(h),
135            )
136            // HeroUI keeps composed child skeletons visible and suppresses
137            // their individual shimmer in the gallery with
138            // `animationType="none"`; the parent owns the synchronized band.
139            .when(has_children, |el| el.children(self.children));
140
141        match animation {
142            SkeletonAnimation::None => crate::util::apply_sx(base, &self.sx).into_any_element(),
143            // `with_animation` hands back an `AnimationElement`, which has no
144            // style of its own to refine, so the slot lands on the box the
145            // animation wraps: the pulse keeps driving opacity, everything
146            // else the caller set holds.
147            SkeletonAnimation::Pulse => crate::util::apply_sx(base, &self.sx)
148                .with_animation(
149                    self.id,
150                    Animation::new(Duration::from_millis(1600)).repeat(),
151                    move |el, delta| {
152                        let t = (delta * std::f32::consts::TAU).sin();
153                        el.opacity(0.55 + 0.25 * t)
154                    },
155                )
156                .into_any_element(),
157            // A highlight band sweeping left to right, like v3's shimmer. The
158            // band itself is animated, so its position moves rather than its
159            // size.
160            SkeletonAnimation::Shimmer => {
161                let highlight = colors.background;
162                // The band sweeps edge to edge, so at each end of its travel
163                // it paints square pixels through the base's rounded corners
164                // (vanilla GPUI clips `overflow_hidden()` to the rectangle).
165                // Rounding the band to the base radius reproduces the CSS
166                // clip v3 gets from `overflow: hidden` + `border-radius`.
167                let band = div()
168                    .absolute()
169                    .top_0()
170                    .bottom_0()
171                    .w(gpui::relative(0.35))
172                    .rounded(base_radius)
173                    .bg(gpui::linear_gradient(
174                        90.0,
175                        gpui::linear_color_stop(highlight.alpha(0.0), 0.0),
176                        gpui::linear_color_stop(highlight.alpha(0.7), 0.5),
177                    ))
178                    .with_animation(
179                        self.id,
180                        Animation::new(Duration::from_millis(1400)).repeat(),
181                        |el, delta| el.left(gpui::relative(delta)),
182                    );
183                crate::util::apply_sx(base.child(band), &self.sx).into_any_element()
184            }
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    #[test]
192    fn composed_skeletons_keep_children_visible_for_a_parent_shimmer() {
193        let source = include_str!("skeleton.rs")
194            .split("#[cfg(test)]")
195            .next()
196            .expect("the implementation section is always present");
197        assert!(source.contains("self.h.or_else(|| (!has_children).then(|| px(24.)))"));
198        assert!(source.contains(".when(has_children, |el| el.children(self.children))"));
199        assert!(
200            !source.contains("div().opacity(0.).children(self.children)"),
201            "composed Skeleton children must remain visible beneath the parent shimmer"
202        );
203    }
204}
205
206crate::util::impl_component_styled!(Skeleton);