Experiment: add FLIP position animations for reordered elements#6
Experiment: add FLIP position animations for reordered elements#6raghubetina wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 04bf3f0614
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| el.style.transform = "" | ||
| el.style.transition = "" | ||
| el.style.position = "" | ||
| el.style.zIndex = "" |
There was a problem hiding this comment.
Preserve inline styles when clearing FLIP state
The turbo:before-cache handler now unconditionally clears transform, transition, position, and zIndex for every [data-turbo-refresh-animate] element, which also strips author-provided inline styles. In Turbo history-navigation flows, this runs before snapshotting, so going back can restore a cached DOM with incorrect layout (for example, elements that depended on inline position/z-index) even if no FLIP animation was active. This should only clear properties the library itself set, or restore prior inline values.
Useful? React with 👍 / 👎.
| const onEnd = (event) => { if (event.propertyName === "transform") cleanup() } | ||
| const onCancel = (event) => { if (event.propertyName === "transform") cleanup() } |
There was a problem hiding this comment.
Ignore bubbled transition events in FLIP cleanup
The FLIP transitionend/transitioncancel handlers only check event.propertyName === "transform" and do not verify event.target === el. Because transition events bubble, a descendant finishing its own transform transition can call cleanup() on the parent mover, ending the FLIP early and restoring position/z-index mid-flight when moved elements contain animated children. Filter by target so cleanup only runs for the animated element itself.
Useful? React with 👍 / 👎.
04bf3f0 to
eb7fb3f
Compare
Updated status (April 2026)The branch has been rebased onto main (which now includes the What the PR description's "scroll preservation fix" section refers toThe scroll preservation code was removed from the branch. It was an attempt to fix a scroll jump that turned out to have two separate causes:
Current state of the branch
|
2cf71b3 to
03b974b
Compare
Add opt-in FLIP (First, Last, Invert, Play) position animations via data-turbo-refresh-move. When an element moves position during a morph, it smoothly slides from old to new at constant velocity (800px/s default). CSS custom properties for control: --turbo-refresh-move-speed (px/s, default 800) --turbo-refresh-move-duration (fixed, overrides speed) --turbo-refresh-move-easing (default: ease-out) Known limitation: moving elements may pass behind stationary siblings (CSS transforms vs DOM paint order). Mitigate with opaque backgrounds on animated items. README improvements: - Add Experimental section documenting FLIP - Move Turbo Stream template and duplicate ID gotchas into their own Common Gotchas section - Fix inconsistent protected/preserved terminology - Clarify that data-turbo-refresh-animate and data-turbo-refresh-move are independent
03b974b to
5dfdd54
Compare
|
Merged to main and published as v0.2.0. |
Add event.target === el check to transitionend and transitioncancel handlers so a descendant's own transform transition doesn't prematurely end the parent's FLIP animation. Found by Codex review on PR #6.
Summary
Add FLIP (First, Last, Invert, Play) position animations so elements that change position during a Turbo page refresh morph smoothly slide to their new location. Also includes a scroll preservation fix for broadcast-receiving clients.
How it works
turbo:before-render, capturegetBoundingClientRect()for each[data-turbo-refresh-animate][id]element alongside the existing signature snapshot.turbo:render, compare old and new rects. For elements that moved, apply the FLIP technique:transform: translate(deltaX, deltaY)to snap the element back to its old visual positiontransformto""so it slides to its new positionDuration scaling
Fixed durations feel wrong — elements moving a long distance zip across the screen while short moves barely register. Duration scales with distance using
sqrt(distance) * 25 + 100ms:CSS custom property overrides
Z-index stacking
Elements moving upward get
z-index: 2, downward getz-index: 1, so upward-movers render on top of siblings during the animation. Original inlinepositionandz-indexvalues are saved and restored after the transition completes.Scroll preservation
Added
scrollBeforecapture inturbo:before-renderand restore inturbo:render. Turbo'sscroll: preservedoesn't reliably prevent scroll jumps during morphs that reorder DOM elements. This fixes scroll for broadcast-receiving clients.Testing
Tested with the turbo_refresh_animations_demo app by changing the item sort order to
order(:completed, :created_at)so toggling a checkbox causes items to reorder.What works:
Open issues
Initiator scroll jump
The initiating client's scroll position still jumps in some configurations. The root cause is a tension between two Turbo features:
The problem: When a form submission redirects to the same page, the redirect's GET request carries the
Accept: text/vnd.turbo-stream.htmlheader (preserved byfetch()across redirects). If the redirect target has a.turbo_stream.erbtemplate (common for inline edit/cancel flows), Rails renders it instead of HTML. This means:turbo:before-renderscroll fix never runs (no morph = no before-render)Without the
.turbo_stream.erbtemplate: The redirect renders HTML, Turbo does a page visit. Addingdata-turbo-action="replace"to the form makes it a replace visit, which triggers a morph. Items reorder and FLIP animates. Our scroll fix runs inturbo:render. However, Turbo applies its own scroll handling afterturbo:render, overwriting our restoration. UsingrequestAnimationFrameto defer restoration made things worse (FLIP rect measurements were stale).Possible paths forward:
turbo:before-renderby modifying the render event's properties, if Turbo exposes this)MutationObserverorscrollevent listener to counteract Turbo's scroll resetshouldPreserveScrollflag that can be set from eventsturbo-refresh-scroll: preserveInteraction with
.turbo_stream.erbtemplatesThis is a broader issue (also documented in #5):
.turbo_stream.erbtemplates on controller actions that serve as redirect targets interfere with the morph lifecycle. The library relies on full-page morphs to detect changes and animate elements. When the response is a Turbo Stream, the morph never happens and the library can't do its job. This affects FLIP, enter/change animations, and scroll preservation equally.Commits