DEV Community

Evenilson Liandro
Evenilson Liandro

Posted on

Como criar um scrollspy moderno com React e IntersectionObserver

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"));
Enter fullscreen mode Exit fullscreen mode

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

  1. Recebe refs para as seções;
  2. Usa o IntersectionObserver para monitorar visibilidade;
  3. Calcula a área visível real de cada seção;
  4. Atualiza o estado com a seção mais visível;
  5. Utiliza MutationObserver para aguardar dinamicamente o momento em que as refs se tornam disponíveis.
  6. Utiliza RequestAnimationFrame para garantir que o DOM esteja completamente renderizado antes de inicializar o IntersectionObserver. Isso é essencial para compatibilidade com React 18+, que utiliza o modo Strict em desenvolvimento e pode montar componentes duas vezes.
  7. (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 o IntersectionObserver 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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

useMemo é usado aqui para garantir que o objeto sectionRefs 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>
Enter fullscreen mode Exit fullscreen mode

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.

Conteúdos relacionados

Top comments (0)