Motion and presence¶
Fresco does not ship an animation system. CSS handles transitions and keyframes, and a React island handles high-rate mechanics such as drag positions and springs. None of them handles exit: 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 fills that gap. It is an optional module; an
application that never requires it includes none of its code.
Retain the exiting node¶
A deleted todo should leave app-db immediately while its row finishes a
300 ms fade. Wrap the keyed children in motion/presence:
(h/defview todo-list [_]
[:ul.todo-list
[motion/presence {:timeout-ms 300}
(for [{:keys [id title]} (h/sub [:todo/visible])]
[:li.todo
{:key id
::motion/unmounting {:class "todo todo--exit"
:inert true
:aria-hidden true}}
title
[:button {:type "button" :aria-label "Delete todo"
:on-click [:todo/delete id]} "×"]])]])
Add the transition to the stylesheet:
.todo { opacity: 1; transition: opacity 300ms; }
.todo--exit { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
.todo { transition: none; }
}
The reduced-motion rule removes the fade; the same 300 ms retention bound still removes the hidden node.
What happens:
- The user deletes todo
7. The handler removes it from app-db. - Presence still has a child with key
7, so that child enters the unmounting phase. - Presence merges the
::motion/unmountingattributes onto the real element. The exit class starts the CSS transition;:inertand:aria-hiddenstop interaction and hide the node from assistive technology while it is still painted. - After 300 ms Presence removes the child. Removal uses a timer rather than
transitionend, so disabled CSS cannot leave the node on the page.
App-db never stores "still animating". Keeping the node painted is Presence's job.
The cell below runs that list. A cell has no stylesheet, so the fade is written as inline styles, and it lasts a full second so you can watch it. Delete a todo: it fades and stops taking clicks, then goes. Press Restore while a row is fading and it comes back on the same node, because re-entry cancels the exit.
(require '[re-frame.core :as rf]
'[re-frame.fresco :as h]
'[re-frame.fresco.motion :as motion])
(rf/reg-event :todo/initialise
(fn [_ _]
{:db {:todos {1 {:id 1 :title "Buy milk"}
2 {:id 2 :title "Walk the dog"}
3 {:id 3 :title "Post letter"}}}}))
(rf/reg-event :todo/delete
(fn [{:keys [db]} [_ id]]
{:db (update db :todos dissoc id)}))
(rf/reg-sub :todo/visible
(fn [db _]
(vec (sort-by :id (vals (:todos db))))))
;; The page's .todo and .todo--exit rules, inline.
(def shown {:opacity 1 :transition "opacity 1000ms"})
(def hidden {:opacity 0 :transition "opacity 1000ms"})
(h/defview todo-list [_]
[:div
[:ul.todo-list
[motion/presence {:timeout-ms 1000}
(for [{:keys [id title]} (h/sub [:todo/visible])]
[:li.todo
{:key id
:style shown
::motion/unmounting {:style hidden
:inert true
:aria-hidden true}}
title " "
[:button {:type "button" :aria-label "Delete todo"
:on-click [:todo/delete id]} "×"]])]]
[:button {:type "button" :on-click [:todo/initialise]} "Restore"]])
[h/frame-root {:id :rf/default :initial-events [[:todo/initialise]]}
[todo-list]]
What Presence covers¶
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 CSS or a React component, mounted as 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. |
A missing or non-positive :timeout-ms raises
:rf.error/fresco-presence-timeout-required.
Children must be keyed Hiccup vectors; a child without a :key raises
:rf.error/fresco-presence-child-unkeyed. Presence renders its children in
the order you pass them, so a sorted or reordered list stays sorted. An exiting
child is no longer in that input, so Presence keeps it straight after the child
it followed, or at the front if nothing preceded it, and it moves with that
child if the list reorders while it fades.
Presence retains a single conditional element too. A nil child is skipped,
so [motion/presence {:timeout-ms 200} (when open? [:div.banner {:key :banner} …])]
keeps the banner painted through its exit after open? turns false.
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:
[:li.todo
{:key id
::motion/mounting {:class "todo todo--enter" :inert true}
::motion/unmounting {:class "todo todo--exit" :inert true :aria-hidden true}}
title]
| 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 are keywords in the re-frame.fresco.motion namespace, so
write them as ::motion/... with the module aliased as motion, not as
::h/....
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. While the
child is in that phase, Presence merges the map into the view's props, and the
view branches on whatever prop it declared:
(h/defview todo-item [{:keys [todo exiting?]}]
(let [{:keys [id title]} todo]
[:li
{:class (cond-> "todo" exiting? (str " todo--exit"))
:inert exiting?
:aria-hidden exiting?}
title
(when-not exiting?
[:button {:type "button" :aria-label "Delete todo"
:on-click [:todo/delete id]} "×"])]))
(h/defview todo-list [_]
[:ul.todo-list
[motion/presence {:timeout-ms 300}
(for [{:keys [id] :as todo} (h/sub [:todo/visible])]
[todo-item {:key id
:todo todo
::motion/unmounting {:exiting? true}}])]])
The view sees the props its author chose for that phase, never a phase value.
A test renders the exiting shape by passing {:exiting? true} directly, with
no timer involved.
Presence retains the last child form and its props, not a historical app-db.
Passing the todo as a prop keeps its title available during exit. If the child
instead reads [:todo/by-id id], it sees the deletion immediately and may render
an empty row for the rest of the transition.
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 goes back to the present phase on the same node, with no remount and 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).
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
| Dismissed item vanishes immediately | The children are not under Presence | Wrap the keyed sequence in motion/presence |
:rf.error/fresco-presence-child-unkeyed |
A child has no :key |
Give every child a stable domain :key |
:rf.error/fresco-presence-timeout-required |
:timeout-ms is missing or not a positive number |
Set it to at least the CSS exit duration |
| Exiting row loses its text | The child reads data already deleted from app-db | Pass the display data in the retained child props |
| Exiting node lingers after its animation ends | :timeout-ms is much longer than the CSS transition |
Match :timeout-ms to the CSS duration |
| Fading row 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 has no effect on an element | The marker is on a nested element, not the keyed child Presence receives, and Fresco drops it there | Put the marker map on Presence's direct child |
| 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¶
- There is no exit animation: remove the data and skip the module.
- The fact is application-visible (open, selected, draft): store it in app-db (Ephemeral state).
- The motion is continuous pointer or layout motion: use an island or CSS.