Quando navegamos em landing pages modernas, é comum vermos o menu se adaptando automaticamente conforme o usuário rola a página. Esse efeito é conhecido como scrollspy, e melhora a experiência de navegação, além de dar um toque profissional ao site.
Neste artigo, vamos demonstrar como implementar um scrollspy moderno utilizando React e a API nativa IntersectionObserver
, garantindo precisão, performance e manutenção limpa.
Por que devemos evitar scrollY
e offsetTop
O window.scrollY
ainda é comumente utilizado, geralmente em conjunto com element.offsetTop
ou getBoundingClientRect()
. Embora funcionem, essas abordagens apresentam desvantagens importantes:
- Exigir cálculos manuais e constantes;
- Precisam de event listeners em
scroll
(impactando a performance); - Reagem mal a mudanças de layout ou responsividade.
Utilizando o
IntersectionObserver
, podemos observar elementos e reagir automaticamente à visibilidade real deles na tela. Ele notifica o navegador quando um elemento entra ou sai da tela, com menos código e mais eficiência, já que foi projetado exatamente para esse tipo de tarefa.
O IntersectionObserver
IntersectionObserver
é uma API nativa do navegador que permite detectar quando um elemento entra ou sai da área visível (viewport).
Quando utilizar:
- Scrollspy
- Lazy loading de imagens
- Animações disparadas no scroll
- Análise de comportamento do usuário
Exemplo simples de como a API funciona
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if(entry.isIntersecting) {
console.log(`${entry.target.id} está visível`);
}
});
});
observer.observe(document.getElementById("home"));
O código acima começa a observar a seção com
id="home"
e imprime no console quando ela se torna visível na tela.
Criação do hook useActiveSection
com React
Aqui o objetivo é criar um hook reutilizável responsável por detectar qual seção da página está mais visível. Isso será usado para destacar automaticamente o item ativo no menu.
Como funciona
- Recebe refs para as seções;
- Usa o
IntersectionObserver
para monitorar visibilidade; - Calcula a área visível real de cada seção;
- Atualiza o estado com a seção mais visível;
- Utiliza
MutationObserver
para aguardar dinamicamente o momento em que as refs se tornam disponíveis. - Utiliza
RequestAnimationFrame
para garantir que o DOM esteja completamente renderizado antes de inicializar oIntersectionObserver
. Isso é essencial para compatibilidade com React 18+, que utiliza o modoStrict
em desenvolvimento e pode montar componentes duas vezes. - (Opcional) Utiliza
requestAnimationFrame
como fallback para garantir a ativação correta da primeira seção visível. Útil em cenários onde o conteúdo estático não dispara oIntersectionObserver
imediatamente, como após SSR ou renderização instantânea de várias seções fora do viewport.
Código do hook
import React, { useEffect, useRef, useState } from "react";
interface SectionRefMap {
}
export function useActiveSection(sectionRefs: SectionRefMap, rootMargin = "-40% 0px -40% 0px", enableFallback = true) {
const [activeSection, setActiveSection] = useState<string | null>(null);
const observer = useRef<IntersectionObserver | null>(null);
const currentId = useRef<string | null>(null);
const hasInitialized = useRef(false);
const fallbackRafId = useRef<number | null>(null);
useEffect(() => {
const refEntries = Object.entries(sectionRefs);
const allAvailableSections = refEntries.every(([, ref]) => ref.current !== null);
// Prevent multiple initializations
if (hasInitialized.current) return;
if (allAvailableSections) {
requestAnimationFrame(() => {
if (!hasInitialized.current) {
initializeObserver();
}
})
} else {
// Wait until all refs are assigned
const mo = new MutationObserver(() => {
const ready = refEntries.every(([, ref]) => ref.current !== null);
if (ready) {
mo.disconnect();
requestAnimationFrame(() => {
if (!hasInitialized.current) {
initializeObserver();
}
})
}
});
mo.observe(document.body, {
childList: true,
subtree: true,
});
return () => mo.disconnect();
}
function initializeObserver() {
if(hasInitialized.current) return;
hasInitialized.current = true;
// Create observer to track visible sections
observer.current = new IntersectionObserver(
(observerEntries) => {
const visibleEntries = observerEntries
.filter(entry => entry.isIntersecting)
.map((entry) => ({
entry,
area:
entry.intersectionRect.width * entry.intersectionRect.height,
}))
.sort((a, b) => b.area - a.area);
// Set the section with the largest visible area
if (visibleEntries.length > 0) {
const id = visibleEntries[0].entry.target.getAttribute("id");
if (id && id !== currentId.current) {
currentId.current = id;
setActiveSection(id);
}
}
},
{
rootMargin,
threshold: Array.from({ length: 11 }, (_, i) => i / 10),
}
);
// Start observing all section elements
for (const [, ref] of refEntries) {
if (ref.current) {
observer.current.observe(ref.current);
}
}
// Optional: fallback using requestAnimationFrame to detect visible sections on first render
if (enableFallback) {
fallbackRafId.current = requestAnimationFrame(() => {
const visibleSections = refEntries
.map(([key, ref]) => {
if (!ref.current) return null;
const rect = ref.current.getBoundingClientRect();
const visibleHeight = Math.max(0, Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0));
const visibleWidth = Math.max(0, Math.min(rect.right, window.innerWidth) - Math.max(rect.left, 0));
const area = visibleHeight * visibleWidth;
return { key, area };
})
.filter(Boolean)
.sort((a, b) => b!.area - a!.area);
if (visibleSections.length > 0) {
const firstVisibleSection = visibleSections[0]!;
if (firstVisibleSection?.key !== currentId.current) {
currentId.current = firstVisibleSection.key;
setActiveSection(firstVisibleSection.key);
}
}
});
}
}
// Cleanup: unobserve all sections
return () => {
if(observer.current) {
for (const [, ref] of refEntries) {
if (ref.current && observer.current) {
observer.current.unobserve(ref.current);
}
}
observer.current?.disconnect();
observer.current = null;
}
if(fallbackRafId.current !== null) {
cancelAnimationFrame(fallbackRafId.current);
fallbackRafId.current = null;
}
};
}, [sectionRefs, rootMargin]);
return activeSection;
}
Como usar
No componente Header ou outro, pode-se usar o hook assim:
const homeRef = useRef<HTMLElement>(null!);
const aboutRef = useRef<HTMLElement>(null!);
const projectsRef = useRef<HTMLElement>(null!);
const contactRef = useRef<HTMLElement>(null!);
const sectionRefs = useMemo(() => ({
home: homeRef,
about: aboutRef,
projects: projectsRef,
contact: contactRef,
}), []);
const activeSection = useActiveSection(sectionRefs);
useMemo
é usado aqui para garantir que o objetosectionRefs
não seja recriado em cada renderização, o que evita reexecutar o efeito do hook.
E devemos destacar o item ativo no menu:
<a
href="#home"
className={activeSection === "home" ? "active" : ""}
>
Início
</a>
Benefícios
- Alta performance (sem polling);
- Robusto mesmo com conteúdo assíncrono;
- Reutilizável com qualquer estrutura de seções;
- Preciso, mesmo com animações ou conteúdo que muda dinamicamente;
- Ideal para landing pages, documentações e single-page apps.
Conclusão
O IntersectionObserver
é uma ferramenta poderosa. Ao ser combinada com React, permite criar experiências modernas, performáticas e responsivas. Se você está com vontade de sair da bolha do scrollY
e entrar no mundo dos eventos inteligentes, esse é o caminho.
Se você curtiu o artigo, compartilhe com outros colegas desenvolvedores e siga para mais conteúdo como este!
Veja o código no GitHub.
Top comments (0)