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 MotionPlugin;
app
.plugin
.run
.await;
Put the scripts in your layout (both are deferred and SRI-hashed):
use motion_script;
use ;
Animate with the typed builder — it wraps your markup in an animated
<div>, no attribute splicing:
use Motion;
let page = layout;
Or write the attributes directly on your own elements:
let list = html! ;
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:
async
Load more
Scroll-linked effects work the same way:
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.
Parallax drift and the opinionated progress bar:
use ;
// In <head>, once per page:
// Hero art that lags the scroll:
// A fixed top bar that fills with page scroll — zero hand-written JS:
Demo
examples/motion_demo.rs is a tiny runnable app showing all of it:
# 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 isscript-src 'self'(nounsafe-inline), so inline scripts are blocked. Put JS in external files under the app'sstatic/dir and reference them withasset_url("js/app.js"). - Never inline
<style>either. In nonce mode (security.headers.csp_nonce)style-srcbecomes'self'+ nonce only, so inline styles break. Same answer: external stylesheet viaasset_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 withautumn assets add htmx@…). - Embed the app's
static/dir withautumn_web::embed_static!()and hand it toAppBuilder::embedded_static(&STATIC);$CARGO_MANIFEST_DIRis the crate root even for examples, so this works fromexamples/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 ;
let bouncy = scale
.ease
.wrap;
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: reducedisables all animations; content stays fully visible. A single element can opt back in withdata-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-motionon the children too.
How it works
assets/motion.min.js— the upstream minified Motion UMD build (12.43.0, from jsDelivr), renamed fromdist/motion.js. Setswindow.Motion.assets/init.js— the plugin-authored scanner. Reads the attributes above, callsMotion.animate/inView/scroll/stagger/hover/press.assets/motion.css— default styles forMotion::scroll_progress().assets/manifest.json— vendoring provenance: Motion version pin, source URL, and thesha384of the upstream file. Not served.- The three served files form one
PluginAssetsbundle (MOTION_ASSETS), whichMotionPlugininstalls with Autumn'sAppBuilder::plugin_assetsseam (autumn-web 0.8+). The framework serves each file under/static/_plugins/motion/:- at a content-hashed URL (
init.<sha256-prefix>.js) withCache-Control: public, max-age=31536000, immutable, and - at its plain URL (
init.js) withmust-revalidate, - both with
ETag/304andRangesupport.
- at a content-hashed URL (
motion_script()/motion_stylesheet()emit the hashed URLs withintegrity=hashes the framework computes from the embedded bytes — no hand-kept SRI constants. The routes show up inautumn routesas public, plugin-attributed static files.- Need a URL yourself?
MOTION_ASSETS.url("init.js"), orasset_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 usemotion_script()/motion_stylesheet(), nothing changes for you. Hand-written/__motion/...references must switch to those helpers,MOTION_ASSETS.url(..), orasset_url("_plugins/motion/.."). motion_routes()is gone —MotionPlugininstallsMOTION_ASSETS.INIT_JS_INTEGRITYandMOTION_CSS_INTEGRITYare gone — useMOTION_ASSETS.integrity("init.js")/("motion.css").MOTION_JS_INTEGRITYstays as the upstream provenance pin.- The plugin no longer turns on autumn-web's
embed-assetsfeature; 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 noautumn 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.Motiondirectly; 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.