# 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`](./eidos-motion.md); for the *design decisions & history* (incl. the > retired "coordinated" engine) read [`MOTION_SERVICE_RFC.md`](./MOTION_SERVICE_RFC.md); for > the *engine API* read [`src/arts/motion/README.md`](../../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` / `` 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 ```svelte …
…
… ``` - The value is a **registered preset name**. Built-ins autocomplete + type-check via the augmentable `EidosMotionPresets` registry (`lib/motion/registry.ts`); an app adds names by declaration-merging it. `'none'` (or `undefined`) disables — the explicit opt-out. - Under the hood the wrapper writes `data-animation-style=""`; 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 `` — enter on mount, exit on removal (it retains the node for the preset's declared duration, then unmounts): ```svelte {#if show}
…
{/if} ``` No extra node wanted (inline content, enter only)? Spread `motionAttrs` on your own element: ```svelte … ``` See [`components/motion/README.md`](./components/motion/README.md). ### Loop something forever Use a loop preset on any element (loops are un-gated — they need no enter/exit lifecycle): ```svelte … ``` `spin`/`pulse`/`bounce` rotate/fade/translate the element; `ping` scales+fades a ring (put it on the ring child, with `transform-box: fill-box`). `` 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 `` — 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): ```svelte
{#each items as item}
{item}
{/each}
``` Use `` (see [`components/cascade/README.md`](./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: ``. ### 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): ```ts 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: ```ts 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 OS `prefers-reduced-motion` and is projected as `data-motion="reduce"` on `` (`arts/prefs`). - Every CSS preset declares a `reduce` policy (`none` / `opacity-only` / `instant`); the generator emits `[data-motion='reduce'] …` overrides **and** a `@media (prefers-reduced-motion: reduce)` block, so it works with or without the JS projection. - **Loops stop** under reduce (the element rests in its static frame). - **JS springs honor it**: `spring()` snaps to the target and settles — no physics — when `ctx.reduced` (a spring IS the overshoot curve, so under reduce there must be none). - Content via `motionAttrs`/`` is reduced-motion-safe for free (the overrides key on `data-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-style` per 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 ``). - **The content domain reuses the state machinery** — `motionAttrs` stamps `data-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 `rect` driver measures ONE node), and WebGL "ambient" backgrounds (`demos/animations/background/*` — a separate axis). --- ## 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` / `` / `` | `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` |