Fix guided tour tooltip going off-screen on mobile

Body scroll is locked for the tour's duration, so scrollIntoView on the
step-2 target had nothing to scroll and its position was measured way
off-screen. Scroll the frozen body's offset directly instead, and clamp
tooltip vertical position to the viewport as a safety net.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
chris 2026-08-19 11:27:44 -04:00
parent f6960fd3ec
commit 219256be55
2 changed files with 18 additions and 7 deletions

View File

@ -1,7 +1,7 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useLockBodyScroll } from '@/lib/useLockBodyScroll'
import { useLockBodyScroll, scrollLockedBodyTo } from '@/lib/useLockBodyScroll'
interface TourStep {
target: string | null // CSS selector, or null = centered modal
@ -54,8 +54,9 @@ const STEPS: TourStep[] = [
},
]
const PAD = 10 // px padding around spotlight
const TIP_WIDTH = 300 // tooltip width in px
const PAD = 10 // px padding around spotlight
const TIP_WIDTH = 300 // tooltip width in px
const TIP_MAX_HEIGHT = 260 // conservative estimate, used to keep the tooltip on-screen
interface Props {
onDone: () => void
@ -103,7 +104,11 @@ export default function GuidedTour({ onDone, onStart }: Props) {
const elRect = el.getBoundingClientRect()
scrollParent.scrollTop = elRect.top - parentRect.top + scrollParent.scrollTop - 20
} else {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
// Page scroll is locked (useLockBodyScroll), so scrollIntoView has nothing
// to scroll — shift the frozen body's offset instead so the target actually moves into view.
const lockedY = Math.abs(parseFloat(document.body.style.top || '0')) || 0
const elRect = el.getBoundingClientRect()
scrollLockedBodyTo(lockedY + elRect.top - 100)
}
// Measure after layout settles (double-rAF ensures paint is done)
requestAnimationFrame(() => {
@ -179,7 +184,7 @@ export default function GuidedTour({ onDone, onStart }: Props) {
)
tooltipStyle = {
position: 'fixed',
top: spot.top + spot.height + 12,
top: Math.max(8, Math.min(spot.top + spot.height + 12, vh - TIP_MAX_HEIGHT - 8)),
left,
width: Math.min(TIP_WIDTH, vw - 16),
zIndex: 10002,
@ -191,7 +196,7 @@ export default function GuidedTour({ onDone, onStart }: Props) {
)
tooltipStyle = {
position: 'fixed',
bottom: vh - spot.top + 12,
bottom: Math.max(8, Math.min(vh - spot.top + 12, vh - TIP_MAX_HEIGHT - 8)),
left,
width: Math.min(TIP_WIDTH, vw - 16),
zIndex: 10002,
@ -200,7 +205,7 @@ export default function GuidedTour({ onDone, onStart }: Props) {
// right
tooltipStyle = {
position: 'fixed',
top: Math.max(8, spot.top + spot.height / 2 - 80),
top: Math.max(8, Math.min(spot.top + spot.height / 2 - 80, vh - TIP_MAX_HEIGHT - 8)),
left: Math.min(spot.left + spot.width + 12, vw - TIP_WIDTH - 8),
width: Math.min(TIP_WIDTH, vw - 16),
zIndex: 10002,

View File

@ -25,3 +25,9 @@ export function useLockBodyScroll() {
}
}, [])
}
/** While the page is frozen by useLockBodyScroll, there's no real scroll to move
* "scrolling" means shifting the fixed body's negative `top` offset instead. */
export function scrollLockedBodyTo(y: number) {
document.body.style.top = `-${Math.max(0, y)}px`
}