You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/theming/motion-guide.md

243 lines
13 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

---
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`).
- 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`/`<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` |

Powered by TurnKey Linux.