15 KiB
| title | type | audience | status | source |
|---|---|---|---|---|
| Motion — implementation guide | guide | human + agent | current | migrated from src/uix/eidos/MOTION_GUIDE.md (2026-07-02, docs-book F7.3) |
Motion — implementation guide
The developer front door for animating UIX. Task-oriented: pick the recipe that matches what you're animating. For the model & engine architecture read
eidos-motion.md; for the design decisions & history (incl. the retired "coordinated" engine) readMOTION_SERVICE_RFC.md; for the engine API readsrc/arts/motion/README.md.
Animation in UIX is declarative CSS by default (no JS engine in the hot path) and one
selector — the motion prop, which maps to a data-animation-style attribute the generated
foundation CSS reacts to. A small physics engine (uix.motion) runs the JS drivers (spring)
when a preset asks for them. Every preset is reduced-motion-aware for free.
1. The model in a minute — three domains
The motion prop exists on (almost) anything, but it does NOT mean the same thing everywhere.
The discriminant is one question:
Does the animation realize a perceptual EVENT — something appears / is committed / claims attention / is still in progress?
| Domain | Triggered by | Owned by | motion is… |
|---|---|---|---|
| event | data-event-* (during a sema signal's hold) |
morfo (declares it) + sema (the firma: + sound/haptic) | an explicit override of the event's signature (or a violation if it should be an event) |
| state | data-state (open/closed, selected…) |
the state-preset (recipe) | selects which preset reacts to the state |
| content | nothing — it runs on mount / forever | the prop, directly | the primary trigger (no event, no state to compose with) |
A spinner is event (sustain — "a live process"); a pulsing logo is content (it
communicates nothing operational). Same SVG, different domain — the question separates them.
The content domain is a usage pattern over the state-preset machinery: motionAttrs /
<Motion> set a constant presentation data-state="open" so the existing enter rule plays.
So at the engine level there are still two moments (event-signatures + state-presets, see
eidos-motion.md); "three domains" is the usage framing.
2. The motion selector
<Popover.Content motion="scale-fade">…</Popover.Content>
<!-- a component prop -->
<div {...motionAttrs('fade')}>…</div>
<!-- a bare element -->
<Motion motion="spring-pop">…</Motion>
<!-- a content wrapper -->
- The value is a registered preset name. Built-ins autocomplete + type-check via the
augmentable
EidosMotionPresetsregistry (lib/motion/registry.ts); an app adds names by declaration-merging it.'none'(orundefined) disables — the explicit opt-out. - Under the hood the wrapper writes
data-animation-style="<preset>"; the generated CSS (generated/base.css) animates on it. The engine only gets involved for JS presets.
3. The preset catalog
| Preset | Kind | For | Notes |
|---|---|---|---|
fade |
enter/exit | the safe default | opacity only — reduced-motion-safe by nature |
scale-fade |
enter/exit | popovers, dialogs, content | scale + fade; transform-origin aware |
slide-fade |
enter/exit | anchored panels (menus, tooltips) | side-aware via data-side |
slide-full |
enter/exit | drawers / sheets | full slide by edge (inset-aware) |
collapse |
enter/exit | accordions / disclosure | animates a measured var(--height) |
shared-axis-x / -y |
enter/exit | spatial navigation (Material) | directional slide + fade |
fade-through |
enter/exit | unrelated content swap (Material) | scale-up + fade |
select-pop |
emphasis (state domain) | a "becoming selected" confirmation | scale pop, no opacity (stays visible) |
spin · pulse · ping · bounce |
loop (content domain, infinite) | loaders · skeletons · notifications · cues | run forever while present; themeable per loop |
spring-pop |
JS (spring driver) |
a physical bounce on an overlay | runs via uix.motion, not CSS |
Presence presets (fade/scale-fade/…) go 0→1 — they appear/disappear. Never use one
in the state domain (a still-visible element would flash from invisible); use an emphasis
preset (select-pop). Loops never enter/exit — they animate continuously.
4. Recipes — "how do I…"
Animate content in & out (mount / unmount)
Wrap it in <Motion> — enter on mount, exit on removal (it retains the node for the preset's
declared duration, then unmounts):
{#if show}
<Motion motion="scale-fade"><div class="card">…</div></Motion>
{/if}
No extra node wanted (inline content, enter only)? Spread motionAttrs on your own element:
<span {...motionAttrs('fade')}>…</span>
See components/motion/README.md.
Loop something forever
Use a loop preset on any element (loops are un-gated — they need no enter/exit lifecycle):
<svg {...motionAttrs('spin')} style="transform-origin:center">…</svg>
spin/pulse/bounce rotate/fade/translate the element; ping scales+fades a ring (put it
on the ring child, with transform-box: fill-box). <Motion motion="spin"> also works —
it unmounts promptly (a loop has no exit).
Animate a stateful component's transition
A component with its OWN data-state machine (Card selected/idle, Switch on/off) drives
a preset via a dedicated data-motion-state, so the preset fires on its transition without
clobbering the semantic data-state. The pilot is <Card motion="select-pop"> — it pops once
each time it becomes selected (animate-on, snap-off; mount-guarded). To wire a new one: keep
your data-state, emit data-animation-style + data-motion-state="open" when the active
transition happens (see components/card/card.svelte).
Stagger a list IN and OUT
The container-driven cascade: mark the container [data-stagger], give items a preset, set the
rhythm. Children inherit the container's data-state — ENTER cascades in (first item first),
EXIT cascades out in reverse (last leaves first):
<div data-stagger data-state={open ? 'open' : 'closed'} style="--motion-stagger-each: 65ms">
{#each items as item}<div data-animation-style="slide-fade">{item}</div>{/each}
</div>
Use <Cascade {open} motion="slide-fade"> (see components/cascade/README.md)
for the explicit wrapper, which manages the open/closed state + retention for you. Exit
caveat: the container must stay in the DOM during the stagger window — give it a longer exit
or retain+unmount after N × each + exit. This is the §D.13.2 pragmatic CSS bridge for
bounded lists; the "parent waits for every child" version needs JS lifecycle (deferred).
An overlay's enter/exit
Automatic. Dialog / Drawer / Popover / … animate via soma's Presence + the component's motion
prop. You only pick the preset: <Dialog.Content motion="scale-fade">.
A physical spring
Use the built-in spring-pop, or author your own JS preset. JS presets hold functions, so they
register directly in ActiveEidos (not the serializable EidosConfig.motion config):
import { spring } from '$motion';
uix.motion.register('pop', {
driver: 'spring',
enter: spring({ values: { scale: [0.8, 1], opacity: [0, 1] }, stiffness: 300, damping: 14 }),
reduce: 'opacity-only'
});
// + declaration-merge `EidosMotionPresets` to make `motion="pop"` type-check.
Add a custom CSS preset (theme / app)
CSS presets are plain serializable data — add them to the config:
defineActiveEidos({
motion: {
keyframes: { 'my-in': { from: { opacity: '0' }, to: { opacity: '1' } } },
presets: { 'my-fade': { driver: 'css', enter: { keyframes: 'my-in' }, reduce: 'none' } }
}
});
5. Reduced motion — free
A developer gets reduced-motion handling without writing anything:
- The pref (
motion: system | allow | reduce) folds the OSprefers-reduced-motionand is projected asdata-motion="reduce"on<html>(arts/prefs), before the first paint. - Every CSS preset declares a
reducepolicy (none/opacity-only/instant); the generator emits[data-motion='reduce'] …overrides and nothing else — eidos does not read media, it reads the attribute. The pref is what folds the OS hint (an explicitallowOVERRULES it), so a@media (prefers-reduced-motion)alongside would be a second source still killing the animation of a user who asked for it. Guarded bysrc/uix/eidos/reduced-motion-media.test.ts. - Loops stop under reduce (the element rests in its static frame).
- JS springs honor it:
spring()snaps to the target and settles — no physics — whenctx.reduced(a spring IS the overshoot curve, so under reduce there must be none). - Content via
motionAttrs/<Motion>is reduced-motion-safe for free (the overrides key ondata-state, which it already sets).
6. Theming & tokens
Retint timing without touching a component:
| Token | Controls |
|---|---|
--duration-{fast,moderate,slow,…,sustained} |
the canonical duration scale |
--motion-loop-{spin,pulse,ping,bounce} |
each loop's period (declared in :root) |
--motion-stagger-each |
the stagger rhythm (set on the [data-stagger] container) |
--motion-duration-{enter,exit} · --motion-ease-{enter,exit} |
per-component override on an animated element (falls back to the preset's token) |
[data-motion-set='expressive'] |
remaps the easing curves (Carbon productive ↔ expressive) |
7. Debugging
Set [data-debug-stagger] on a stagger container: every animated child gets a corner badge
with its :nth-child - 1 index (the CSS analog of UIX_DEBUG_MOTION). Opt-in → zero cost
without the attr. The stagger index (--motion-stagger-index), animation-delay and
animation-name are also all inspectable in DevTools' Computed pane (@property-typed).
8. Constraints & gotchas
- One
data-animation-styleper element. You can't loop AND enter/exit the same element with different presets — use nested elements (the demo loops an inner SVG inside a<Motion>). - The content domain reuses the state machinery —
motionAttrsstampsdata-state="open". Harmless for loops (their rule is un-gated), but don't read it as semantic state. - No-goals (deliberate): layout animations / shared-layout (the
rectdriver measures ONE node). WebGL/canvas "ambient" backgrounds were a no-goal here until 2026-07-12 — they now have their own home OUTSIDE the motion system: thearts/sceneruntime + theAmbientpack (seearchitecture/packs.md). They remain out of scope for themotionprop / preset registry — a scene is not a preset. - Scroll-linked travel is not a preset either.
Background's parallax (speed/depth/attach) ridesanimation-timeline: view()locally in its own recipe, with aScrollProgressfallback where the engine lacks it — deliberately NOT atimelineaxis in the preset registry. A preset names a discrete transition with a duration; this is continuous modulation with no duration at all, driven by a scroll position rather than by a state change or an event. The domain is a candidate for the motion service only if a SECOND consumer appears (MOTION_SERVICE_RFC); one component is not a service. Seeeidos/components/background/README§Parallax. - Text effects are content animations, not motion presets. The
text-effects family (
TextGradient,TextCircular,TextBlur,TextFocus,TextScramble) animates real text content — each owns its own mechanism (CSS keyframes / rAF / WAAPI) rather than themotionprop.TextBlur's staggered entrance is the one that most overlaps this domain: it uses WAAPI directly today (the same primitive thewaapidriver wraps) and is the natural candidate to migrate behind the motion service's content domain whenMOTION_SERVICE_RFClands. Until then, a content entrance is not a preset.
9. Where the code lives
| Concern | Path |
|---|---|
| Engine (registry, run, drivers) | src/arts/motion/ ($motion) |
| Preset/keyframe data + the registry | src/uix/eidos/lib/motion/ |
| CSS generation (presets, loops, stagger, debug, reduce) | src/uix/eidos/lib/render-css.ts → generated/base.css |
motionAttrs / <Motion> / <Cascade> |
src/uix/eidos/lib/motion/motion-attrs.ts, components/{motion,cascade}/ |
| Presence (lifecycle + awaits JS motion) | src/uix/soma/layers/presence.svelte.ts |
| Reduced-motion projection | src/arts/prefs/dom-projection.ts, arts/adom |