Skip to content

Pointer spotlight renders at wrong position when step requires scrolling #44

@AmanBhanse

Description

@AmanBhanse

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

Image

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

  1. 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).
  2. Advance from step N-1 to step N.
  3. Observe the spotlight — it renders at the wrong Y position.
  4. Resize the browser window by any amount.
  5. 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 MutationObserver to detect when the target
    element is available, which avoids this issue since the scroll position is 0
    on page load.
  • The window.scrollY approach in getElementPosition may not account for pages
    where the scroll container is a child element rather than window (e.g. a
    <main class="overflow-auto"> inside a sidebar layout). In those cases
    window.scrollY is always 0 and getBoundingClientRect().top is relative to
    the container viewport — causing the same class of positioning errors regardless
    of which fix approach is used. The setTimeout approach (fix 2 above) handles
    this correctly since it re-reads the coordinates after the animation completes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions