Skip to content

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.

(ns app.toasts
  (:require [re-frame.fresco :as h]
            [re-frame.fresco.motion :as motion]))

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:

  1. The user dismisses toast 7. The handler removes it from app-db.
  2. Presence still has a child with key 7. That child enters the unmounting phase.
  3. Presence merges ::motion/unmounting attributes onto the real element. The exit class starts the CSS transition; :inert and :aria-hidden stop interaction and hide the node from assistive tech while it is still painted.
  4. 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-ms is mandatory. It is both retention length and the hard terminal bound.
  • Re-entry cancels exit. A key that returns while unmounting becomes :present on 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 requestAnimationFrame or 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 :inert and :aria-hidden — under ::motion/unmounting on 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.