export function OnboardingHighlight({
isActive,
spotlight,
onActionComplete,
}: Readonly<OnboardingHighlightProps>) {
const [position, setPosition] = useState<HighlightPosition | null>(null);
const [currentSpotlight, setCurrentSpotlight] = useState<
ElementSpotlight | undefined
>(spotlight);
const spotlightRef = useRef<HTMLDivElement>(null);
const attachedElementsRef = useRef<WeakSet<HTMLElement>>(new WeakSet());
const boundListenersRef = useRef<
Map<HTMLElement, { listener: (e: Event) => void; useCapture: boolean }>
>(new Map());
// Sync tab switching: auto-switch spotlight when sync tab is clicked
useEffect(() => {
if (!isActive || !spotlight?.selector?.includes("sync-tab")) {
setCurrentSpotlight(spotlight);
return;
}
// Listen for sync tab click to switch spotlight to sync settings
const syncTab = document.querySelector('[data-onboarding="sync-tab"]');
if (!syncTab) return;
const handleSyncTabClick = () => {
// Transition spotlight to sync-settings when sync tab is clicked
setCurrentSpotlight({
selector: '[data-onboarding="sync-settings"]',
padding: 12,
borderRadius: 8,
});
};
syncTab.addEventListener("click", handleSyncTabClick);
return () => {
syncTab.removeEventListener("click", handleSyncTabClick);
};
}, [isActive, spotlight]);
// Dismiss spotlight when sync button is clicked
useEffect(() => {
if (!isActive || !currentSpotlight?.selector?.includes("sync-button")) {
return;
}
const syncButton = document.querySelector(
'[data-onboarding="sync-button"]',
);
if (!syncButton || !(syncButton instanceof HTMLElement)) return;
const handleSyncButtonClick = (e: Event) => {
// Prevent default behavior and stop propagation
e.stopPropagation();
// Clear spotlight on button click
setPosition(null);
setCurrentSpotlight(undefined);
onActionComplete?.();
};
// Track attached listeners to prevent duplicate event handlers
if (!attachedElementsRef.current.has(syncButton)) {
syncButton.addEventListener("click", handleSyncButtonClick, true);
attachedElementsRef.current.add(syncButton);
boundListenersRef.current.set(syncButton, {
listener: handleSyncButtonClick,
useCapture: true,
});
}
return () => {
// Clean up listener only if element still exists in DOM
if (
attachedElementsRef.current.has(syncButton) &&
document.contains(syncButton)
) {
const stored = boundListenersRef.current.get(syncButton);
if (stored) {
syncButton.removeEventListener(
"click",
stored.listener,
stored.useCapture,
);
boundListenersRef.current.delete(syncButton);
}
}
attachedElementsRef.current = new WeakSet();
};
}, [isActive, currentSpotlight, onActionComplete]);
// Update spotlight when configuration changes
useEffect(() => {
// Dismiss spotlight when undefined
if (!spotlight) {
setCurrentSpotlight(undefined);
setPosition(null);
return;
}
// Switch spotlight selector: exit animation, then update
if (spotlight?.selector !== currentSpotlight?.selector) {
setPosition(null);
// Wait for exit animation to complete before updating
const timeoutId = setTimeout(() => {
setCurrentSpotlight(spotlight);
}, 50);
return () => clearTimeout(timeoutId);
}
}, [spotlight, currentSpotlight?.selector]);
// Track element position and DOM changes
useEffect(() => {
if (!isActive || !currentSpotlight) {
setPosition(null);
return;
}
const updatePosition = () => {
const element = resolveElement(currentSpotlight);
if (element) {
const rect = element.getBoundingClientRect();
const padding = currentSpotlight.padding ?? 8;
setPosition({
top: rect.top - padding + window.scrollY,
left: rect.left - padding + window.scrollX,
width: rect.width + padding * 2,
height: rect.height + padding * 2,
});
}
};
// Clear spotlight when element is no longer available
const handleElementNotFound = () => {
setPosition(null);
setCurrentSpotlight(undefined);
};
// Initial position calculation
updatePosition();
let isMounted = true;
let isObserverActive = false;
// Watch for DOM mutations to recompute position on element changes
const observer = new MutationObserver(() => {
if (!isMounted) return;
const element = resolveElement(currentSpotlight);
if (element) {
updatePosition();
} else if (isObserverActive) {
// Element was removed from DOM
handleElementNotFound();
}
});
// Start observing subtree changes on document body
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: false,
});
isObserverActive = true;
// Handle window scroll and resize events
const handlePositionChange = () => {
if (!isMounted) return;
const element = resolveElement(currentSpotlight);
if (!element) {
// Element disappeared during scroll/resize
handleElementNotFound();
observer.disconnect();
isObserverActive = false;
return;
}
updatePosition();
};
window.addEventListener("scroll", handlePositionChange, true);
window.addEventListener("resize", handlePositionChange);
return () => {
isMounted = false;
if (isObserverActive) {
observer.disconnect();
}
window.removeEventListener("scroll", handlePositionChange, true);
window.removeEventListener("resize", handlePositionChange);
};
}, [isActive, currentSpotlight]);
return (
<AnimatePresence>
{isActive && position && currentSpotlight && (
<>
{/* Spotlight cutout with glow */}
<motion.div
ref={spotlightRef}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="pointer-events-none fixed z-40"
style={{
top: position.top,
left: position.left,
width: position.width,
height: position.height,
borderRadius: currentSpotlight.borderRadius ?? 8,
}}
>
{/* Glowing border */}
<motion.div
animate={{
boxShadow: [
"0 0 20px rgba(59, 130, 246, 0.5), inset 0 0 10px rgba(59, 130, 246, 0.2)",
"0 0 30px rgba(59, 130, 246, 0.7), inset 0 0 15px rgba(59, 130, 246, 0.3)",
"0 0 20px rgba(59, 130, 246, 0.5), inset 0 0 10px rgba(59, 130, 246, 0.2)",
],
}}
transition={{ duration: 2, repeat: Number.POSITIVE_INFINITY }}
className="h-full w-full rounded-[inherit] border-2 border-blue-500"
/>
{/* Pulsing dot indicator (top-right) */}
<motion.div
animate={{
scale: [1, 1.2, 1],
opacity: [1, 0.6, 1],
}}
transition={{ duration: 2, repeat: Number.POSITIVE_INFINITY }}
className="absolute -right-2 -top-2 h-4 w-4 rounded-full bg-blue-500 shadow-lg"
/>
</motion.div>
{/* Floating pointer/arrow - positioned ABOVE element */}
<motion.div
animate={{
y: [0, 8, 0],
}}
transition={{ duration: 2, repeat: Number.POSITIVE_INFINITY }}
className="pointer-events-none fixed z-40 text-blue-500"
style={{
top: position.top - 32,
left: position.left + position.width / 2,
transform: "translateX(-50%)",
}}
>
<div className="text-2xl drop-shadow-lg">👇</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}
Renders a spotlight highlight around a DOM element with animated glow effects. Automatically tracks element position changes and maintains highlight positioning.
Features: