Source code
Revision control
Copy as Markdown
Other Tools
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
import { useLayoutEffect, useRef } from "react";
const REORDER_FLIP_MS = 160;
/**
* Framework-agnostic FLIP (First-Last-Invert-Play) reorder animator for a grid
* whose tiles are repositioned by something CSS can't transition (a changed
* `order`, grid placement, or DOM reordering). After the layout has changed,
* `sync()` measures every `childSelector` element, snaps each back to where it
* was with an instant inverse transform, then releases it so it transitions to
* its new cell.
*
* Rects are keyed by the DOM node itself, so any consumer that preserves its
* nodes across a reorder (stable React keys, or a stable CSS `order`) works
* with no per-item id attribute. `skipSelector` matches tiles to leave
* untransformed — the one being dragged, which should track the cursor
* instantly rather than be flung by its own (largest) inverse transform.
*
* A sliding tile's transform also moves its hit-testing box, so with
* dragenter-based drop detection (TopSites) a tile sliding under a still cursor
* re-triggers the reorder and oscillates. The engine exposes `isAnimating()`;
* such a consumer should ignore reorder events while it returns true, which
* fully decouples detection from the animation.
*
* The caller drives intent via `sync(container, { enabled, reset })`:
* - `reset: true` only refreshes the baseline (measure, no animation). Used at
* a drag's start so the first move animates from the tiles' current on-screen
* positions rather than a stale snapshot — e.g. one captured while newtab was
* preloaded offscreen, where getBoundingClientRect reports the origin.
* - otherwise tiles that moved animate from the previous baseline.
*
* getBoundingClientRect includes transforms, so in-flight transforms are
* cleared before measuring — this keeps deltas correct when a drag reorders
* faster than an animation can finish. The "Play" step forces a synchronous
* reflow to commit the inverted state instead of waiting for rAF: a native
* HTML5 drag runs a nested event loop where rAF callbacks are throttled, so an
* rAF-driven release intermittently skips the transition (most visibly on slow
* drags).
*/
export function createReorderFlip({
childSelector,
skipSelector = null,
durationMs = REORDER_FLIP_MS,
} = {}) {
let prevRects = new Map();
let slideUntil = 0;
return {
// True while a slide is in flight. Hit-test-based consumers gate their
// reorder detection on this so moving tiles can't feed back into it.
isAnimating() {
return performance.now() < slideUntil;
},
sync(container, { enabled = true, reset = false } = {}) {
if (!container) {
return;
}
const items = [...container.querySelectorAll(childSelector)];
// Clear any in-flight transform so we measure true layout positions.
for (const el of items) {
el.style.transition = "none";
el.style.transform = "";
}
const newRects = new Map(
items.map(el => [el, el.getBoundingClientRect()])
);
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (enabled && !reset && !reduceMotion) {
const moved = [];
for (const el of items) {
if (skipSelector && el.matches(skipSelector)) {
continue;
}
const prev = prevRects.get(el);
if (!prev) {
continue;
}
const next = newRects.get(el);
const dx = prev.left - next.left;
const dy = prev.top - next.top;
if (!dx && !dy) {
continue;
}
// Invert: snap back to where it was, with no transition.
el.style.transform = `translate(${dx}px, ${dy}px)`;
moved.push(el);
}
if (moved.length) {
// Commit the inverted state as the transition's starting point, then
// release. The reflow read makes this synchronous and drag-loop-proof.
void container.offsetWidth;
for (const el of moved) {
el.style.transition = `transform ${durationMs}ms ease`;
el.style.transform = "";
}
slideUntil = performance.now() + durationMs;
}
}
prevRects = newRects;
},
};
}
/**
* React hook wrapper around createReorderFlip for function components. Returns a
* ref to put on the grid container. `orderKey` must change whenever the visual
* order changes; `resetKey` should change at each drag boundary (e.g. the
* dragged id) so the baseline is refreshed before the first move. A render
* where only `resetKey` changed refreshes the baseline without animating.
*/
export function useReorderFlip({
orderKey,
resetKey = null,
enabled = true,
childSelector,
skipSelector = null,
} = {}) {
const containerRef = useRef(null);
const engineRef = useRef(null);
const orderRef = useRef(orderKey);
if (!engineRef.current) {
engineRef.current = createReorderFlip({ childSelector, skipSelector });
}
useLayoutEffect(() => {
const orderChanged = orderKey !== orderRef.current;
orderRef.current = orderKey;
engineRef.current.sync(containerRef.current, {
enabled,
reset: !orderChanged,
});
// resetKey is a dep so a drag-start refresh fires even when orderKey is
// unchanged; it intentionally has no other use in the body.
}, [orderKey, enabled, resetKey]);
return containerRef;
}