Defer parallax snap until pointer is idle

Reset the enter-ease timer on each move so transition:none cannot
cut off a retargeted interpolation mid-flight.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-22 17:30:30 +02:00
parent 1fc453f839
commit 0689ac7d14

View File

@@ -30,14 +30,41 @@ const BACKDROP_EASE = `transform ${BACKDROP_EASE_MS}ms ease-out`;
// Set on the tracking surface while the pointer is over it. // Set on the tracking surface while the pointer is over it.
const PARALLAX_ACTIVE = "data-backdrop-parallax"; const PARALLAX_ACTIVE = "data-backdrop-parallax";
// Kept for BACKDROP_EASE_MS after enter so follow-up pointermoves do not flip // Kept until pointer movement has been idle for BACKDROP_EASE_MS after enter
// transition to none mid-ease (that was the edge-enter jump). // (and after each move during that window) so transition is not flipped to
// none while a retargeted ease is still interpolating.
const PARALLAX_EASING = "data-backdrop-parallax-easing"; const PARALLAX_EASING = "data-backdrop-parallax-easing";
const BACKDROP_VARIANT = "data-backdrop-variant"; const BACKDROP_VARIANT = "data-backdrop-variant";
const enterEaseTimeouts = new WeakMap<HTMLElement, number>(); const enterEaseTimeouts = new WeakMap<HTMLElement, number>();
function clearEaseEnd(surface: HTMLElement) {
const previous = enterEaseTimeouts.get(surface);
if (previous != null) {
window.clearTimeout(previous);
enterEaseTimeouts.delete(surface);
}
surface.removeAttribute(PARALLAX_EASING);
}
// (Re)start the idle timer that ends eased tracking. Call on enter and on every
// move while still easing so a late retarget can finish before transition:none.
function scheduleEaseEnd(surface: HTMLElement) {
surface.setAttribute(PARALLAX_EASING, "");
const previous = enterEaseTimeouts.get(surface);
if (previous != null) {
window.clearTimeout(previous);
}
enterEaseTimeouts.set(
surface,
window.setTimeout(() => {
surface.removeAttribute(PARALLAX_EASING);
enterEaseTimeouts.delete(surface);
}, BACKDROP_EASE_MS),
);
}
// Per-surface Figma specs. Both use a width-based square centered on the frame // Per-surface Figma specs. Both use a width-based square centered on the frame
// (top/left 50% + translate -50%). Logo Tile overflows (~inset -42px on a // (top/left 50% + translate -50%). Logo Tile overflows (~inset -42px on a
// ~157px media → scale 1.53); Subprocessor Card fills the header width at 1×. // ~157px media → scale 1.53); Subprocessor Card fills the header width at 1×.
@@ -144,27 +171,18 @@ export function onBackdropPointerMove(event: PointerEvent<HTMLElement>) {
); );
const entering = !surface.hasAttribute(PARALLAX_ACTIVE); const entering = !surface.hasAttribute(PARALLAX_ACTIVE);
const easing = entering || surface.hasAttribute(PARALLAX_EASING);
surface.setAttribute(PARALLAX_ACTIVE, ""); surface.setAttribute(PARALLAX_ACTIVE, "");
if (entering) { if (easing) {
surface.setAttribute(PARALLAX_EASING, "");
const previous = enterEaseTimeouts.get(surface);
if (previous != null) {
window.clearTimeout(previous);
}
enterEaseTimeouts.set(
surface,
window.setTimeout(() => {
surface.removeAttribute(PARALLAX_EASING);
enterEaseTimeouts.delete(surface);
}, BACKDROP_EASE_MS),
);
// Apply transition before changing transform so the browser interpolates // Apply transition before changing transform so the browser interpolates
// from the frozen pose (same-frame transition+transform often snaps). // from the frozen pose (same-frame transition+transform often snaps).
image.style.transition = BACKDROP_EASE; image.style.transition = BACKDROP_EASE;
void image.offsetWidth; if (entering) {
} else if (!surface.hasAttribute(PARALLAX_EASING)) { void image.offsetWidth;
}
scheduleEaseEnd(surface);
} else {
image.style.transition = "none"; image.style.transition = "none";
} }
@@ -175,11 +193,6 @@ export function onBackdropPointerMove(event: PointerEvent<HTMLElement>) {
// ease from that frozen pose instead of jumping. // ease from that frozen pose instead of jumping.
export function onBackdropPointerLeave(event: PointerEvent<HTMLElement>) { export function onBackdropPointerLeave(event: PointerEvent<HTMLElement>) {
const surface = event.currentTarget; const surface = event.currentTarget;
const previous = enterEaseTimeouts.get(surface); clearEaseEnd(surface);
if (previous != null) {
window.clearTimeout(previous);
enterEaseTimeouts.delete(surface);
}
surface.removeAttribute(PARALLAX_ACTIVE); surface.removeAttribute(PARALLAX_ACTIVE);
surface.removeAttribute(PARALLAX_EASING);
} }