Motion and presence¶
Fresco does not ship an animation system. CSS owns transitions and keyframes. The compositor interpolates them. A native host owns high-rate mechanics such as drag positions and spring integrators. Exactly one gap remains: React removes a node as soon as its data leaves app-db, and a node that is gone cannot finish an exit animation.
re-frame.fresco.motion closes that gap. It is an optional module. An
application that never requires it carries none of its code.
The problem in one example¶
A toast should leave app-db the moment the user dismisses it. Tests, Xray, and other views must see the toast as gone. The painted element may still need 300 ms of CSS exit transition.
If the view simply maps over the subscription, the DOM node disappears on the same turn as the event:
;; Don't — the node vanishes with the data; CSS has nothing left to animate.
(h/defview toast-tray [_]
[:div.toast-tray
(for [t (h/sub [:toasts/visible])]
[:div.toast {:key (:id t)}
(:message t)
[:button {:on-click [:toasts/dismiss (:id t)]} "×"]])])
Presence keeps the exiting node for a stated timeout while app-db already records the dismissal.
The taught spelling¶
(h/defview toast-tray [_]
[motion/presence {:timeout-ms 300}
(for [t (h/sub [:toasts/visible])]
[:div.toast
{:key (:id t)
::motion/unmounting {:class "toast toast--exit"
:inert true
:aria-hidden true}}
(:message t)
[:button {:on-click [:toasts/dismiss (:id t)]} "×"]])])
What happens:
- The user dismisses toast
7. The handler removes it from app-db. - Presence still has a child with key
7. That child enters the unmounting phase. - Presence merges
::motion/unmountingattributes onto the real element. The exit class starts the CSS transition;:inertand:aria-hiddenstop interaction and hide the node from assistive tech while it is still painted. - After 300 ms Presence removes the child. Removal is timer-based, not
transitionend. Disabled CSS cannot strand the node forever.
App-db never stores “still animating.” The retention is a paint concern owned by Presence.
Module posture¶
Presence owns retention and phase, nothing else:
| Belongs to Presence | Does not belong to Presence |
|---|---|
| Keeping a keyed child after its data leaves | Easing curves, springs, keyframe APIs |
| Applying mounting/unmounting attribute overrides | Timelines, sequences, orchestrators |
A hard :timeout-ms terminal bound |
transitionend subscriptions |
| Cancelling exit when a key re-enters | Gesture or drag state |
High-rate motion stays in a React component or CSS. Host those mechanics in a
foreign component or a React island through
h/defhost; do not route pointer-move events through app-db.
API¶
motion/presence¶
A Hiccup head. Props:
| Prop | Required | Meaning |
|---|---|---|
:timeout-ms |
yes | How long an exiting child is retained. Also the hard stop for removal. |
Children must be keyed. Presence freezes order at first appearance so an exiting sibling does not jump while it leaves.
Presence inserts no wrapper DOM node and stamps no data-*. Each child is
the author's node with the author's attributes merged for the active phase.
Phase overrides on elements¶
On a native element child, write overrides with the motion markers:
[:div.card
{:key id
::motion/mounting {:class "card card--enter" :inert true}
::motion/unmounting {:class "card card--exit" :inert true :aria-hidden true}}
body]
| Marker | When applied |
|---|---|
::motion/mounting |
While the child is entering (first paint of a new key) |
::motion/unmounting |
While the child is retained after its key left the live set |
These markers live in the re-frame.fresco.motion keyword namespace
(::motion/... when you alias the module as motion): the module owns its
vocabulary, and the door's ::h/... markers are a separate roster.
Prefer CSS insertion animations or @starting-style for simple entrances.
Use ::motion/mounting when the node must carry attributes such as :inert
until it settles.
Phase overrides on views¶
The same markers work on a h/defview head, and mean the
same thing: while the child is in that phase, Presence merges the map into the
view's props. The view branches on whatever prop it declared:
(h/defview toast-item [{:keys [id message exiting?]}]
[:div.toast
{:class (cond-> "toast" exiting? (str " toast--exit"))
:inert exiting?
:aria-hidden exiting?}
message
(when-not exiting?
[:button {:on-click [:toasts/dismiss id]} "×"])])
(h/defview toast-tray [_]
[motion/presence {:timeout-ms 300}
(for [t (h/sub [:toasts/visible])]
[toast-item {:key (:id t)
:id (:id t)
:message (:message t)
::motion/unmounting {:exiting? true}}])])
A view never sees the phase as a value; it sees the props its author declared
for that phase, under names the author chose. So a test renders the exiting
shape by passing {:exiting? true} directly, with no timer armed and no
reserved key to know about.
Rules that matter in production¶
:timeout-msis mandatory. It is both retention length and the hard terminal bound.- Re-entry cancels exit. A key that returns while unmounting becomes
:presenton the same node — no remount, no second deadline. - Unmount clears timers. Leaving the page mid-transition does not leave dangling timers.
- Per-frame work is zero. Presence arms timers at phase changes; it does
not run
requestAnimationFrameor write state every frame. CSS owns the visual interpolation. - SSR. A presence-managed server node hydrates as already present. The server HTML does not carry entry-phase attributes (SSR and hydration).
- Accessibility. While unmounting, set
:inertand:aria-hidden— under::motion/unmountingon an element, or from the prop a view declares there — so a fading node does not keep focus or announce itself (Accessibility).
What Presence does not do¶
- It does not dispatch an event when a transition ends.
- It does not keep the removed domain data in app-db.
- It does not replace CSS, the Web Animations API, or a hosted animation library.
- It does not own open/closed UI truth. That is still app-db (Ephemeral state).
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
| Dismissed item vanishes immediately | Children are not under Presence, or keys are missing | Wrap the keyed sequence in motion/presence and give every child a stable :key |
| Node stays forever after dismiss | :timeout-ms omitted or far longer than the CSS |
Set :timeout-ms to at least the CSS duration; it is required |
| Fading toast still takes focus or clicks | Exit class changes appearance only | Add :inert true and :aria-hidden true under ::motion/unmounting — on the element, or from the prop the view's override declares |
| Override on a view head has no visible effect | The map was merged into the view's props, and the view's body does not read the prop it names | Destructure the prop in the view and branch on it |
| Exit restarts on every parent re-render | Unstable keys | Key by domain id, not index |
| Bundle still contains motion code when unused | Something required the module | Require re-frame.fresco.motion only where Presence is used |
When not to use Presence¶
- No exit animation — just remove the data; no module required.
- The fact is application-visible (open, selected, draft) — store it in app-db, not as a phase.
- Continuous pointer or layout motion — use a native host or CSS, not Presence.
Advanced¶
Optional module reachability¶
re-frame.fresco does not import re-frame.fresco.motion. That keeps the
retention machine out of applications that never ask for it. A check in the
Fresco package fails if the public door re-acquires a hard dependency on the
module.
Phase vocabulary¶
The override markers are ::motion/mounting and ::motion/unmounting — the
naming ledger's ruled spellings (row 31), shipped by the engine and used by
every example here. The prototype's ::h/... spellings are retired.