From 0689ac7d145e83a37e9741f6b0b518a9358ed2d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 22 Jul 2026 17:30:30 +0200 Subject: [PATCH] Defer parallax snap until pointer is idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reset the enter-ease timer on each move so transition:none cannot cut off a retargeted interpolation mid-flight. Signed-off-by: Émile Ré --- .../src/components/backdropParallax.ts | 61 +++++++++++-------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/apps/compliance-portal/src/components/backdropParallax.ts b/apps/compliance-portal/src/components/backdropParallax.ts index bbefa4f4b..8eea22d92 100644 --- a/apps/compliance-portal/src/components/backdropParallax.ts +++ b/apps/compliance-portal/src/components/backdropParallax.ts @@ -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. const PARALLAX_ACTIVE = "data-backdrop-parallax"; -// Kept for BACKDROP_EASE_MS after enter so follow-up pointermoves do not flip -// transition to none mid-ease (that was the edge-enter jump). +// Kept until pointer movement has been idle for BACKDROP_EASE_MS after enter +// (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 BACKDROP_VARIANT = "data-backdrop-variant"; const enterEaseTimeouts = new WeakMap(); +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 // (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×. @@ -144,27 +171,18 @@ export function onBackdropPointerMove(event: PointerEvent) { ); const entering = !surface.hasAttribute(PARALLAX_ACTIVE); + const easing = entering || surface.hasAttribute(PARALLAX_EASING); surface.setAttribute(PARALLAX_ACTIVE, ""); - if (entering) { - 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), - ); - + if (easing) { // Apply transition before changing transform so the browser interpolates // from the frozen pose (same-frame transition+transform often snaps). image.style.transition = BACKDROP_EASE; - void image.offsetWidth; - } else if (!surface.hasAttribute(PARALLAX_EASING)) { + if (entering) { + void image.offsetWidth; + } + scheduleEaseEnd(surface); + } else { image.style.transition = "none"; } @@ -175,11 +193,6 @@ export function onBackdropPointerMove(event: PointerEvent) { // ease from that frozen pose instead of jumping. export function onBackdropPointerLeave(event: PointerEvent) { const surface = event.currentTarget; - const previous = enterEaseTimeouts.get(surface); - if (previous != null) { - window.clearTimeout(previous); - enterEaseTimeouts.delete(surface); - } + clearEaseEnd(surface); surface.removeAttribute(PARALLAX_ACTIVE); - surface.removeAttribute(PARALLAX_EASING); }