Building a Universal "Scroll-To" Hook in React & Next.js
On a landing page, navigation usually involves smooth scrolling to sections. However, once you add multiple pages (like a Blog or FAQ), a simple element.scrollIntoView() isn't enough. You need a way to navigate back to the home page and then scroll to the section.
The Math Behind the Scroll
To get the exact position for window.scrollTo, we use a specific formula. Think of it like finding a specific page in a book:
getBoundingClientRect().top: How far the element is from the current top of your screen.window.scrollY: How far you have already scrolled down the page.offset: Space for your sticky header so it doesn't cover the section title.
The Formula: Target = Viewport Top + Current Scroll - Offset
The Implementation: useScrollTo
This hook handles three scenarios:
External Links: Redirects normally.
Same-page Scroll: Scrolls smoothly and updates the URL hash.
Cross-page Scroll: Navigates to the correct page first, then scrolls after the page loads.
JavaScript
import { useRouter } from "next/router";
/**
* A universal hook for smooth scrolling that handles
* both same-page and cross-page navigation.
*/
export function useScrollTo() {
const router = useRouter();
const scrollTo = (href, offset = 0) => {
if (typeof window === "undefined") return;
// 1. Handle External Links
if (href.startsWith("http")) {
window.location.href = href;
return;
}
const targetId = href.startsWith("#") ? href.substring(1) : href;
const targetElement = document.getElementById(targetId);
// 2. Scenario: Element exists on current page
if (targetElement) {
const targetPosition =
targetElement.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top: targetPosition, behavior: "smooth" });
// Update URL hash without a full page refresh
window.history.pushState(null, "", `#${targetId}`);
}
// 3. Scenario: Element is on a different page
else {
// Navigate to the root (or specific page) with the hash
router.push(`/#${targetId}`).then(() => {
// Wait briefly for the new page to mount
setTimeout(() => {
const newTarget = document.getElementById(targetId);
if (newTarget) {
const newPos = newTarget.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top: newPos, behavior: "smooth" });
}
}, 150);
});
}
};
return scrollTo;
}
How to Use It
Simply call the hook in your component and pass the ID of the section you want to reach.
JavaScript
const scrollTo = useScrollTo();
<Button onClick={() => scrollTo("pricing-section", 80)}>
View Pricing
</Button>
Why This Approach is Better:
URL Synchronicity: It uses
window.history.pushState. If the user refreshes or shares the link, the#idstays in the URL, allowing the browser to find the spot again.The "Header Gap" Fix: Most landing pages have a sticky navbar. By passing an
offset, you prevent the navbar from overlapping your section headings.Next.js Integration: Using
router.push().then()ensures that we only attempt to scroll after Next.js has finished the route transition.
Pro-Tip: Why the setTimeout?
In the cross-page scenario, we use a small delay (100ms–150ms). This is because Next.js needs a split second to render the DOM elements on the new page before document.getElementById can actually find them.