Implementing Scroll-Driven Interactions with IntersectionObserver in Cloned Components
Use the native Intersection Observer API inside a custom React hook to detect when elements enter the viewport, then apply conditional Tailwind classes via the cn() utility to create scroll-based navigation highlighting and animations in any cloned component.
The ai-website-cloner-template provides a modern Next.js 16 foundation with TypeScript strict mode, Tailwind v4, and shadcn/ui primitives. When cloning websites that require scroll-driven behaviors—such as sticky navigation highlighting or section-triggered animations—you need a performant, reusable solution that doesn't rely on expensive scroll event listeners. The browser's native Intersection Observer API paired with a custom React hook provides exactly that, integrating seamlessly with the template's existing architecture in src/lib/utils.ts and src/components/ui/.
Understanding the Architecture
The template separates concerns across distinct layers, making it straightforward to add scroll-driven functionality without modifying core UI primitives.
| Layer | Purpose | Key File |
|---|---|---|
| Utility helpers | Tailwind class merging and type-safe utilities | src/lib/utils.ts |
| UI primitives | Reusable shadcn/ui components (buttons, cards, etc.) | src/components/ui/button.tsx |
| Custom hooks | Encapsulated reusable logic for browser APIs | src/hooks/useIntersectionObserver.ts |
| Page components | Route-level components consuming hooks and UI | src/app/page.tsx |
By placing Intersection Observer logic in a dedicated hook layer, you keep presentational components pure while enabling any cloned component—tabs, carousels, or section navigators—to react to viewport changes.
Creating the useIntersectionObserver Hook
Create src/hooks/useIntersectionObserver.ts to encapsulate the native API with automatic cleanup and TypeScript safety.
// src/hooks/useIntersectionObserver.ts
import { useEffect, useState, RefObject } from "react";
type Options = IntersectionObserverInit;
export function useIntersectionObserver<T extends Element>(
ref: RefObject<T>,
options?: Options
): { isIntersecting: boolean; entry?: IntersectionObserverEntry } {
const [state, setState] = useState<{
isIntersecting: boolean;
entry?: IntersectionObserverEntry;
}>({ isIntersecting: false });
useEffect(() => {
const node = ref?.current;
if (!node) return;
const observer = new IntersectionObserver(
([entry]) => {
setState({ isIntersecting: entry.isIntersecting, entry });
},
options
);
observer.observe(node);
return () => {
observer.unobserve(node);
observer.disconnect();
};
}, [ref, options?.root, options?.rootMargin, options?.threshold]);
return state;
}
The hook returns an isIntersecting boolean and the full entry object, giving components access to intersection ratios and bounding rectangles. The cleanup function prevents memory leaks when components unmount—a critical consideration for cloned components that may be dynamically added or removed.
Implementing Scroll-Driven Navigation Highlighting
Here's how to build a sticky navigation that automatically highlights the active section as users scroll, using the hook in src/app/page.tsx.
// src/app/page.tsx
import { cn } from "@/src/lib/utils";
import { useRef } from "react";
import { useIntersectionObserver } from "@/src/hooks/useIntersectionObserver";
export default function HomePage() {
const sections = [
{ id: "intro", title: "Introduction" },
{ id: "features", title: "Features" },
{ id: "pricing", title: "Pricing" },
];
return (
<main className="flex flex-col">
<nav className="sticky top-0 bg-background z-10 flex gap-4 p-4 shadow">
{sections.map((sec) => (
<NavItem key={sec.id} targetId={sec.id} label={sec.title} />
))}
</nav>
{sections.map((sec) => (
<Section key={sec.id} id={sec.id} title={sec.title} />
))}
</main>
);
}
function NavItem({ targetId, label }: { targetId: string; label: string }) {
const ref = useRef<HTMLDivElement>(null);
const { isIntersecting } = useIntersectionObserver(ref, {
rootMargin: "-50% 0px -50% 0px",
});
return (
<div ref={ref}>
<a
href={`#${targetId}`}
className={cn(
"text-sm font-medium transition-colors",
isIntersecting ? "text-primary" : "text-muted-foreground"
)}
>
{label}
</a>
</div>
);
}
function Section({ id, title }: { id: string; title: string }) {
return (
<section id={id} className="min-h-screen py-20">
<h2 className="text-3xl font-bold">{title}</h2>
<p className="mt-4 text-base">
Content for {title} section...
</p>
</section>
);
}
The rootMargin: "-50% 0px -50% 0px" option triggers the observer when the section is roughly centered in the viewport, creating an intuitive "active" state that feels responsive to the user's reading position.
Building Reusable Scroll-Triggered UI Components
Extend the pattern to create interactive elements that respond to scroll position. This ScrollButton component demonstrates opacity changes based on section visibility.
// src/components/ui/ScrollButton.tsx
import { Button } from "./button";
import { useRef } from "react";
import { useIntersectionObserver } from "@/src/hooks/useIntersectionObserver";
export function ScrollButton({ targetId }: { targetId: string }) {
const ref = useRef<HTMLDivElement>(null);
const { isIntersecting } = useIntersectionObserver(ref, {
rootMargin: "-80% 0px -20% 0px",
});
const scrollTo = () => {
document.getElementById(targetId)?.scrollIntoView({ behavior: "smooth" });
};
return (
<div ref={ref}>
<Button
onClick={scrollTo}
className={cn(
"transition-opacity",
isIntersecting ? "opacity-100" : "opacity-50"
)}
>
Go to {targetId}
</Button>
</div>
);
}
The cn() utility from src/lib/utils.ts cleanly handles conditional class application without template string concatenation, maintaining type safety and preventing class conflicts.
Configuring IntersectionObserver Options for Different Scroll Behaviors
Different cloned components require different intersection thresholds. Adjust these options in your hook calls:
| Option | Use Case | Example Value |
|---|---|---|
threshold |
Trigger when specific percentage of element is visible | 0.25 (25% visible) |
rootMargin |
Offset the trigger point (positive expands, negative shrinks) | "-100px 0px" (100px buffer) |
root |
Observe against a specific scroll container instead of viewport | scrollContainerRef.current |
For lazy-loaded images in cloned galleries, use threshold: 0.1 with a small rootMargin to preload just before visibility. For scroll spy navigation, use negative rootMargin values to trigger at the section center rather than edge.
Performance Considerations for Cloned Components
The native Intersection Observer API runs on a separate thread, avoiding the main-thread blocking common with scroll event listeners. The template's strict TypeScript configuration ensures your hook implementations remain type-safe:
- Single observer per element: The hook creates one observer instance per
useEffectcall, disconnected on unmount - Memoized options: The dependency array includes
optionsproperties to prevent unnecessary recreations - Ref-based observation: Passing DOM nodes directly avoids React state overhead for the observer itself
When cloning complex websites with many scroll-driven elements, consider using a single IntersectionObserverContext to share observer instances across multiple components, reducing memory overhead from many individual observers.
Summary
- Create
src/hooks/useIntersectionObserver.tsto encapsulate the native API with automatic cleanup and TypeScript generics - Use
rootMarginandthresholdoptions to fine-tune when interactions trigger—centered sections need different values than edge-triggered animations - Apply conditional classes via
cn()fromsrc/lib/utils.tsto maintain consistent styling patterns across cloned components - Place refs on wrapper elements rather than interactive elements themselves to avoid disrupting event handling
- Clean up observers on unmount via the returned cleanup function to prevent memory leaks in dynamic cloned layouts
Frequently Asked Questions
How do I observe multiple sections with a single scroll spy navigation?
Create a separate NavItem component for each section, each with its own useIntersectionObserver call and ref. This pattern scales to any number of sections without complex state management, though for very large lists (50+ items) consider a single observer with multiple targets and a shared state object.
Can I use this with server-side rendering in Next.js 16?
Yes—the Intersection Observer API only runs in the browser, so the hook safely returns isIntersecting: false during SSR. The useEffect hook ensures observer creation only happens client-side, compatible with the template's App Router architecture and npm run build output.
Why does my observer trigger immediately on page load?
The observer evaluates elements against the viewport immediately upon creation. If the element is already visible, isIntersecting starts as true. To prevent initial animation triggers, add a useState flag that ignores the first intersection or use CSS animation-fill-mode to handle initial states.
How do I animate elements as they scroll into view from different directions?
Use the entry object returned by the hook, specifically entry.boundingClientRect and entry.rootBounds, to determine scroll direction. Combine with CSS transforms or animation libraries like Framer Motion, wiring the direction detection to the isIntersecting state change.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →