|
|
---
|
|
|
title: Motion — implementation guide
|
|
|
type: guide
|
|
|
audience: human + agent
|
|
|
status: current
|
|
|
source: 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`](./motion.md); for the _design decisions & history_ (incl. the
|
|
|
> retired "coordinated" engine) read [`MOTION_SERVICE_RFC.md`](../../src/uix/eidos/MOTION_SERVICE_RFC.md); for
|
|
|
> the _engine API_ read [`src/arts/motion/README.md`](../../src/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
|
|
|
|
|
|
```svelte
|
|
|
<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 `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="<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):
|
|
|
|
|
|
```svelte
|
|
|
{#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:
|
|
|
|
|
|
```svelte
|
|
|
<span {...motionAttrs('fade')}>…</span>
|
|
|
```
|
|
|
|
|
|
See [`components/motion/README.md`](../../src/uix/eidos/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
|
|
|
<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):
|
|
|
|
|
|
```svelte
|
|
|
<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`](../../src/uix/eidos/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):
|
|
|
|
|
|
```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 `<html>` (`arts/prefs`), before the first paint.
|
|
|
- Every CSS preset declares a `reduce` policy (`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 explicit `allow`
|
|
|
OVERRULES 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 by
|
|
|
`src/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 — when
|
|
|
`ctx.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 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 `<Motion>`).
|
|
|
- **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). WebGL/canvas "ambient" backgrounds were a no-goal here until 2026-07-12 — they now
|
|
|
have their own home OUTSIDE the motion system: the `arts/scene` runtime + the `Ambient`
|
|
|
pack (see [`architecture/packs.md`](../architecture/packs.md)). They remain out of scope
|
|
|
for the `motion` prop / preset registry — a scene is not a preset.
|
|
|
- **Scroll-linked travel is not a preset either.** `Background`'s parallax
|
|
|
(`speed` / `depth` / `attach`) rides `animation-timeline: view()` locally in its own recipe,
|
|
|
with a `ScrollProgress` fallback where the engine lacks it — deliberately NOT a `timeline`
|
|
|
axis 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`](../../src/uix/eidos/MOTION_SERVICE_RFC.md)); one
|
|
|
component is not a service. See
|
|
|
[`eidos/components/background/README`](../../src/uix/eidos/components/background/README.md)
|
|
|
§Parallax.
|
|
|
- **Text effects are content animations, not motion presets.** The
|
|
|
[text-effects family](../decisions/design-text-effects.md) (`TextGradient`, `TextCircular`,
|
|
|
`TextBlur`, `TextFocus`, `TextScramble`) animates real text content — each owns its own
|
|
|
mechanism (CSS keyframes / rAF / WAAPI) rather than the `motion` prop. `TextBlur`'s staggered
|
|
|
entrance is the one that most overlaps this domain: it uses WAAPI directly today (the same
|
|
|
primitive the `waapi` driver wraps) and is the natural candidate to migrate behind the motion
|
|
|
service's **content domain** when [`MOTION_SERVICE_RFC`](../../src/uix/eidos/MOTION_SERVICE_RFC.md)
|
|
|
lands. 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` |
|