autumn_plugin_motion/motion.rs
1//! The typed [`Motion`] animation builder.
2//!
3//! A [`Motion`] value describes one declarative animation: a preset plus
4//! modifiers. It renders the `data-motion*` attributes that the plugin's
5//! `init.js` scans for, so animations stay type-checked on the Rust side
6//! while the behavior lives in the vendored Motion build.
7//!
8//! # Raw attribute reference
9//!
10//! The builder emits these attributes; you can also write them by hand on
11//! your own elements:
12//!
13//! - `data-motion="fade-up|fade-down|fade-left|fade-right|fade|scale|zoom-in|zoom-out|slide-left|slide-right|slide-up|slide-down|rotate-in|blur-in"`
14//! — the preset (default `fade-up`).
15//! - `data-motion-delay="150"` — start delay in milliseconds (default `0`).
16//! - `data-motion-duration="0.6"` — duration in seconds (default `0.6`).
17//! - `data-motion-once="false"` — re-animate every time the element enters
18//! the viewport (default is once).
19//! - `data-motion-stagger="60"` — on a container: cascade the preset over
20//! its direct children, `60` ms apart.
21//! - `data-motion-scroll` — bare attribute: drive the keyframes by scroll
22//! progress instead of animating on entry.
23//! - `data-motion-ease="ease-out|cubic-bezier(0.16,1,0.3,1)|spring(300,20,1)"`
24//! — easing curve (default: a snappy `cubic-bezier(0.16,1,0.3,1)`).
25//! - `data-motion-repeat="2"` — repeat the animation `n` times after the
26//! first play.
27//! - `data-motion-repeat-type="loop|reverse|mirror"` — how each repeat
28//! cycle restarts (default `loop`).
29//! - `data-motion-amount="some|all|0.5"` — how much of the element must be
30//! visible before the `inView` trigger fires (default `some`).
31//! - `data-motion-margin="-100px"` — grow/shrink the viewport for the
32//! `inView` trigger, in CSS margin syntax (`px` or `%`, up to four
33//! values). Negative waits until the element is further inside;
34//! positive fires early.
35//! - `data-motion="parallax"` + `data-motion-parallax="0.3"` — scroll-linked
36//! drift: the element travels `0.3` × its own height as it moves through
37//! the viewport (see [`Motion::parallax`]).
38//! - `data-motion="scroll-progress"` — opinionated page-scroll progress bar
39//! (see [`Motion::scroll_progress`]).
40//! - `data-motion-scroll-target="#hero"` — drive the scroll-linked keyframes
41//! by *another* element's traversal (a CSS selector; falls back to the
42//! element itself when missing). Implies `data-motion-scroll`.
43//! - `data-motion-scroll-offset="start end,center center"` — comma-separated
44//! scroll offsets mapping target/container edges (default
45//! `"start end,end start"`). Invalid entries are ignored.
46//! - `data-motion-reduced="animate"` — opt back into animation when the
47//! user prefers reduced motion (default: skip animation, content stays
48//! fully visible).
49//! - `data-motion-stagger-from="first|last|center|edges|2"` — where a
50//! staggered cascade starts (default `first`). Only applies with
51//! `data-motion-stagger`.
52//! - `data-motion-stagger-ease="ease-out"` — ease the stagger distribution
53//! across children (same value syntax as `data-motion-ease`).
54//! - `data-motion-hover="scale(1.05) brightness(1.1)"` — grow and/or
55//! brighten on hover (any subset).
56//! - `data-motion-press="scale(0.95)"` — shrink while pressed.
57
58use autumn_web::{Markup, html};
59
60/// Default animation duration in seconds, used when no duration is set.
61const DEFAULT_DURATION_SECS: f32 = 0.6;
62
63/// An easing curve for a [`Motion`] animation.
64///
65/// Serializes to the `data-motion-ease` attribute, which `assets/init.js`
66/// maps onto Motion's easing definitions. When no ease is set, `init.js`
67/// applies a snappy expo-out curve (`cubic-bezier(0.16, 1, 0.3, 1)`).
68///
69/// ```rust
70/// use autumn_plugin_motion::{Ease, Motion};
71/// use autumn_web::{Markup, html};
72///
73/// let card: Markup = Motion::scale()
74/// .ease(Ease::Spring { stiffness: 300.0, damping: 20.0, mass: 1.0 })
75/// .wrap(html! { p { "Boing" } });
76/// assert!(card.into_string().contains(r#"data-motion-ease="spring(300,20,1)""#));
77/// ```
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub enum Ease {
80 /// Constant speed.
81 Linear,
82 /// Start slow, end fast (`cubic-bezier(0.42, 0, 1, 1)`).
83 In,
84 /// Start fast, end slow (`cubic-bezier(0, 0, 0.58, 1)`).
85 Out,
86 /// Slow at both ends (`cubic-bezier(0.42, 0, 0.58, 1)`).
87 InOut,
88 /// Circular ease-in.
89 CircIn,
90 /// Circular ease-out.
91 CircOut,
92 /// Circular ease-in-out.
93 CircInOut,
94 /// Overshoots, then settles (ease-in flavor).
95 BackIn,
96 /// Overshoots, then settles (ease-out flavor).
97 BackOut,
98 /// Overshoots, then settles (in-out flavor).
99 BackInOut,
100 /// Starts by moving slightly backwards, then accelerates.
101 Anticipate,
102 /// A custom cubic-bezier curve: `(x1, y1, x2, y2)`.
103 CubicBezier(f32, f32, f32, f32),
104 /// Spring physics. `stiffness` is the spring strength, `damping` the
105 /// friction, `mass` the weight being moved.
106 Spring {
107 /// Spring strength (higher = snappier).
108 stiffness: f32,
109 /// Friction (higher = less oscillation).
110 damping: f32,
111 /// Weight being moved.
112 mass: f32,
113 },
114}
115
116impl Ease {
117 /// The `data-motion-ease` attribute value for this ease.
118 fn attr_value(self) -> String {
119 match self {
120 Self::Linear => "linear".to_owned(),
121 Self::In => "ease-in".to_owned(),
122 Self::Out => "ease-out".to_owned(),
123 Self::InOut => "ease-in-out".to_owned(),
124 Self::CircIn => "circ-in".to_owned(),
125 Self::CircOut => "circ-out".to_owned(),
126 Self::CircInOut => "circ-in-out".to_owned(),
127 Self::BackIn => "back-in".to_owned(),
128 Self::BackOut => "back-out".to_owned(),
129 Self::BackInOut => "back-in-out".to_owned(),
130 Self::Anticipate => "anticipate".to_owned(),
131 Self::CubicBezier(x1, y1, x2, y2) => {
132 format!("cubic-bezier({x1},{y1},{x2},{y2})")
133 }
134 Self::Spring {
135 stiffness,
136 damping,
137 mass,
138 } => format!("spring({stiffness},{damping},{mass})"),
139 }
140 }
141}
142
143/// How a repeated [`Motion`] animation restarts each cycle.
144///
145/// Serializes to the `data-motion-repeat-type` attribute; mirrors Motion's
146/// `repeatType` transition option.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum RepeatType {
149 /// Jump back to the start each cycle (default).
150 Loop,
151 /// Play backwards on alternate cycles.
152 Reverse,
153 /// Alternate direction each cycle (ping-pong).
154 Mirror,
155}
156
157impl RepeatType {
158 /// The `data-motion-repeat-type` attribute value.
159 const fn attr(self) -> &'static str {
160 match self {
161 Self::Loop => "loop",
162 Self::Reverse => "reverse",
163 Self::Mirror => "mirror",
164 }
165 }
166}
167
168/// How much of the element must be visible before its `inView` animation
169/// triggers.
170///
171/// Serializes to the `data-motion-amount` attribute; mirrors Motion's
172/// `inView` `amount` option (`"some" | "all" | number`).
173#[derive(Debug, Clone, Copy, PartialEq)]
174pub enum InViewAmount {
175 /// Trigger when any part of the element enters the viewport
176 /// (Motion's default).
177 Some,
178 /// Trigger only when the whole element is visible.
179 All,
180 /// Trigger when this fraction (0.0–1.0) of the element is visible.
181 /// Values outside the range are clamped.
182 Fraction(f32),
183}
184
185impl InViewAmount {
186 /// The `data-motion-amount` attribute value.
187 fn attr_value(self) -> String {
188 match self {
189 Self::Some => "some".to_string(),
190 Self::All => "all".to_string(),
191 Self::Fraction(v) => format!("{}", v.clamp(0.0, 1.0)),
192 }
193 }
194}
195
196impl From<f32> for InViewAmount {
197 /// Lets `.amount(0.5)` mean `.amount(InViewAmount::Fraction(0.5))`.
198 fn from(v: f32) -> Self {
199 Self::Fraction(v)
200 }
201}
202
203/// Where a staggered cascade starts.
204///
205/// Serializes to `data-motion-stagger-from`; mirrors Motion's `stagger`
206/// `from` option (`"first" | "last" | "center" | "edges" | index`).
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum StaggerFrom {
209 /// From the first child (default).
210 First,
211 /// From the last child.
212 Last,
213 /// From the middle outward.
214 Center,
215 /// From the edges inward.
216 Edges,
217 /// From the child at this index.
218 Index(u32),
219}
220
221impl StaggerFrom {
222 /// The `data-motion-stagger-from` attribute value.
223 fn attr_value(self) -> String {
224 match self {
225 Self::First => "first".to_string(),
226 Self::Last => "last".to_string(),
227 Self::Center => "center".to_string(),
228 Self::Edges => "edges".to_string(),
229 Self::Index(i) => i.to_string(),
230 }
231 }
232}
233
234/// An animation preset, mirroring the keyframes in `assets/init.js`.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum Preset {
237 /// Fade in while rising 24px.
238 FadeUp,
239 /// Fade in while sinking 24px.
240 FadeDown,
241 /// Fade in while drifting 24px left.
242 FadeLeft,
243 /// Fade in while drifting 24px right.
244 FadeRight,
245 /// Fade in place.
246 Fade,
247 /// Fade in while scaling from 0.94.
248 Scale,
249 /// Fade in while zooming from 0.8 scale.
250 ZoomIn,
251 /// Fade in while settling from 1.15 scale.
252 ZoomOut,
253 /// Fade in while sliding 32px from the right.
254 SlideLeft,
255 /// Fade in while sliding 32px from the left.
256 SlideRight,
257 /// Fade in while rising 32px from below.
258 SlideUp,
259 /// Fade in while sinking 32px from above.
260 SlideDown,
261 /// Fade in while straightening from -8 degrees.
262 RotateIn,
263 /// Fade in while de-blurring from 8px.
264 BlurIn,
265 /// Scroll-linked drift (see [`Motion::parallax`]).
266 Parallax,
267}
268
269impl Preset {
270 /// The `data-motion` attribute value for this preset.
271 const fn attr(self) -> &'static str {
272 match self {
273 Self::FadeUp => "fade-up",
274 Self::FadeDown => "fade-down",
275 Self::FadeLeft => "fade-left",
276 Self::FadeRight => "fade-right",
277 Self::Fade => "fade",
278 Self::Scale => "scale",
279 Self::ZoomIn => "zoom-in",
280 Self::ZoomOut => "zoom-out",
281 Self::SlideLeft => "slide-left",
282 Self::SlideRight => "slide-right",
283 Self::SlideUp => "slide-up",
284 Self::SlideDown => "slide-down",
285 Self::RotateIn => "rotate-in",
286 Self::BlurIn => "blur-in",
287 Self::Parallax => "parallax",
288 }
289 }
290}
291
292/// A declarative animation: preset plus modifiers.
293///
294/// Build with a preset constructor ([`Motion::fade_up`], [`Motion::fade`],
295/// [`Motion::scale`], [`Motion::slide_left`], … — see [`Preset`]),
296/// tune with the modifier methods, then either [`Motion::wrap`] the markup
297/// or write the attributes by hand (see the module docs).
298///
299/// ```rust
300/// use autumn_plugin_motion::Motion;
301/// use autumn_web::{Markup, html};
302///
303/// let card: Markup = Motion::fade_up().delay(150).wrap(html! {
304/// h2 { "Hello" }
305/// });
306/// assert!(card.into_string().contains(r#"data-motion="fade-up""#));
307/// ```
308#[derive(Debug, Clone)]
309pub struct Motion {
310 preset: Preset,
311 delay_ms: u32,
312 duration_secs: Option<f32>,
313 ease: Option<Ease>,
314 repeat: Option<u32>,
315 repeat_type: Option<RepeatType>,
316 amount: Option<InViewAmount>,
317 margin: Option<String>,
318 scroll_target: Option<String>,
319 scroll_offset: Option<[String; 2]>,
320 parallax_factor: Option<f32>,
321 stagger_from: Option<StaggerFrom>,
322 stagger_ease: Option<Ease>,
323 hover_scale: Option<f32>,
324 hover_brightness: Option<f32>,
325 press_scale: Option<f32>,
326 once: bool,
327 stagger_ms: Option<u32>,
328 scroll: bool,
329}
330
331impl Motion {
332 /// Fade in while rising 24px.
333 #[must_use]
334 pub const fn fade_up() -> Self {
335 Self::preset(Preset::FadeUp)
336 }
337
338 /// Fade in while sinking 24px.
339 #[must_use]
340 pub const fn fade_down() -> Self {
341 Self::preset(Preset::FadeDown)
342 }
343
344 /// Fade in while drifting 24px left.
345 #[must_use]
346 pub const fn fade_left() -> Self {
347 Self::preset(Preset::FadeLeft)
348 }
349
350 /// Fade in while drifting 24px right.
351 #[must_use]
352 pub const fn fade_right() -> Self {
353 Self::preset(Preset::FadeRight)
354 }
355
356 /// Fade in place.
357 #[must_use]
358 pub const fn fade() -> Self {
359 Self::preset(Preset::Fade)
360 }
361
362 /// Fade in while scaling from 0.94.
363 #[must_use]
364 pub const fn scale() -> Self {
365 Self::preset(Preset::Scale)
366 }
367
368 /// Fade in while zooming from 0.8 scale.
369 #[must_use]
370 pub const fn zoom_in() -> Self {
371 Self::preset(Preset::ZoomIn)
372 }
373
374 /// Fade in while settling from 1.15 scale.
375 #[must_use]
376 pub const fn zoom_out() -> Self {
377 Self::preset(Preset::ZoomOut)
378 }
379
380 /// Fade in while sliding 32px from the right.
381 #[must_use]
382 pub const fn slide_left() -> Self {
383 Self::preset(Preset::SlideLeft)
384 }
385
386 /// Fade in while sliding 32px from the left.
387 #[must_use]
388 pub const fn slide_right() -> Self {
389 Self::preset(Preset::SlideRight)
390 }
391
392 /// Fade in while rising 32px from below.
393 #[must_use]
394 pub const fn slide_up() -> Self {
395 Self::preset(Preset::SlideUp)
396 }
397
398 /// Fade in while sinking 32px from above.
399 #[must_use]
400 pub const fn slide_down() -> Self {
401 Self::preset(Preset::SlideDown)
402 }
403
404 /// Fade in while straightening from -8 degrees.
405 #[must_use]
406 pub const fn rotate_in() -> Self {
407 Self::preset(Preset::RotateIn)
408 }
409
410 /// Fade in while de-blurring from 8px.
411 #[must_use]
412 pub const fn blur_in() -> Self {
413 Self::preset(Preset::BlurIn)
414 }
415
416 /// Scroll-linked drift for hero art and backgrounds.
417 ///
418 /// As the element travels through the viewport, it translates by
419 /// `factor` × its own height: positive lags the scroll (drifts down as
420 /// you scroll down), negative moves against it. `0.3` is a good
421 /// default; keep `|factor|` modest — large values fling content away.
422 ///
423 /// Parallax is inherently scroll-linked and ignores the `scroll`,
424 /// `scroll_target`, and `scroll_offset` modifiers.
425 ///
426 /// ```rust
427 /// use autumn_plugin_motion::Motion;
428 /// use autumn_web::{Markup, html};
429 ///
430 /// let html: Markup = Motion::parallax(0.3).wrap(html! { p { "Hi" } });
431 /// let s = html.into_string();
432 /// assert!(s.contains(r#"data-motion="parallax""#), "{s}");
433 /// assert!(s.contains(r#"data-motion-parallax="0.3""#), "{s}");
434 /// ```
435 #[must_use]
436 pub const fn parallax(factor: f32) -> Self {
437 let mut motion = Self::preset(Preset::Parallax);
438 motion.parallax_factor = Some(factor);
439 motion
440 }
441
442 /// Opinionated page-scroll progress bar element.
443 ///
444 /// Renders a fixed top bar that fills left-to-right as the page
445 /// scrolls — no hand-written JavaScript. Include the plugin stylesheet
446 /// once per page for the default styling:
447 ///
448 /// ```rust
449 /// use autumn_plugin_motion::{Motion, motion_stylesheet};
450 /// use autumn_web::{Markup, html};
451 ///
452 /// let head: Markup = html! { (motion_stylesheet()) };
453 /// assert!(head.into_string().contains("/static/_plugins/motion/motion."));
454 /// let bar: Markup = Motion::scroll_progress();
455 /// let s = bar.into_string();
456 /// assert!(s.contains(r#"data-motion="scroll-progress""#), "{s}");
457 /// assert!(s.contains(r#"class="motion-progress""#), "{s}");
458 /// ```
459 #[must_use]
460 pub fn scroll_progress() -> Markup {
461 html! {
462 div class="motion-progress" data-motion="scroll-progress" {}
463 }
464 }
465
466 /// Builds a [`Motion`] from a raw preset.
467 const fn preset(preset: Preset) -> Self {
468 Self {
469 preset,
470 delay_ms: 0,
471 duration_secs: None,
472 ease: None,
473 repeat: None,
474 repeat_type: None,
475 amount: None,
476 margin: None,
477 scroll_target: None,
478 scroll_offset: None,
479 parallax_factor: None,
480 stagger_from: None,
481 stagger_ease: None,
482 hover_scale: None,
483 hover_brightness: None,
484 press_scale: None,
485 once: true,
486 stagger_ms: None,
487 scroll: false,
488 }
489 }
490
491 /// Start delay in milliseconds.
492 #[must_use]
493 pub const fn delay(mut self, ms: u32) -> Self {
494 self.delay_ms = ms;
495 self
496 }
497
498 /// Duration in seconds (default 0.6).
499 #[must_use]
500 pub const fn duration(mut self, secs: f32) -> Self {
501 self.duration_secs = Some(secs);
502 self
503 }
504
505 /// Easing curve (default: a snappy `cubic-bezier(0.16,1,0.3,1)` applied
506 /// by `init.js` when the attribute is absent).
507 ///
508 /// ```rust
509 /// use autumn_plugin_motion::{Ease, Motion};
510 /// use autumn_web::{Markup, html};
511 ///
512 /// let html: Markup = Motion::fade_up()
513 /// .ease(Ease::Out)
514 /// .wrap(html! { p { "Hi" } });
515 /// assert!(html.into_string().contains(r#"data-motion-ease="ease-out""#));
516 /// ```
517 #[must_use]
518 pub const fn ease(mut self, ease: Ease) -> Self {
519 self.ease = Some(ease);
520 self
521 }
522
523 /// Repeat the animation `n` times after the first play (so `repeat(2)`
524 /// plays three times total). Useful for pulsing badges and attention
525 /// loops.
526 ///
527 /// ```rust
528 /// use autumn_plugin_motion::Motion;
529 /// use autumn_web::{Markup, html};
530 ///
531 /// let html: Markup = Motion::scale().repeat(2).wrap(html! { p { "Hi" } });
532 /// assert!(html.into_string().contains(r#"data-motion-repeat="2""#));
533 /// ```
534 #[must_use]
535 pub const fn repeat(mut self, n: u32) -> Self {
536 self.repeat = Some(n);
537 self
538 }
539
540 /// How each repeat cycle restarts: [`RepeatType::Loop`] (default),
541 /// [`RepeatType::Reverse`], or [`RepeatType::Mirror`] (ping-pong).
542 #[must_use]
543 pub const fn repeat_type(mut self, repeat_type: RepeatType) -> Self {
544 self.repeat_type = Some(repeat_type);
545 self
546 }
547
548 /// How much of the element must be visible before the `inView`
549 /// animation triggers: [`InViewAmount::Some`] (default),
550 /// [`InViewAmount::All`], or [`InViewAmount::Fraction`]. A bare `f32`
551 /// works too, via `From`:
552 ///
553 /// ```rust
554 /// use autumn_plugin_motion::{InViewAmount, Motion};
555 /// use autumn_web::{Markup, html};
556 ///
557 /// let a: Markup = Motion::fade_up().amount(0.5).wrap(html! { p { "Hi" } });
558 /// assert!(a.into_string().contains(r#"data-motion-amount="0.5""#));
559 /// let b: Markup = Motion::fade_up()
560 /// .amount(InViewAmount::All)
561 /// .wrap(html! { p { "Hi" } });
562 /// assert!(b.into_string().contains(r#"data-motion-amount="all""#));
563 /// ```
564 #[must_use]
565 pub fn amount(mut self, amount: impl Into<InViewAmount>) -> Self {
566 self.amount = Some(amount.into());
567 self
568 }
569
570 /// Grow or shrink the viewport used for the `inView` trigger, in CSS
571 /// margin syntax with `px` or `%` values — e.g. `"-100px"` waits until
572 /// the element is 100px inside the viewport, `"80px"` fires early.
573 /// Up to four space-separated values, like CSS `margin`.
574 ///
575 /// ```rust
576 /// use autumn_plugin_motion::Motion;
577 /// use autumn_web::{Markup, html};
578 ///
579 /// let html: Markup = Motion::fade_up().margin("-100px").wrap(html! { p { "Hi" } });
580 /// assert!(html.into_string().contains(r#"data-motion-margin="-100px""#));
581 /// ```
582 #[must_use]
583 pub fn margin(mut self, margin: impl Into<String>) -> Self {
584 self.margin = Some(margin.into());
585 self
586 }
587
588 /// When `false`, re-animate every time the element enters the viewport
589 /// instead of only the first time.
590 #[must_use]
591 pub const fn once(mut self, once: bool) -> Self {
592 self.once = once;
593 self
594 }
595
596 /// Cascade the preset over the wrapped element's direct children, `ms`
597 /// milliseconds apart.
598 #[must_use]
599 pub const fn stagger(mut self, ms: u32) -> Self {
600 self.stagger_ms = Some(ms);
601 self
602 }
603
604 /// Where the staggered cascade starts: [`StaggerFrom::First`] (default),
605 /// [`StaggerFrom::Last`], [`StaggerFrom::Center`],
606 /// [`StaggerFrom::Edges`], or [`StaggerFrom::Index`]. Only applies with
607 /// [`.stagger(ms)`](Motion::stagger).
608 ///
609 /// ```rust
610 /// use autumn_plugin_motion::{Motion, StaggerFrom};
611 /// use autumn_web::{Markup, html};
612 ///
613 /// let html: Markup = Motion::fade_up()
614 /// .stagger(90)
615 /// .stagger_from(StaggerFrom::Center)
616 /// .wrap(html! { p { "Hi" } });
617 /// assert!(
618 /// html.into_string().contains(r#"data-motion-stagger-from="center""#)
619 /// );
620 /// ```
621 #[must_use]
622 pub const fn stagger_from(mut self, from: StaggerFrom) -> Self {
623 self.stagger_from = Some(from);
624 self
625 }
626
627 /// Ease the stagger distribution across children — e.g. [`Ease::Out`]
628 /// bunches later children together. Same value syntax as
629 /// [`.ease()`](Motion::ease); only applies with
630 /// [`.stagger(ms)`](Motion::stagger).
631 #[must_use]
632 pub const fn stagger_ease(mut self, ease: Ease) -> Self {
633 self.stagger_ease = Some(ease);
634 self
635 }
636
637 /// Drive the keyframes by scroll progress as the element travels through
638 /// the viewport, instead of animating on entry.
639 #[must_use]
640 pub const fn scroll(mut self) -> Self {
641 self.scroll = true;
642 self
643 }
644
645 /// Drive the scroll-linked keyframes by *another* element's traversal
646 /// of the viewport — a CSS selector like `"#hero"`. When the selector
647 /// matches nothing, the element itself is used.
648 ///
649 /// Implies [`Motion::scroll`].
650 ///
651 /// ```rust
652 /// use autumn_plugin_motion::Motion;
653 /// use autumn_web::{Markup, html};
654 ///
655 /// let html: Markup = Motion::fade()
656 /// .scroll_target("#hero")
657 /// .wrap(html! { p { "Hi" } });
658 /// let s = html.into_string();
659 /// assert!(s.contains(r#"data-motion-scroll-target="#), "{s}");
660 /// assert!(s.contains("#hero"), "{s}");
661 /// assert!(s.contains("data-motion-scroll"), "{s}");
662 /// ```
663 #[must_use]
664 pub fn scroll_target(mut self, selector: impl Into<String>) -> Self {
665 self.scroll_target = Some(selector.into());
666 self.scroll = true;
667 self
668 }
669
670 /// Remap the scroll-linked keyframes onto custom viewport edges, e.g.
671 /// `["start end", "center center"]` finishes the animation when the
672 /// element's center reaches the viewport's center (default
673 /// `["start end", "end start"]`: the full traversal).
674 ///
675 /// Each entry names a target edge and a container edge
676 /// (`start`/`center`/`end`, or a number with optional `px`/`%`).
677 /// Implies [`Motion::scroll`].
678 ///
679 /// ```rust
680 /// use autumn_plugin_motion::Motion;
681 /// use autumn_web::{Markup, html};
682 ///
683 /// let html: Markup = Motion::fade()
684 /// .scroll_offset(["start end", "center center"])
685 /// .wrap(html! { p { "Hi" } });
686 /// assert!(
687 /// html.into_string().contains(
688 /// r#"data-motion-scroll-offset="start end,center center""#
689 /// )
690 /// );
691 /// ```
692 #[must_use]
693 pub fn scroll_offset(mut self, offset: [&str; 2]) -> Self {
694 self.scroll_offset = Some([offset[0].into(), offset[1].into()]);
695 self.scroll = true;
696 self
697 }
698
699 /// Grow to `scale` while hovered (e.g. `1.05` for a subtle lift).
700 /// The element eases back on pointer leave. Pairs with entrance
701 /// presets; combines with [`Motion::hover_brightness`].
702 ///
703 /// ```rust
704 /// use autumn_plugin_motion::Motion;
705 /// use autumn_web::{Markup, html};
706 ///
707 /// let html: Markup = Motion::fade_up()
708 /// .hover_scale(1.05)
709 /// .hover_brightness(1.1)
710 /// .wrap(html! { p { "Hi" } });
711 /// let s = html.into_string();
712 /// assert!(s.contains(r#"data-motion-hover="scale(1.05) brightness(1.1)""#), "{s}");
713 /// ```
714 #[must_use]
715 pub const fn hover_scale(mut self, scale: f32) -> Self {
716 self.hover_scale = Some(scale);
717 self
718 }
719
720 /// Brighten to `brightness` while hovered (e.g. `1.1`). Combines with
721 /// [`Motion::hover_scale`].
722 #[must_use]
723 pub const fn hover_brightness(mut self, brightness: f32) -> Self {
724 self.hover_brightness = Some(brightness);
725 self
726 }
727
728 /// Shrink to `scale` while pressed (e.g. `0.97` for a tactile button).
729 /// Releases back on pointer up.
730 ///
731 /// ```rust
732 /// use autumn_plugin_motion::Motion;
733 /// use autumn_web::{Markup, html};
734 ///
735 /// let html: Markup = Motion::fade_up().press_scale(0.97).wrap(html! { p { "Hi" } });
736 /// assert!(
737 /// html.into_string().contains(r#"data-motion-press="scale(0.97)""#)
738 /// );
739 /// ```
740 #[must_use]
741 pub const fn press_scale(mut self, scale: f32) -> Self {
742 self.press_scale = Some(scale);
743 self
744 }
745
746 /// Wraps `markup` in a `<div>` carrying the animation attributes.
747 ///
748 /// This is the Maud-friendly composition path — no attribute splicing,
749 /// just a wrapper element:
750 ///
751 /// ```rust
752 /// use autumn_plugin_motion::Motion;
753 /// use autumn_web::html;
754 ///
755 /// let page = html! {
756 /// (Motion::fade_up().delay(100).wrap(html! { p { "Hi" } }))
757 /// };
758 /// ```
759 ///
760 /// Takes `markup` by value even though Maud only borrows it: the owned
761 /// form is the ergonomic call shape (`.wrap(html! { ... })`).
762 #[allow(clippy::needless_pass_by_value)]
763 #[must_use]
764 pub fn wrap(self, markup: Markup) -> Markup {
765 let preset = self.preset.attr();
766 let delay = self.delay_ms.ne(&0).then(|| self.delay_ms.to_string());
767 let duration = self
768 .duration_secs
769 .filter(|d| *d != DEFAULT_DURATION_SECS)
770 .map(|d| d.to_string());
771 let once = (!self.once).then_some("false");
772 let stagger = self.stagger_ms.map(|ms| ms.to_string());
773 let scroll = self.scroll;
774 let ease = self.ease.map(Ease::attr_value);
775 let repeat = self.repeat.map(|n| n.to_string());
776 let repeat_type = self.repeat_type.map(RepeatType::attr);
777 let amount = self.amount.map(InViewAmount::attr_value);
778 let margin = self.margin;
779 let scroll_target = self.scroll_target;
780 let scroll_offset = self.scroll_offset.map(|[a, b]| format!("{a},{b}"));
781 let parallax = self.parallax_factor.map(|f| format!("{f}"));
782 let stagger_from = self.stagger_from.map(StaggerFrom::attr_value);
783 let stagger_ease = self.stagger_ease.map(Ease::attr_value);
784 let mut hover_parts = Vec::new();
785 if let Some(scale) = self.hover_scale {
786 hover_parts.push(format!("scale({scale})"));
787 }
788 if let Some(brightness) = self.hover_brightness {
789 hover_parts.push(format!("brightness({brightness})"));
790 }
791 let hover = (!hover_parts.is_empty()).then(|| hover_parts.join(" "));
792 let press = self.press_scale.map(|scale| format!("scale({scale})"));
793 html! {
794 div data-motion=(preset)
795 data-motion-delay=[delay.as_deref()]
796 data-motion-duration=[duration.as_deref()]
797 data-motion-ease=[ease.as_deref()]
798 data-motion-repeat=[repeat.as_deref()]
799 data-motion-repeat-type=[repeat_type]
800 data-motion-amount=[amount.as_deref()]
801 data-motion-margin=[margin.as_deref()]
802 data-motion-parallax=[parallax.as_deref()]
803 data-motion-scroll-target=[scroll_target.as_deref()]
804 data-motion-scroll-offset=[scroll_offset.as_deref()]
805 data-motion-stagger-from=[stagger_from.as_deref()]
806 data-motion-stagger-ease=[stagger_ease.as_deref()]
807 data-motion-hover=[hover.as_deref()]
808 data-motion-press=[press.as_deref()]
809 data-motion-once=[once]
810 data-motion-stagger=[stagger.as_deref()]
811 data-motion-scroll[scroll]
812 {
813 (markup)
814 }
815 }
816 }
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822
823 /// Renders a [`Motion`] wrapping empty markup.
824 fn render(motion: Motion) -> String {
825 motion.wrap(html! {}).into_string()
826 }
827
828 #[test]
829 fn all_presets_render_their_attribute() {
830 let cases = [
831 (Motion::fade_up(), "fade-up"),
832 (Motion::fade_down(), "fade-down"),
833 (Motion::fade_left(), "fade-left"),
834 (Motion::fade_right(), "fade-right"),
835 (Motion::fade(), "fade"),
836 (Motion::scale(), "scale"),
837 (Motion::zoom_in(), "zoom-in"),
838 (Motion::zoom_out(), "zoom-out"),
839 (Motion::slide_left(), "slide-left"),
840 (Motion::slide_right(), "slide-right"),
841 (Motion::slide_up(), "slide-up"),
842 (Motion::slide_down(), "slide-down"),
843 (Motion::rotate_in(), "rotate-in"),
844 (Motion::blur_in(), "blur-in"),
845 ];
846 for (motion, attr) in cases {
847 let html = render(motion);
848 assert!(
849 html.contains(&format!(r#"data-motion="{attr}""#)),
850 "preset renders data-motion=\"{attr}\": {html}"
851 );
852 }
853 }
854
855 #[test]
856 fn defaults_omit_optional_attributes() {
857 let html = render(Motion::fade_up());
858 for attr in [
859 "data-motion-delay",
860 "data-motion-duration",
861 "data-motion-ease",
862 "data-motion-repeat",
863 "data-motion-amount",
864 "data-motion-margin",
865 "data-motion-parallax",
866 "data-motion-scroll-target",
867 "data-motion-scroll-offset",
868 "data-motion-stagger-from",
869 "data-motion-stagger-ease",
870 "data-motion-hover",
871 "data-motion-press",
872 "data-motion-once",
873 "data-motion-stagger",
874 "data-motion-scroll",
875 ] {
876 assert!(!html.contains(attr), "default omits {attr}: {html}");
877 }
878 }
879
880 #[test]
881 fn modifiers_render_their_attributes() {
882 let html = render(
883 Motion::scale()
884 .delay(150)
885 .duration(1.2)
886 .once(false)
887 .stagger(60),
888 );
889 assert!(html.contains(r#"data-motion-delay="150""#), "{html}");
890 assert!(html.contains(r#"data-motion-duration="1.2""#), "{html}");
891 assert!(html.contains(r#"data-motion-once="false""#), "{html}");
892 assert!(html.contains(r#"data-motion-stagger="60""#), "{html}");
893 }
894
895 #[test]
896 fn default_duration_is_omitted() {
897 let html = render(Motion::fade().duration(DEFAULT_DURATION_SECS));
898 assert!(
899 !html.contains("data-motion-duration"),
900 "explicit default duration is omitted: {html}"
901 );
902 }
903
904 #[test]
905 fn ease_renders_its_attribute() {
906 let cases = [
907 (Ease::Linear, r#"data-motion-ease="linear""#),
908 (Ease::Out, r#"data-motion-ease="ease-out""#),
909 (Ease::InOut, r#"data-motion-ease="ease-in-out""#),
910 (Ease::CircOut, r#"data-motion-ease="circ-out""#),
911 (Ease::BackIn, r#"data-motion-ease="back-in""#),
912 (Ease::Anticipate, r#"data-motion-ease="anticipate""#),
913 (
914 Ease::CubicBezier(0.16, 1.0, 0.3, 1.0),
915 r#"data-motion-ease="cubic-bezier(0.16,1,0.3,1)""#,
916 ),
917 (
918 Ease::Spring {
919 stiffness: 300.0,
920 damping: 20.0,
921 mass: 1.0,
922 },
923 r#"data-motion-ease="spring(300,20,1)""#,
924 ),
925 ];
926 for (ease, attr) in cases {
927 let html = render(Motion::fade_up().ease(ease));
928 assert!(html.contains(attr), "ease renders {attr}: {html}");
929 }
930 }
931
932 #[test]
933 fn repeat_renders_its_attributes() {
934 let html = render(Motion::scale().repeat(2).repeat_type(RepeatType::Mirror));
935 assert!(html.contains(r#"data-motion-repeat="2""#), "{html}");
936 assert!(
937 html.contains(r#"data-motion-repeat-type="mirror""#),
938 "{html}"
939 );
940 }
941
942 #[test]
943 fn amount_variants_render() {
944 let html = render(Motion::fade_up().amount(0.5));
945 assert!(html.contains(r#"data-motion-amount="0.5""#), "{html}");
946 let html = render(Motion::fade_up().amount(InViewAmount::All));
947 assert!(html.contains(r#"data-motion-amount="all""#), "{html}");
948 let html = render(Motion::fade_up().amount(InViewAmount::Some));
949 assert!(html.contains(r#"data-motion-amount="some""#), "{html}");
950 }
951
952 #[test]
953 fn amount_fraction_clamps_to_unit_range() {
954 let html = render(Motion::fade_up().amount(1.5));
955 assert!(html.contains(r#"data-motion-amount="1""#), "{html}");
956 let html = render(Motion::fade_up().amount(-0.2));
957 assert!(html.contains(r#"data-motion-amount="0""#), "{html}");
958 }
959
960 #[test]
961 fn margin_renders() {
962 let html = render(Motion::fade_up().margin("-100px"));
963 assert!(html.contains(r#"data-motion-margin="-100px""#), "{html}");
964 }
965
966 #[test]
967 fn parallax_renders_kind_and_factor() {
968 let html = render(Motion::parallax(0.3));
969 assert!(html.contains(r#"data-motion="parallax""#), "{html}");
970 assert!(html.contains(r#"data-motion-parallax="0.3""#), "{html}");
971 }
972
973 #[test]
974 fn scroll_target_renders_and_implies_scroll() {
975 let html = render(Motion::fade().scroll_target("#hero"));
976 assert!(html.contains("data-motion-scroll-target"), "{html}");
977 assert!(html.contains("#hero"), "{html}");
978 assert!(html.contains("data-motion-scroll"), "{html}");
979 }
980
981 #[test]
982 fn scroll_offset_renders_comma_separated() {
983 let html = render(Motion::fade().scroll_offset(["start end", "center center"]));
984 assert!(
985 html.contains(r#"data-motion-scroll-offset="start end,center center""#),
986 "{html}"
987 );
988 }
989
990 #[test]
991 fn scroll_progress_renders_opinionated_bar() {
992 let html = Motion::scroll_progress().into_string();
993 assert!(html.contains(r#"data-motion="scroll-progress""#), "{html}");
994 assert!(html.contains(r#"class="motion-progress""#), "{html}");
995 }
996
997 #[test]
998 fn stagger_from_and_ease_render() {
999 let html = render(
1000 Motion::fade_up()
1001 .stagger(90)
1002 .stagger_from(StaggerFrom::Center)
1003 .stagger_ease(Ease::Out),
1004 );
1005 assert!(html.contains(r#"data-motion-stagger="90""#), "{html}");
1006 assert!(
1007 html.contains(r#"data-motion-stagger-from="center""#),
1008 "{html}"
1009 );
1010 assert!(
1011 html.contains(r#"data-motion-stagger-ease="ease-out""#),
1012 "{html}"
1013 );
1014 }
1015
1016 #[test]
1017 fn stagger_from_index_renders_number() {
1018 let html = render(Motion::fade_up().stagger_from(StaggerFrom::Index(2)));
1019 assert!(html.contains(r#"data-motion-stagger-from="2""#), "{html}");
1020 }
1021
1022 #[test]
1023 fn hover_and_press_render() {
1024 let html = render(
1025 Motion::fade_up()
1026 .hover_scale(1.05)
1027 .hover_brightness(1.1)
1028 .press_scale(0.97),
1029 );
1030 assert!(
1031 html.contains(r#"data-motion-hover="scale(1.05) brightness(1.1)""#),
1032 "{html}"
1033 );
1034 assert!(
1035 html.contains(r#"data-motion-press="scale(0.97)""#),
1036 "{html}"
1037 );
1038 }
1039
1040 #[test]
1041 fn scroll_renders_bare_attribute() {
1042 let html = render(Motion::fade_up().scroll());
1043 assert!(
1044 html.contains("data-motion-scroll"),
1045 "scroll renders the bare attribute: {html}"
1046 );
1047 }
1048
1049 #[test]
1050 fn wrap_keeps_inner_markup() {
1051 let html = Motion::fade_up()
1052 .wrap(html! { p { "inner text" } })
1053 .into_string();
1054 assert!(html.starts_with("<div"), "{html}");
1055 assert!(html.contains("<p>inner text</p>"), "{html}");
1056 assert!(html.contains(r#"data-motion="fade-up""#), "{html}");
1057 }
1058}