6.3 KiB
Motion
A single-element content animator (RFC §D.12 — the content domain). Wrap any content you
want to animate in and out; it rides the existing state-presets + reduced-motion — no new
mechanism, no JS engine. Same motion selector as the overlays and <Cascade>.
<script>
import { Motion } from '$uix/eidos/components/motion'
let show = $state(true)
</script>
{#if show}
<Motion motion="scale-fade">
<div class="card">…</div>
</Motion>
{/if}
motion is any registered preset (fade / scale-fade / slide-fade / …); 'none' disables.
The keyframe / duration / easing / reduced-motion all come from the preset.
Baseline
No Air baseline — new component (2026-06, RFC §D.12). The prior repo pattern
was hand-spread data-animation-style + ad-hoc exit setTimeouts in demos;
Motion packages that lifecycle once.
Comparativa
| Reference | Their shape | Difference |
|---|---|---|
Framer Motion <motion.div> / AnimatePresence |
JS animation engine; exit needs AnimatePresence bookkeeping |
Motion runs NO JS animation — it stamps the state attr and retains the node; keyframes/duration/reduced-motion live in eidos CSS |
Svelte transition: directives |
Per-element JS/CSS transitions authored inline at each call site | Motion consumes the REGISTERED preset catalog — one vocabulary, themable, reduced-motion handled centrally |
| Radix / Ark presence | Presence utilities tied to their overlay state machines | Motion is the free-standing content case (no state machine, no semantic event) — overlays here already have their own presence via soma |
| Motion One | Imperative WAAPI tweens | Same boundary: eidos owns the visual; the wrapper owns only lifecycle |
Passive justification
Zero events by design: Motion animates content that merely appears or
disappears — no interaction, no semantic occurrence (the book's two-moment
model puts eventful animation in the FIRMA, which components declare
themselves). scope: ['eidos'], events: [] in the morfo.
Audit exceptions
E-2.2 exception:Motion ships nomotion.csson purpose — it has no visual of its own; the enter/exit rules it rides are the state-presets ingenerated/base.css(declared inEidosConfig.motion), and adding a recipe here would fork them (see Decisiones).
How it works
| Phase | Mechanism |
|---|---|
| enter (on mount) | motionAttrs(motion) puts data-animation-style + a presentation data-state="open" on the element → the EXISTING [data-animation-style][data-state='open'] enter rule plays once. Flash-free (attrs at insertion, backwards fill). |
| exit (on removal) | Svelte out: flips the node to data-state='closed' (the existing exit rule plays) and retains it for the eidos-declared duration (read via the ActiveDom getComputedStyle), then unmounts. |
The wrapper writes no visual var and runs no JS animation — it only sets the state attr and holds the node during exit. Eidos owns the keyframes.
The reveal, published to the subtree
trigger="viewport" makes the entrance a moment, and descendants sometimes have to
synchronise with it. Motion publishes it by context:
<script>
import { getMotionContext } from '$uix/eidos/components/motion'
// `undefined` outside a <Motion> — then there is no reveal to wait for.
const motion = getMotionContext()
</script>
<CountUp to={12500} startWhen={motion?.seen ?? true} />
seen is false while a viewport animator waits and true from the start for every other
trigger. The precedent is <Cascade>, which already published open to its items; this is the
same seam on the single-element animator.
Why it is a seam and not an observer. Its only other trace is data-animation-pending in the
DOM, so a consumer that needed the moment had to watch the wrapper from outside — which is what
the blocks contract (B-6) forbids, and what left stats-band's counters running behind
opacity: 0 (ledger A-68 / A-93). An IntersectionObserver does not look at opacity, so two
animators with different thresholds disagree silently; the context is what lets the inner one
defer to the outer one instead of guessing.
<Motion> vs motionAttrs
| Use | Extra node | Exit | |
|---|---|---|---|
<Motion> |
wrap content to animate in/out | yes (a real box — needed for transform/opacity) | ✓ (out: retention) |
motionAttrs(preset) |
spread onto your OWN element | no | — (enter only; you manage exit) |
Reach for motionAttrs when an extra wrapper would disrupt layout (inline content) and you only
need the entrance; reach for <Motion> when you want the full in/out lifecycle.
Domain boundary
Motion is the content domain only — content that simply appears/disappears, with no semantic event and no state machine. Do not wrap:
- a stateful component (its own
data-state: a Card'sselected/idle, a checkbox'schecked) — Motion's presentationdata-statewould clash; that component selects its preset via its ownmotionprop tied to its own state. - an event surface (an overlay's
emerge, acommit) — the firma owns the motion; the prop there is an override, not a wrapper.
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
motion |
MotionPresetName |
'fade' |
The preset played on mount (enter) and removal (exit). 'none' disables. |
Plus any HTMLAttributes<HTMLDivElement>.
Decisiones
- No recipe, no morfo events. Motion is structural lifecycle + a preset selector; the morfo
(
scope: ['eidos']) declares only thedata-stateof its single part. It reuses themotionregistry + the state-presets — it adds no keyframes of its own. - Reuses
data-state, not a bespoke trigger. The content domain fires via a constant presentationdata-state="open"(the<Cascade.Item>pattern), so the existing enter/exit + reduced-motion rules apply verbatim and the generator stays untouched (RFC §D.12). - A real box, not
display: contents. The transform/opacity need a layout box. For zero-extra-node, usemotionAttrson your own element.
Gaps
- No demo page yet (deferred to the demo/docs web phase) — disposition: implementar.
- Enter-only
motionAttrsleaves exit to the caller by design — disposition: descartar (the full lifecycle IS<Motion>).