autumn-plugin-motion 0.3.0

Autumn plugin: Motion (motion.dev) animations with Maud + htmx ergonomics.
Documentation

autumn-plugin-motion

Motion (motion.dev, vanilla JS, MIT) animations for Autumn apps, with Maud + htmx ergonomics. No npm, no bundler, no React: the plugin vendors the Motion UMD build into the crate and serves it from memory, and gives you a typed Rust builder plus a declarative data-motion attribute system.

Quickstart

Add the plugin:

use autumn_plugin_motion::MotionPlugin;

autumn_web::app()
    .plugin(MotionPlugin::new())
    .run()
    .await;

Put the scripts in your layout (both are deferred and SRI-hashed):

use autumn_plugin_motion::motion_script;
use autumn_web::{Markup, html};

fn layout(content: Markup) -> Markup {
    html! {
        html {
            head { (motion_script()) }
            body { (content) }
        }
    }
}

Animate with the typed builder — it wraps your markup in an animated <div>, no attribute splicing:

use autumn_plugin_motion::Motion;

let page = layout(html! {
    (Motion::fade_up().delay(100).wrap(html! {
        h1 { "Hello" }
    }))
});

Or write the attributes directly on your own elements:

let list = html! {
    ul data-motion="fade-up" data-motion-stagger="60" {
        li { "one" }
        li { "two" }
        li { "three" }
    }
};

htmx

This is where it shines. The init script re-scans on htmx:afterSwap, scoped to the swap target, so every htmx partial animates declaratively with zero extra JS:

#[get("/feed")]
async fn feed() -> Markup {
    html! {
        div id="feed" {
            // Each row fades up, staggered — including rows added later
            // by an htmx swap into #feed.
            (Motion::fade_up().stagger(50).wrap(rows()))
        }
    }
}
<button hx-get="/feed" hx-target="#feed" hx-swap="afterbegin">
    Load more
</button>

Scroll-linked effects work the same way:

(Motion::fade().scroll().wrap(html! {
    div class="scrub-me" { }
}))

Target another element's traversal, or remap the offsets:

// Fade as #hero crosses the viewport; finish when the element's center
// hits the viewport's center.
(Motion::fade()
    .scroll_target("#hero")
    .scroll_offset(["start end", "center center"])
    .wrap(markup))

Parallax drift and the opinionated progress bar:

use autumn_plugin_motion::{Motion, motion_stylesheet};

// In <head>, once per page:
(motion_stylesheet())

// Hero art that lags the scroll:
(Motion::parallax(0.3).wrap(markup))

// A fixed top bar that fills with page scroll — zero hand-written JS:
(Motion::scroll_progress())

Demo

examples/motion_demo.rs is a tiny runnable app showing all of it:

cargo run --example motion_demo
# then open http://127.0.0.1:3000

The page demonstrates a staggered hero entrance with parallax drift, scroll-reveal cards (one scrubbed by the scroll position itself), an htmx "Load more" button whose server-rendered batches stagger in via the htmx:afterSwap re-scan hook, and a scroll-driven progress bar (Motion::scroll_progress() — one line, zero hand-written JS).

Gotchas

Learned the hard way while building the demo — the rules for JS/CSS in Autumn apps:

  • Never inline <script>. The default CSP is script-src 'self' (no unsafe-inline), so inline scripts are blocked. Put JS in external files under the app's static/ dir and reference them with asset_url("js/app.js").
  • Never inline <style> either. In nonce mode (security.headers.csp_nonce) style-src becomes 'self' + nonce only, so inline styles break. Same answer: external stylesheet via asset_url("css/app.css").
  • htmx ships with the framework. Don't pull it from a CDN: asset_url("js/htmx.min.js") resolves to Autumn's built-in embedded htmx handler — no vendoring needed (it's skipped automatically if you pin your own copy with autumn assets add htmx@…).
  • Embed the app's static/ dir with autumn_web::embed_static!() and hand it to AppBuilder::embedded_static(&STATIC); $CARGO_MANIFEST_DIR is the crate root even for examples, so this works from examples/ too.

Attribute reference

Attribute Values Default Notes
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 fade-up The preset. Unknown values fall back to fade-up. parallax and scroll-progress are special scroll-driven kinds (see below), not entrance presets.
data-motion-delay milliseconds, e.g. 150 0 Start delay.
data-motion-duration seconds, e.g. 1.2 0.6 Animation duration.
data-motion-once false (once) Set to false to re-animate every time the element enters the viewport.
data-motion-stagger milliseconds, e.g. 60 — On a container: cascade the preset over its direct children.
data-motion-stagger-from first, last, center, edges, or a child index, e.g. 2 first Where the staggered cascade starts. Only applies with data-motion-stagger.
data-motion-stagger-ease same syntax as data-motion-ease — Ease the stagger distribution across children.
data-motion-hover e.g. scale(1.05) brightness(1.1) — Grow and/or brighten while hovered; eases back on leave.
data-motion-press e.g. scale(0.95) — Shrink while pressed; releases on pointer up.
data-motion-reduced animate — Opt back into animation when the user prefers reduced motion. Default: skip animation, content stays fully visible.
data-motion-scroll (bare attribute) — Drive the keyframes by scroll progress instead of animating on entry.
data-motion-scroll-target CSS selector, e.g. #hero (the element itself) Drive the scroll-linked keyframes by another element's traversal. Implies data-motion-scroll.
data-motion-scroll-offset comma-separated edges, e.g. start end,center center start end,end start Remap the scroll-linked keyframes onto custom viewport edges. Invalid entries are ignored.
data-motion-parallax factor, e.g. 0.3 0.3 With data-motion="parallax": scroll-linked drift of factor × the element's own height. Positive lags the scroll; negative opposes it.
data-motion-ease linear, ease-in, ease-out, ease-in-out, circ-in, circ-out, circ-in-out, back-in, back-out, back-in-out, anticipate, cubic-bezier(0.16,1,0.3,1), spring(300,20,1) cubic-bezier(0.16,1,0.3,1) Easing curve. spring(stiffness,damping,mass) uses spring physics.
data-motion-repeat times, e.g. 2 — Repeat the animation n times after the first play.
data-motion-repeat-type loop, reverse, mirror loop How each repeat cycle restarts.
data-motion-amount some, all, or a fraction 0–1, e.g. 0.5 some How much of the element must be visible before the inView trigger fires.
data-motion-margin CSS margin syntax in px/%, e.g. -100px, 80px — Grow/shrink the viewport for the inView trigger. Negative waits until the element is further inside; positive fires early. Invalid values are ignored.

The Rust Motion builder mirrors all of these: 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(), plus .delay(ms), .duration(secs), .ease(Ease::Out), .repeat(n), .repeat_type(RepeatType::Mirror), .amount(0.5) (or .amount(InViewAmount::All)), .margin("-100px"), .scroll_target("#hero"), .scroll_offset(["start end", "center center"]), .stagger_from(StaggerFrom::Center), .stagger_ease(Ease::Out), .hover_scale(1.05), .hover_brightness(1.1), .press_scale(0.97), .once(bool), .stagger(ms), .scroll(), and .wrap(markup). Motion::parallax(0.3) builds a scroll-drift wrapper and Motion::scroll_progress() renders the opinionated progress-bar element (pair it with motion_stylesheet() in <head>). The [Ease] enum covers named easings, Ease::CubicBezier(x1, y1, x2, y2), and Ease::Spring { stiffness, damping, mass }:

use autumn_plugin_motion::{Ease, Motion};

let bouncy = Motion::scale()
    .ease(Ease::Spring { stiffness: 300.0, damping: 20.0, mass: 1.0 })
    .wrap(markup);

Details:

  • Elements animate when they scroll into view (inView), not on page load — below-the-fold content waits its turn.
  • The scanner marks handled elements with data-motion-init, so htmx re-scans never double-animate.
  • prefers-reduced-motion: reduce disables all animations; content stays fully visible. A single element can opt back in with data-motion-reduced="animate". If Motion fails to load, the page is likewise untouched — animations are progressive enhancement, never a dependency.
  • Stagger containers animate their direct children; don't put data-motion on the children too.

How it works

  • assets/motion.min.js — the upstream minified Motion UMD build (12.43.0, from jsDelivr), renamed from dist/motion.js. Sets window.Motion.
  • assets/init.js — the plugin-authored scanner. Reads the attributes above, calls Motion.animate / inView / scroll / stagger / hover / press.
  • assets/motion.css — default styles for Motion::scroll_progress().
  • assets/manifest.json — vendoring provenance: Motion version pin, source URL, and the sha384 of the upstream file. Not served.
  • The three served files form one PluginAssets bundle (MOTION_ASSETS), which MotionPlugin installs with Autumn's AppBuilder::plugin_assets seam (autumn-web 0.8+). The framework serves each file under /static/_plugins/motion/:
    • at a content-hashed URL (init.<sha256-prefix>.js) with Cache-Control: public, max-age=31536000, immutable, and
    • at its plain URL (init.js) with must-revalidate,
    • both with ETag / 304 and Range support.
  • motion_script() / motion_stylesheet() emit the hashed URLs with integrity= hashes the framework computes from the embedded bytes — no hand-kept SRI constants. The routes show up in autumn routes as public, plugin-attributed static files.
  • Need a URL yourself? MOTION_ASSETS.url("init.js"), or asset_url("_plugins/motion/init.js") once the plugin is installed.

Upgrading from 0.1

0.2 requires autumn-web 0.8 and moves the assets onto the framework's plugin-asset seam:

  • Asset URLs moved from /__motion/* to /static/_plugins/motion/* (content-hashed). If you only use motion_script() / motion_stylesheet(), nothing changes for you. Hand-written /__motion/... references must switch to those helpers, MOTION_ASSETS.url(..), or asset_url("_plugins/motion/..").
  • motion_routes() is gone — MotionPlugin installs MOTION_ASSETS.
  • INIT_JS_INTEGRITY and MOTION_CSS_INTEGRITY are gone — use MOTION_ASSETS.integrity("init.js") / ("motion.css"). MOTION_JS_INTEGRITY stays as the upstream provenance pin.
  • The plugin no longer turns on autumn-web's embed-assets feature; enable it in your app if you rely on it.

Limits

  • Motion+ is not included. The paid motion.dev tier (splitText, Cursor, Carousel, Ticker, AnimateNumber, ScrambleText, Typewriter, …) needs a private-registry token and is deliberately out of scope — this plugin ships the MIT core only.
  • The Motion version is pinned per plugin release (see assets/manifest.json). Upgrading Motion means upgrading the plugin; there is no autumn assets-style version switch.
  • Declarative enter/scroll effects only. No layout animations, no shared-element transitions, no gesture handlers in the attribute layer — for bespoke choreography, call window.Motion directly; the global is there.
  • No live-browser animation verification. The init script's logic is covered by content/string tests only (hook presence, preset table, reduced-motion guard). Motion's own test suite covers the animation engine; this plugin covers the wiring.