-
Notifications
You must be signed in to change notification settings - Fork 66
Description
Pointer spotlight renders at wrong position when step requires scrolling
Library: onborda
Version: 1.2.5
Environment: Next.js 15 (App Router), React 19, Framer Motion
Description
When advancing to a tour step whose target element is not currently in the viewport,
the Onborda pointer spotlight renders at an incorrect position on first render.
Manually resizing the browser window immediately corrects it.
The wrong position is consistently offset in the Y axis. In our case the offset was
approximately 159px — the pointer appeared too far down the page every time.
Steps to Reproduce
- Set up a multi-step tour where step N targets an element that is out of the
viewport when step N-1 is active (i.e., the page needs to scroll to reach it). - Advance from step N-1 to step N.
- Observe the spotlight — it renders at the wrong Y position.
- Resize the browser window by any amount.
- Observe the spotlight snaps to the correct position.
Expected Behavior
The pointer spotlight is positioned correctly over the target element as soon as
step N becomes active.
Actual Behavior
The spotlight is rendered offset from the target element. The offset corresponds
roughly to the scroll distance that scrollIntoView needs to travel to bring the
element into view. After a window resize event, updatePointerPosition is called
and the spotlight corrects itself.
Root Cause
getElementPosition calculates absolute document coordinates:
// Onborda.js
const getElementPosition = (element) => {
const { top, left, width, height } = element.getBoundingClientRect();
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const scrollLeft = window.scrollX || document.documentElement.scrollLeft;
return { x: left + scrollLeft, y: top + scrollTop, width, height };
};In the currentStep effect, setPointerPosition is called before
scrollIntoView, so the measurement is taken while the scroll offset is still
at the previous step's position:
// Onborda.js — useEffect on currentStep
useEffect(() => {
const element = document.querySelector(step.selector);
if (element) {
setPointerPosition(getElementPosition(element)); // measured here — scroll hasn't moved yet
// ...
element.scrollIntoView({ behavior: "smooth", block: "center" }); // scroll starts after
}
}, [currentStep, ...]);scrollIntoView with behavior: "smooth" is asynchronous — the browser animates
the scroll over several hundred milliseconds. By the time the scroll completes,
pointerPosition already holds a stale value. The pointer is never re-measured
after the scroll settles unless something triggers updatePointerPosition, which
only happens on window resize.
The same ordering issue exists in scrollToElement (called from nextStep /
prevStep):
const scrollToElement = (stepIndex) => {
const element = document.querySelector(currentTourSteps[stepIndex].selector);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center" }); // async
setPointerPosition(getElementPosition(element)); // measured immediately — still wrong
}
};Suggested Fix
Two complementary approaches are needed depending on what the scroll container is.
1. scrollend / debounced scroll listener (for window-level scroll)
Listen for scrollend (modern browsers) or a debounced scroll event and call
updatePointerPosition once scrolling stops:
useEffect(() => {
if (!isOnbordaVisible) return;
let timer;
const handleScrollEnd = () => updatePointerPosition();
const handleScroll = () => {
clearTimeout(timer);
timer = setTimeout(updatePointerPosition, 150);
};
const supportsScrollEnd = "onscrollend" in window;
if (supportsScrollEnd) {
window.addEventListener("scrollend", handleScrollEnd);
} else {
window.addEventListener("scroll", handleScroll, { passive: true });
}
return () => {
if (supportsScrollEnd) {
window.removeEventListener("scrollend", handleScrollEnd);
} else {
window.removeEventListener("scroll", handleScroll);
clearTimeout(timer);
}
};
}, [currentStep, isOnbordaVisible]);2. setTimeout re-measurement at each scrollIntoView call site (for child scroll containers)
When the scroll container is a child element (e.g. <main class="overflow-auto">
inside a sidebar layout), scroll events do not bubble to window so the listener
above never fires. In that case, schedule a re-measurement directly after each
scrollIntoView call:
if (!isInViewport) {
element.scrollIntoView({ behavior: "smooth", block: "center" });
// Re-measure after smooth scroll animation completes (~300–500ms)
setTimeout(() => setPointerPosition(getElementPosition(element)), 500);
} else {
setPointerPosition(getElementPosition(element));
}This pattern needs to be applied at all three scrollIntoView call sites:
the init useEffect, the step-change useEffect, and scrollToElement.
Workaround (consumer-side)
Because updatePointerPosition is triggered by the window resize event, you
can work around this by dispatching a synthetic resize event after each step
change from outside the library:
// In a component that has access to useOnborda()
const { currentStep, isOnbordaVisible } = useOnborda();
useEffect(() => {
if (!isOnbordaVisible) return;
const timer = setTimeout(() => window.dispatchEvent(new Event("resize")), 600);
return () => clearTimeout(timer);
}, [currentStep, isOnbordaVisible]);The 600ms delay is chosen to exceed the typical smooth-scroll animation duration.
This is a workaround, not a fix — the correct solution is for the library to
re-measure after scroll settles internally.
Additional Notes
- The bug is reliably reproducible when advancing between steps that are on the
same page but far apart vertically. - Cross-page step transitions use a
MutationObserverto detect when the target
element is available, which avoids this issue since the scroll position is 0
on page load. - The
window.scrollYapproach ingetElementPositionmay not account for pages
where the scroll container is a child element rather thanwindow(e.g. a
<main class="overflow-auto">inside a sidebar layout). In those cases
window.scrollYis always 0 andgetBoundingClientRect().topis relative to
the container viewport — causing the same class of positioning errors regardless
of which fix approach is used. ThesetTimeoutapproach (fix 2 above) handles
this correctly since it re-reads the coordinates after the animation completes.