Skip to main content

animato_dioxus/
css.rs

1//! CSS property helpers for Dioxus animation hooks.
2
3use animato_core::Easing;
4use animato_spring::SpringConfig;
5use dioxus::prelude::{Signal, use_effect, use_signal};
6
7/// CSS property bag used by Animato Dioxus helpers.
8#[derive(Clone, Debug, Default, PartialEq)]
9pub struct AnimatedStyle {
10    /// CSS `opacity`.
11    pub opacity: Option<f32>,
12    /// Raw CSS `transform` string appended after generated transform parts.
13    pub transform: Option<String>,
14    /// Uniform CSS scale.
15    pub scale: Option<f32>,
16    /// Translation on the x axis in CSS pixels.
17    pub translate_x: Option<f32>,
18    /// Translation on the y axis in CSS pixels.
19    pub translate_y: Option<f32>,
20    /// Rotation in degrees.
21    pub rotate: Option<f32>,
22    /// Skew on the x axis in degrees.
23    pub skew_x: Option<f32>,
24    /// Skew on the y axis in degrees.
25    pub skew_y: Option<f32>,
26    /// CSS blur radius in pixels.
27    pub blur: Option<f32>,
28    /// RGBA background color with components in `[0.0, 1.0]`.
29    pub background_color: Option<[f32; 4]>,
30    /// CSS border radius in pixels.
31    pub border_radius: Option<f32>,
32    /// CSS width in pixels.
33    pub width: Option<f32>,
34    /// CSS height in pixels.
35    pub height: Option<f32>,
36    /// Raw CSS `clip-path` value.
37    pub clip_path: Option<String>,
38    /// Additional raw CSS property/value pairs.
39    pub custom: Vec<(String, String)>,
40}
41
42impl AnimatedStyle {
43    /// Create an empty style bag.
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Set `opacity`.
49    pub fn opacity(mut self, value: f32) -> Self {
50        self.opacity = Some(value.clamp(0.0, 1.0));
51        self
52    }
53
54    /// Set translation in CSS pixels.
55    pub fn translate(mut self, x: f32, y: f32) -> Self {
56        self.translate_x = Some(crate::finite_or(x, 0.0));
57        self.translate_y = Some(crate::finite_or(y, 0.0));
58        self
59    }
60
61    /// Set uniform scale.
62    pub fn scale(mut self, value: f32) -> Self {
63        self.scale = Some(crate::finite_or(value, 1.0).max(0.0));
64        self
65    }
66
67    /// Set rotation in degrees.
68    pub fn rotate(mut self, degrees: f32) -> Self {
69        self.rotate = Some(crate::finite_or(degrees, 0.0));
70        self
71    }
72
73    /// Set blur in CSS pixels.
74    pub fn blur(mut self, px: f32) -> Self {
75        self.blur = Some(crate::finite_or(px, 0.0).max(0.0));
76        self
77    }
78
79    /// Set width in CSS pixels.
80    pub fn width(mut self, px: f32) -> Self {
81        self.width = Some(crate::finite_or(px, 0.0).max(0.0));
82        self
83    }
84
85    /// Set height in CSS pixels.
86    pub fn height(mut self, px: f32) -> Self {
87        self.height = Some(crate::finite_or(px, 0.0).max(0.0));
88        self
89    }
90
91    /// Set background color from RGBA components in `[0.0, 1.0]`.
92    pub fn background_color(mut self, rgba: [f32; 4]) -> Self {
93        self.background_color = Some(rgba.map(|v| v.clamp(0.0, 1.0)));
94        self
95    }
96
97    /// Set border radius in CSS pixels.
98    pub fn border_radius(mut self, px: f32) -> Self {
99        self.border_radius = Some(crate::finite_or(px, 0.0).max(0.0));
100        self
101    }
102
103    /// Set raw `clip-path`.
104    pub fn clip_path(mut self, value: impl Into<String>) -> Self {
105        self.clip_path = Some(value.into());
106        self
107    }
108
109    /// Set raw `transform`.
110    pub fn transform(mut self, value: impl Into<String>) -> Self {
111        self.transform = Some(value.into());
112        self
113    }
114
115    /// Add a custom raw CSS property.
116    pub fn custom(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
117        self.custom.push((name.into(), value.into()));
118        self
119    }
120
121    /// Interpolate two style bags.
122    pub fn interpolate(&self, other: &Self, t: f32) -> Self {
123        let t = t.clamp(0.0, 1.0);
124        Self {
125            opacity: lerp_option(self.opacity, other.opacity, t),
126            transform: choose_string(self.transform.as_ref(), other.transform.as_ref(), t),
127            scale: lerp_option(self.scale, other.scale, t),
128            translate_x: lerp_option(self.translate_x, other.translate_x, t),
129            translate_y: lerp_option(self.translate_y, other.translate_y, t),
130            rotate: lerp_option(self.rotate, other.rotate, t),
131            skew_x: lerp_option(self.skew_x, other.skew_x, t),
132            skew_y: lerp_option(self.skew_y, other.skew_y, t),
133            blur: lerp_option(self.blur, other.blur, t),
134            background_color: lerp_color(self.background_color, other.background_color, t),
135            border_radius: lerp_option(self.border_radius, other.border_radius, t),
136            width: lerp_option(self.width, other.width, t),
137            height: lerp_option(self.height, other.height, t),
138            clip_path: choose_string(self.clip_path.as_ref(), other.clip_path.as_ref(), t),
139            custom: if t >= 1.0 {
140                other.custom.clone()
141            } else {
142                self.custom.clone()
143            },
144        }
145    }
146
147    /// Convert the property bag into a CSS style string.
148    pub fn to_css(&self) -> String {
149        let mut style = String::new();
150
151        if let Some(opacity) = self.opacity {
152            push_prop(&mut style, "opacity", &format_num(opacity));
153        }
154
155        let transform = self.transform_string();
156        if !transform.is_empty() {
157            push_prop(&mut style, "transform", &transform);
158        }
159
160        if let Some(blur) = self.blur {
161            push_prop(&mut style, "filter", &format!("blur({})", format_px(blur)));
162        }
163        if let Some(color) = self.background_color {
164            push_prop(&mut style, "background-color", &rgba_to_css(color));
165        }
166        if let Some(radius) = self.border_radius {
167            push_prop(&mut style, "border-radius", &format_px(radius));
168        }
169        if let Some(width) = self.width {
170            push_prop(&mut style, "width", &format_px(width));
171        }
172        if let Some(height) = self.height {
173            push_prop(&mut style, "height", &format_px(height));
174        }
175        if let Some(clip_path) = &self.clip_path {
176            push_prop(&mut style, "clip-path", clip_path);
177        }
178        for (name, value) in &self.custom {
179            push_prop(&mut style, name, value);
180        }
181
182        style
183    }
184
185    /// Return only the generated CSS transform string.
186    pub fn transform_string(&self) -> String {
187        let mut parts = Vec::new();
188        if let Some(x) = self.translate_x {
189            let y = self.translate_y.unwrap_or(0.0);
190            parts.push(format!("translate({},{})", format_px(x), format_px(y)));
191        } else if let Some(y) = self.translate_y {
192            parts.push(format!("translateY({})", format_px(y)));
193        }
194        if let Some(scale) = self.scale {
195            parts.push(format!("scale({})", format_num(scale)));
196        }
197        if let Some(rotate) = self.rotate {
198            parts.push(format!("rotate({}deg)", format_num(rotate)));
199        }
200        if let Some(skew_x) = self.skew_x {
201            parts.push(format!("skewX({}deg)", format_num(skew_x)));
202        }
203        if let Some(skew_y) = self.skew_y {
204            parts.push(format!("skewY({}deg)", format_num(skew_y)));
205        }
206        if let Some(raw) = &self.transform {
207            parts.push(raw.clone());
208        }
209        parts.join(" ")
210    }
211}
212
213/// Animate CSS properties with a tween and return a style-string signal.
214pub fn css_tween(
215    from: AnimatedStyle,
216    to: AnimatedStyle,
217    duration: f32,
218    easing: Easing,
219) -> Signal<String> {
220    let initial = from.to_css();
221    let style = use_signal(move || initial);
222    let (progress, _handle) = crate::hooks::use_tween(0.0_f32, 1.0, move |builder| {
223        builder.duration(duration.max(0.0)).easing(easing)
224    });
225
226    use_effect(move || {
227        let p = crate::read_signal(progress);
228        crate::set_signal(style, from.interpolate(&to, p).to_css());
229    });
230
231    style
232}
233
234/// Animate CSS properties with a spring and return a style-string signal.
235pub fn css_spring(target: AnimatedStyle, config: SpringConfig) -> Signal<String> {
236    let style = use_signal(String::new);
237    let (progress, handle) = crate::hooks::use_spring(0.0_f32, config);
238    handle.set_target(1.0);
239
240    use_effect(move || {
241        let p = crate::read_signal(progress).clamp(0.0, 1.0);
242        crate::set_signal(
243            style,
244            AnimatedStyle::default().interpolate(&target, p).to_css(),
245        );
246    });
247
248    style
249}
250
251fn lerp_option(a: Option<f32>, b: Option<f32>, t: f32) -> Option<f32> {
252    match (a, b) {
253        (Some(a), Some(b)) => Some(a + (b - a) * t),
254        (Some(a), None) => Some(a),
255        (None, Some(b)) => Some(b * t),
256        (None, None) => None,
257    }
258}
259
260fn lerp_color(a: Option<[f32; 4]>, b: Option<[f32; 4]>, t: f32) -> Option<[f32; 4]> {
261    match (a, b) {
262        (Some(a), Some(b)) => Some([
263            a[0] + (b[0] - a[0]) * t,
264            a[1] + (b[1] - a[1]) * t,
265            a[2] + (b[2] - a[2]) * t,
266            a[3] + (b[3] - a[3]) * t,
267        ]),
268        (Some(a), None) => Some(a),
269        (None, Some(b)) => Some([b[0] * t, b[1] * t, b[2] * t, b[3] * t]),
270        (None, None) => None,
271    }
272}
273
274fn choose_string(a: Option<&String>, b: Option<&String>, t: f32) -> Option<String> {
275    match (a, b) {
276        (_, Some(b)) if t >= 1.0 => Some(b.clone()),
277        (Some(a), _) => Some(a.clone()),
278        (None, Some(b)) => Some(b.clone()),
279        (None, None) => None,
280    }
281}
282
283fn push_prop(style: &mut String, name: &str, value: &str) {
284    if !style.is_empty() {
285        style.push(' ');
286    }
287    style.push_str(name);
288    style.push(':');
289    style.push_str(value);
290    style.push(';');
291}
292
293fn format_px(value: f32) -> String {
294    format!("{}px", format_num(value))
295}
296
297fn format_num(value: f32) -> String {
298    let value = crate::finite_or(value, 0.0);
299    let rounded = (value * 1000.0).round() / 1000.0;
300    if (rounded - rounded.round()).abs() < 0.0001 {
301        format!("{}", rounded.round() as i32)
302    } else {
303        format!("{rounded:.3}")
304            .trim_end_matches('0')
305            .trim_end_matches('.')
306            .to_owned()
307    }
308}
309
310fn rgba_to_css(color: [f32; 4]) -> String {
311    let r = (color[0].clamp(0.0, 1.0) * 255.0).round() as u8;
312    let g = (color[1].clamp(0.0, 1.0) * 255.0).round() as u8;
313    let b = (color[2].clamp(0.0, 1.0) * 255.0).round() as u8;
314    let a = format_num(color[3].clamp(0.0, 1.0));
315    format!("rgba({r},{g},{b},{a})")
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use animato_core::Easing;
322    use animato_spring::SpringConfig;
323    use dioxus::prelude::*;
324    use std::cell::RefCell;
325
326    thread_local! {
327        static CSS_TWEEN_CAPTURE: RefCell<Option<Signal<String>>> = const { RefCell::new(None) };
328        static CSS_SPRING_CAPTURE: RefCell<Option<Signal<String>>> = const { RefCell::new(None) };
329    }
330
331    #[allow(non_snake_case)]
332    fn CssTweenApp() -> Element {
333        let style = css_tween(
334            AnimatedStyle::new().opacity(0.0),
335            AnimatedStyle::new()
336                .opacity(1.0)
337                .translate(10.0, 0.0)
338                .blur(2.0),
339            0.2,
340            Easing::Linear,
341        );
342        CSS_TWEEN_CAPTURE.with(|slot| *slot.borrow_mut() = Some(style));
343
344        rsx! { div {} }
345    }
346
347    #[allow(non_snake_case)]
348    fn CssSpringApp() -> Element {
349        let style = css_spring(
350            AnimatedStyle::new()
351                .opacity(1.0)
352                .scale(1.25)
353                .border_radius(8.0),
354            SpringConfig::snappy(),
355        );
356        CSS_SPRING_CAPTURE.with(|slot| *slot.borrow_mut() = Some(style));
357
358        rsx! { div {} }
359    }
360
361    #[test]
362    fn style_formats_transform_and_color() {
363        let css = AnimatedStyle::new()
364            .opacity(0.5)
365            .translate(10.0, 20.0)
366            .scale(1.25)
367            .background_color([1.0, 0.0, 0.5, 0.75])
368            .to_css();
369
370        assert!(css.contains("opacity:0.5;"));
371        assert!(css.contains("translate(10px,20px)"));
372        assert!(css.contains("rgba(255,0,128,0.75)"));
373    }
374
375    #[test]
376    fn interpolation_blends_numeric_properties() {
377        let from = AnimatedStyle::new().opacity(0.0).translate(0.0, 10.0);
378        let to = AnimatedStyle::new().opacity(1.0).translate(20.0, 30.0);
379        let mid = from.interpolate(&to, 0.5);
380
381        assert_eq!(mid.opacity, Some(0.5));
382        assert_eq!(mid.translate_x, Some(10.0));
383        assert_eq!(mid.translate_y, Some(20.0));
384    }
385
386    #[test]
387    fn style_formats_all_supported_properties_and_clamps_inputs() {
388        let mut style = AnimatedStyle::new()
389            .opacity(2.0)
390            .translate(f32::NAN, 12.3456)
391            .scale(-1.0)
392            .rotate(f32::INFINITY)
393            .blur(-4.0)
394            .width(-100.0)
395            .height(42.25)
396            .background_color([2.0, -1.0, 0.25, 1.5])
397            .border_radius(-3.0)
398            .clip_path("inset(0)")
399            .transform("translateZ(0)")
400            .custom("pointer-events", "none");
401        style.skew_x = Some(15.0);
402        style.skew_y = Some(-10.0);
403
404        let transform = style.transform_string();
405        assert!(transform.contains("translate(0px,12.346px)"));
406        assert!(transform.contains("scale(0)"));
407        assert!(transform.contains("rotate(0deg)"));
408        assert!(transform.contains("skewX(15deg)"));
409        assert!(transform.contains("skewY(-10deg)"));
410        assert!(transform.contains("translateZ(0)"));
411
412        let css = style.to_css();
413        assert!(css.contains("opacity:1;"));
414        assert!(css.contains("filter:blur(0px);"));
415        assert!(css.contains("background-color:rgba(255,0,64,1);"));
416        assert!(css.contains("border-radius:0px;"));
417        assert!(css.contains("width:0px;"));
418        assert!(css.contains("height:42.25px;"));
419        assert!(css.contains("clip-path:inset(0);"));
420        assert!(css.contains("pointer-events:none;"));
421    }
422
423    #[test]
424    fn interpolation_handles_missing_values_strings_colors_and_custom_props() {
425        let from = AnimatedStyle::new()
426            .opacity(0.8)
427            .transform("scale(2)")
428            .background_color([1.0, 0.0, 0.0, 1.0])
429            .clip_path("circle(20%)")
430            .custom("left", "0px");
431        let to = AnimatedStyle::new()
432            .scale(2.0)
433            .blur(10.0)
434            .background_color([0.0, 0.0, 1.0, 0.5])
435            .clip_path("circle(80%)")
436            .custom("left", "10px");
437
438        let mid = from.interpolate(&to, 0.5);
439        assert_eq!(mid.opacity, Some(0.8));
440        assert_eq!(mid.scale, Some(1.0));
441        assert_eq!(mid.blur, Some(5.0));
442        assert_eq!(mid.background_color, Some([0.5, 0.0, 0.5, 0.75]));
443        assert_eq!(mid.transform.as_deref(), Some("scale(2)"));
444        assert_eq!(mid.clip_path.as_deref(), Some("circle(20%)"));
445        assert_eq!(mid.custom, vec![("left".to_owned(), "0px".to_owned())]);
446
447        let end = from.interpolate(&to, 1.0);
448        assert_eq!(end.transform.as_deref(), Some("scale(2)"));
449        assert_eq!(end.clip_path.as_deref(), Some("circle(80%)"));
450        assert_eq!(end.custom, vec![("left".to_owned(), "10px".to_owned())]);
451
452        let from_only = from.interpolate(&AnimatedStyle::new(), 0.5);
453        assert_eq!(from_only.background_color, Some([1.0, 0.0, 0.0, 1.0]));
454    }
455
456    #[test]
457    fn css_hooks_return_stable_style_signals() {
458        CSS_TWEEN_CAPTURE.with(|slot| *slot.borrow_mut() = None);
459        let mut tween_dom = VirtualDom::new(CssTweenApp);
460        tween_dom.rebuild_in_place();
461        let tween_style = CSS_TWEEN_CAPTURE.with(|slot| {
462            slot.borrow()
463                .as_ref()
464                .copied()
465                .expect("css tween signal captured")
466        });
467        assert!(crate::read_signal(tween_style).contains("opacity:0;"));
468
469        CSS_SPRING_CAPTURE.with(|slot| *slot.borrow_mut() = None);
470        let mut spring_dom = VirtualDom::new(CssSpringApp);
471        spring_dom.rebuild_in_place();
472        let spring_style = CSS_SPRING_CAPTURE.with(|slot| {
473            slot.borrow()
474                .as_ref()
475                .copied()
476                .expect("css spring signal captured")
477        });
478        let spring_css = crate::read_signal(spring_style);
479        assert!(spring_css.is_empty() || spring_css.contains("opacity:"));
480    }
481}