Skip to content

Experiment: add FLIP position animations for reordered elements#6

Closed
raghubetina wants to merge 1 commit into
mainfrom
flip-position-animations
Closed

Experiment: add FLIP position animations for reordered elements#6
raghubetina wants to merge 1 commit into
mainfrom
flip-position-animations

Conversation

@raghubetina
Copy link
Copy Markdown
Contributor

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

  1. In turbo:before-render, capture getBoundingClientRect() for each [data-turbo-refresh-animate][id] element alongside the existing signature snapshot.
  2. In turbo:render, compare old and new rects. For elements that moved, apply the FLIP technique:
    • Invert: set transform: translate(deltaX, deltaY) to snap the element back to its old visual position
    • Play: transition transform to "" so it slides to its new position
  3. Batch operations to minimize forced reflows: measure all → invert all → one reflow → play all.

Duration 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:

Distance Duration
50px ~277ms
100px ~350ms
200px ~453ms
500px ~659ms

CSS custom property overrides

[data-turbo-refresh-animate] {
  --turbo-refresh-move-duration: 500ms;  /* fixed duration, disables scaling */
  --turbo-refresh-move-easing: ease-in-out;  /* default: ease-out */
}

Z-index stacking

Elements moving upward get z-index: 2, downward get z-index: 1, so upward-movers render on top of siblings during the animation. Original inline position and z-index values are saved and restored after the transition completes.

Scroll preservation

Added scrollBefore capture in turbo:before-render and restore in turbo:render. Turbo's scroll: preserve doesn'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:

  • Receiver tab: items smoothly slide to new positions with correct z-index stacking, scroll preserved
  • Both tabs: enter/change/exit animations still work correctly
  • All 26 existing tests pass

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.html header (preserved by fetch() across redirects). If the redirect target has a .turbo_stream.erb template (common for inline edit/cancel flows), Rails renders it instead of HTML. This means:

  • No page morph happens (it's a Turbo Stream response, not an HTML page)
  • Items don't reorder on the initiator (only the Turbo Stream target is updated)
  • The broadcast refresh is deduped by request-id, so no morph follows
  • Our turbo:before-render scroll fix never runs (no morph = no before-render)

Without the .turbo_stream.erb template: The redirect renders HTML, Turbo does a page visit. Adding data-turbo-action="replace" to the form makes it a replace visit, which triggers a morph. Items reorder and FLIP animates. Our scroll fix runs in turbo:render. However, Turbo applies its own scroll handling after turbo:render, overwriting our restoration. Using requestAnimationFrame to defer restoration made things worse (FLIP rect measurements were stale).

Possible paths forward:

  1. Intercept Turbo's scroll handling earlier (in turbo:before-render by modifying the render event's properties, if Turbo exposes this)
  2. Use a MutationObserver or scroll event listener to counteract Turbo's scroll reset
  3. Investigate whether Turbo exposes a shouldPreserveScroll flag that can be set from events
  4. File an issue with Turbo about form submission redirects not respecting turbo-refresh-scroll: preserve
  5. Document that forms on morph pages should respond with Turbo Streams that include all affected elements (not redirect), or use optimistic UI via Stimulus

Interaction with .turbo_stream.erb templates

This is a broader issue (also documented in #5): .turbo_stream.erb templates 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

  1. Add FLIP position animations — core implementation with distance-scaled duration, CSS custom property overrides, batched reflows
  2. Fix z-index stacking — upward movers render on top of siblings, save/restore original inline styles
  3. Preserve scroll position — save/restore scrollX/Y around page refresh morphs (fixes receiver tab)

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread turbo-refresh-animations.js Outdated
Comment on lines +146 to +149
el.style.transform = ""
el.style.transition = ""
el.style.position = ""
el.style.zIndex = ""
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +614 to +615
const onEnd = (event) => { if (event.propertyName === "transform") cleanup() }
const onCancel = (event) => { if (event.propertyName === "transform") cleanup() }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@raghubetina raghubetina force-pushed the flip-position-animations branch from 04bf3f0 to eb7fb3f Compare April 2, 2026 15:35
@raghubetina raghubetina changed the title Add FLIP position animations for reordered elements Experiment: Add FLIP position animations for reordered elements Apr 2, 2026
@raghubetina raghubetina changed the title Experiment: Add FLIP position animations for reordered elements Experiment: add FLIP position animations for reordered elements Apr 2, 2026
@raghubetina
Copy link
Copy Markdown
Contributor Author

Updated status (April 2026)

The branch has been rebased onto main (which now includes the data-turbo-refresh-preserve rename and version 0.1.0), squashed to a single commit, and cleaned of debugging artifacts (scroll save/restore attempts, overflow-anchor experiments).

What the PR description's "scroll preservation fix" section refers to

The 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:

  1. Duplicate IDs — Rails' form.check_box generates the same id for every item in a loop. Turbo's morph engine loses scroll position with duplicate IDs (hotwired/turbo#1226). Fix: use unique IDs (e.g., id: dom_id(item, :completed)). Now documented in the README on main.
  2. FLIP's forced reflow triggers scroll anchoring — the void document.body.offsetWidth in the Invert phase causes the browser to follow upward-moving elements. This is the remaining open issue.

Current state of the branch

  • Clean single commit: FLIP implementation with distance-scaled duration, z-index handling, cache cleanup, timeout fallbacks
  • No scroll fix code (all attempts removed)
  • Two open issues: scroll anchoring on upward moves, imperfect z-index stacking
  • Not ready to merge until the scroll anchoring issue is resolved

@raghubetina raghubetina force-pushed the flip-position-animations branch 2 times, most recently from 2cf71b3 to 03b974b Compare April 3, 2026 12:58
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
@raghubetina raghubetina force-pushed the flip-position-animations branch from 03b974b to 5dfdd54 Compare April 3, 2026 13:00
@raghubetina
Copy link
Copy Markdown
Contributor Author

Merged to main and published as v0.2.0.

@raghubetina raghubetina closed this Apr 3, 2026
raghubetina added a commit that referenced this pull request Apr 3, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant