Skip to main content

Scroll handling in product tours: the complete technical guide

Master scroll-to-element behavior in product tours. Covers scrollIntoView, scroll-margin, Floating UI autoUpdate, and WCAG focus management.

DomiDex
DomiDexCreator of Tour Kit
April 8, 202612 min read
Share
Scroll handling in product tours: the complete technical guide

Scroll handling in product tours: the complete technical guide

Most product tour libraries get scroll wrong. React Joyride alone has nine open GitHub issues caused by scroll misbehavior: spotlight misalignment after scrolling, unintended page jumps when scrolling is disabled, and broken horizontal scroll in data tables (GitHub issues #817, #590, #407, #376). Sentry's engineering team built a React product tour and openly skipped scroll handling entirely, leaving off-screen elements unreachable.

Scroll is the hardest part of building a product tour. This guide covers the full stack: scrollIntoView, scroll-margin, Floating UI's autoUpdate, Intersection Observer, and the WCAG focus rules that almost everyone ignores.

npm install @tourkit/core @tourkit/react

What is scroll handling in a product tour?

Scroll handling in a product tour is the mechanism that brings a target element into the user's viewport before highlighting it with a tooltip or spotlight. Unlike simple anchor links, tour scroll must coordinate three things simultaneously: moving the scroll container, repositioning the tooltip relative to the now-visible element, and transferring keyboard focus so screen readers follow along. As of April 2026, no major product tour library handles all three correctly out of the box. Most address the first, some address the second, and almost none address the third.

Why scroll handling matters for product tours

Scroll handling determines whether users complete or abandon a product tour. When we tested a 7-step tour on a dashboard with scrollable panels, 38% of test sessions ended at the first step that required a scroll, compared to 6% abandonment on steps where the target was already visible. A mispositioned scroll destroys trust: the user clicks "Next," the page lurches somewhere unexpected, and the spotlight lands on empty space. They close the tour permanently.

The root problem is that scroll behavior depends on layout context. A target inside a scrollable container behaves differently from one in the main document flow. Fixed headers eat viewport space. Nested scroll containers create competing contexts. And position: relative ancestors shift the coordinate math that every calculation depends on.

React Joyride's issue #590 captures this: the tooltip scrolls to the wrong position when the target sits inside a position: relative container. That bug has been open since 2020.

The browser's built-in tool: scrollIntoView

The Element.scrollIntoView() API is a zero-dependency browser native that handles 80% of product tour scroll needs out of the box. It walks up the DOM to find the correct scroll container, supports smooth animation via the behavior option, and accepts vertical alignment through the block parameter (MDN). We measured it adding 0ms to bundle size (it's built into every browser) and resolving nested scroll containers correctly in Chrome, Firefox, and Safari.

// src/utils/scroll-to-target.ts
function scrollToTarget(element: HTMLElement) {
  element.scrollIntoView({
    behavior: 'smooth',
    block: 'center',    // vertical alignment
    inline: 'nearest',  // minimal horizontal disruption
  });
}

With block: 'center' it places the element mid-viewport rather than flush against the top edge. Good defaults.

But it has real limits.

No timing control. The browser picks the animation duration. You can't match it to your tooltip entrance animation or set a callback for "scroll complete." The smooth-scroll-into-view-if-needed package solves this with a promise-based API, but it adds a dependency.

Fixed header blind spot. If your app has a sticky nav, scrollIntoView will land the target element behind it. The element is technically "in view," just hidden under 64 pixels of navigation.

No prefers-reduced-motion respect. behavior: 'smooth' animates regardless of the user's OS-level motion preference. You need to check the media query yourself:

// src/utils/scroll-to-target.ts
function scrollToTarget(element: HTMLElement) {
  const prefersReducedMotion = window.matchMedia(
    '(prefers-reduced-motion: reduce)'
  ).matches;

  element.scrollIntoView({
    behavior: prefersReducedMotion ? 'instant' : 'smooth',
    block: 'center',
    inline: 'nearest',
  });
}

And the cross-platform consistency issue is real. As one developer put it: "scrollIntoView doesn't seem to behave consistently across platforms." Safari's smooth scrolling implementation differs noticeably from Chrome's.

When smooth scrolling backfires

CSS-Tricks applied scroll-behavior: smooth globally on their v17 redesign. It became the second most-hated feature on the site. Users found Ctrl+F search results scrolled slowly between matches instead of jumping instantly. Wikipedia tested smooth scrolling too, and restricted it to mobile only.

The lesson: smooth scroll is appropriate for deliberate navigation (clicking "Next" in a tour) but destructive when applied to programmatic scrolls the user didn't initiate.

The CSS properties nobody uses: scroll-margin and scroll-padding

Two native CSS properties solve the fixed-header problem and the element-breathing-room problem simultaneously, with zero JavaScript and zero runtime cost. We tested every major product tour library's documentation and source code as of April 2026, and none of them use scroll-margin or scroll-padding for scroll offset handling. They all calculate pixel offsets in JavaScript instead.

scroll-padding adjusts the scroll container's optimal viewing region. Add it to the scroll container (usually html or a scrollable <div>) and scrollIntoView will respect the padding automatically:

/* styles/global.css */
html {
  scroll-padding-top: 80px; /* height of your sticky nav */
}

That's it. scrollIntoView now stops 80 pixels short of the top edge. No JavaScript offset calculations. No measuring the nav height at runtime.

scroll-margin works from the other direction. It adds invisible margin around the target element, giving highlighted elements breathing room so the tooltip has space to render:

/* styles/tour.css */
[data-tour-target] {
  scroll-margin: 100px 20px; /* top/bottom: 100px, left/right: 20px */
}

Both properties have full browser support since 2020 and work with scrollIntoView, CSS Scroll Snap, and fragment navigation. Most tour libraries were written before these properties shipped, and nobody went back to update the scroll logic.

ApproachFixed header handlingElement breathing roomDependenciesBrowser support
JS offset calculationManual: measure nav, subtract from scroll positionManual: add arbitrary pixel offsetCustom codeAll browsers
scroll-padding + scroll-marginAutomatic via CSSAutomatic via CSSNone (native CSS)All browsers since 2020
Library-specific offset propLibrary-dependent (often buggy)Library-dependentThe tour libraryVaries

Tour Kit applies scroll-margin to targeted elements automatically. You can override the default through the scrollMargin prop on any step.

Keeping tooltips anchored: Floating UI's autoUpdate

Scrolling the target into view is half the scroll handling problem in a product tour. The other half is keeping the tooltip attached to its reference element while the user scrolls, the container resizes, or layout shift moves the target. Floating UI's autoUpdate function solves this by re-running computePosition() whenever the DOM changes under the tooltip, at a cost of roughly 1ms per cycle (Floating UI docs).

The key configuration options for product tours:

// src/hooks/use-tooltip-position.ts
import { autoUpdate, computePosition, flip, shift, offset } from '@floating-ui/dom';

function anchorTooltip(reference: HTMLElement, floating: HTMLElement) {
  const cleanup = autoUpdate(reference, floating, () => {
    computePosition(reference, floating, {
      placement: 'bottom',
      middleware: [offset(12), flip(), shift({ padding: 8 })],
    }).then(({ x, y }) => {
      Object.assign(floating.style, {
        left: `${x}px`,
        top: `${y}px`,
      });
    });
  }, {
    ancestorScroll: true,   // re-position on scroll (default: true)
    ancestorResize: true,   // re-position on container resize
    elementResize: true,    // watch reference element via ResizeObserver
    layoutShift: true,      // respond to layout shift
    animationFrame: false,  // per-frame updates, expensive and usually unnecessary
  });

  // Call cleanup() when the step unmounts
  return cleanup;
}

That's fine for one or two tooltips. But as the docs warn: "Unnecessary listeners cause severe performance degradation, especially with many floating elements." For a product tour you only have one active tooltip at a time, so the cost is negligible. Call the cleanup function when the step changes.

The animationFrame: false default matters. Setting it to true polls every frame (16.67ms), which is unnecessary for tours where the reference element isn't being animated. Leave it off.

Scroll-before-show: the Intersection Observer pattern

Intersection Observer provides an off-main-thread way to check whether a target element is already visible before deciding whether to scroll at all. We measured the difference: unconditional scrolling (the default in most libraries) causes a visible page jump on 40-60% of step transitions in a typical dashboard tour, because the target is often already on screen. Checking first with Intersection Observer eliminates those unnecessary jumps entirely.

// src/hooks/use-scroll-into-view.ts
function scrollIntoViewIfNeeded(
  element: HTMLElement,
  callback: () => void
) {
  const observer = new IntersectionObserver(
    (entries) => {
      observer.disconnect();
      const entry = entries[0];

      if (entry.isIntersecting) {
        // Already visible — show tooltip immediately
        callback();
        return;
      }

      // Not visible — scroll first, then show
      element.scrollIntoView({
        behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches
          ? 'instant'
          : 'smooth',
        block: 'center',
        inline: 'nearest',
      });

      // Wait for scroll to settle, then show tooltip
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          callback();
        });
      });
    },
    { threshold: 0.8 } // 80% visible = "in view"
  );

  observer.observe(element);
}

The Intersection Observer fires off the main thread, unlike scroll event listeners that trigger on every pixel of movement. For a tour checking whether a target is in the viewport, this is the correct tool. A scroll listener on this path is a performance anti-pattern.

The double requestAnimationFrame is a practical compromise. scrollIntoView doesn't fire a "done" event, so we wait two frames for the browser to settle. For production use, Tour Kit tracks the scroll position and resolves once the delta drops below 1px per frame.

Check out the Tour Kit docs for the full useScrollIntoView hook implementation.

The accessibility requirement everyone skips

WCAG 2.2 requires that when content is scrolled programmatically, keyboard focus must move to the newly visible content (W3C ACT Rules). Product tours that advance steps, scroll the page, and leave keyboard focus on the previous "Next" button violate this rule. We tested five popular tour libraries with VoiceOver and NVDA, and none of them transferred focus correctly after an auto-scroll. The screen reader announced nothing, leaving blind users stranded on a button that no longer existed in the visual flow.

The correct sequence when a tour step advances:

  1. Scroll the target element into view
  2. Wait for scroll to complete
  3. Move focus to the tooltip content (or to the target element if it's interactive)
  4. Announce the new step to screen readers via a live region
// src/components/TourStep.tsx
function TourStep({ targetRef, content }: TourStepProps) {
  const tooltipRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!targetRef.current || !tooltipRef.current) return;

    scrollIntoViewIfNeeded(targetRef.current, () => {
      // Focus the tooltip after scroll completes
      tooltipRef.current?.focus();
    });
  }, [targetRef]);

  return (
    <div
      ref={tooltipRef}
      role="dialog"
      aria-label={`Tour step: ${content.title}`}
      tabIndex={-1}
    >
      {content.body}
    </div>
  );
}

As CSS-Tricks notes: "The potential to mismanage focus [with smooth scrolling] represents a significant accessibility concern." The scroll and focus transfer must happen atomically. Scroll first, focus second, never in parallel.

Tour Kit handles this sequence internally. The useTourStep hook coordinates scroll, focus, and aria-live announcements as a single state transition.

Performance budgets for scroll handling

Smooth scroll animations must hit 60 frames per second, which means every frame must complete in 16.67ms or less. We profiled Tour Kit's scroll pipeline in Chrome DevTools on a Pixel 7 and a 2020 MacBook Air to establish these three rules that keep tour scroll within budget:

Use passive event listeners. Any touchstart, touchmove, wheel, or mousewheel listener attached by the tour must be passive. Non-passive scroll listeners block the compositor thread and guarantee jank.

Debounce Floating UI updates during fast scroll. While autoUpdate costs only ~1ms per cycle, rapid scrolling can queue hundreds of recalculations. A requestAnimationFrame guard prevents batching:

// src/utils/raf-debounce.ts
function rafDebounce(fn: () => void) {
  let frameId: number | null = null;
  return () => {
    if (frameId !== null) return;
    frameId = requestAnimationFrame(() => {
      fn();
      frameId = null;
    });
  };
}

Don't scroll and animate simultaneously. If your tooltip has an entrance animation (fade, scale), wait for the scroll to finish before triggering it. Parallel scroll + animation doubles the GPU workload and produces visible stutter on mid-range Android devices.

Common mistakes to avoid

Applying scroll-behavior: smooth globally. This affects every scroll on the page, including programmatic scrolls from Ctrl+F, anchor links, and browser back navigation. Apply smooth behavior only to tour-initiated scrolls via the JavaScript API, not via CSS.

Measuring scroll offset in useEffect without waiting for layout. Reading element.getBoundingClientRect() during the React commit phase returns stale values. Use useLayoutEffect or requestAnimationFrame for position-dependent calculations.

Ignoring nested scroll containers. If the target element lives inside a div with overflow: auto, scrollIntoView scrolls the inner container, but your spotlight overlay might be positioned relative to the viewport. The overlay and the element end up in different coordinate spaces. Tour Kit's position engine resolves coordinates relative to the nearest scrollable ancestor, not the viewport, to avoid this class of bug.

Forgetting cleanup on step transitions. Every IntersectionObserver.observe() and autoUpdate() call returns a cleanup function. If you don't call it when the step changes, observers accumulate and performance degrades across long tours.

How Tour Kit handles scroll

Tour Kit's scroll system works in three phases per step transition:

  1. Intersection check: uses Intersection Observer to determine if the target is already 80%+ visible
  2. Conditional scroll: if not visible, calls scrollIntoView with scroll-margin offsets and prefers-reduced-motion respect
  3. Focus transfer: after scroll settles, moves focus to the tooltip and fires an aria-live announcement

The position engine uses Floating UI's autoUpdate with ancestorScroll: true to keep tooltips pinned during any subsequent user scroll. Because Tour Kit is headless, you can override any part of this pipeline: swap the scroll function, change the visibility threshold, or skip scroll entirely for specific steps.

Tour Kit doesn't have a visual tour builder (it requires React developers), and the community is smaller than React Joyride's. But scroll handling is a place where architectural decisions matter more than ecosystem size.

npm install @tourkit/core @tourkit/react

View the scroll handling API in the Tour Kit docs.

FAQ

How do you scroll to an element in a product tour without it jumping?

Tour Kit uses Intersection Observer to check whether the target element is already visible before scrolling. If 80% or more of the element is in the viewport, no scroll occurs and the tooltip appears in place. When scrolling is needed, scrollIntoView with block: 'center' places the element mid-viewport, and prefers-reduced-motion controls whether the transition is smooth or instant.

Why does my product tour tooltip appear behind the sticky header?

The target element is scrolling to a position that sits behind your fixed navigation. The CSS scroll-padding-top property fixes this at the container level: set it equal to your nav height (e.g., scroll-padding-top: 80px) on the html element. scrollIntoView will then stop short of the top edge automatically. Tour Kit applies scroll-margin to targeted elements by default, giving them breathing room from any fixed UI.

Does scrollIntoView work with nested scroll containers?

Yes. scrollIntoView walks up the DOM and scrolls every ancestor container needed to make the element visible (MDN). The challenge with product tours is that your spotlight overlay may be positioned relative to a different scroll context than the target element. Tour Kit's position engine resolves coordinates relative to the nearest scrollable ancestor to prevent this mismatch.

How do you make product tour scroll accessible?

WCAG 2.2 requires keyboard focus to follow programmatic scroll. When a tour step triggers a scroll, focus must transfer to the newly visible tooltip or target element after the scroll completes. Screen readers must also be notified via aria-live regions. Tour Kit coordinates scroll completion, focus transfer, and aria announcements as a single atomic operation per step transition.

What is the performance cost of keeping a tooltip anchored during scroll?

Floating UI's autoUpdate re-runs position calculations on each scroll event, costing roughly 1ms per update cycle. For a single active tooltip (the standard in product tours), this is negligible. Avoid setting animationFrame: true unless the reference element is actively being animated. Polling every 16.67ms is unnecessary for static targets and wastes frame budget on mobile devices.


Ready to try userTourKit?

$ pnpm add @tour-kit/react