Eventos de Scroll
Responda a mudanças na posição de scroll para scroll infinito, cabeçalhos fixos, botões de scroll para o topo e indicadores de progresso.
Busque em todas as páginas da documentação
Responda a mudanças na posição de scroll para scroll infinito, cabeçalhos fixos, botões de scroll para o topo e indicadores de progresso.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
| Evento / Método | Dispara Quando | Objeto de Evento / Notas |
|---|---|---|
onScroll | A posição de scroll do elemento muda | React.UIEvent |
onScrollCapture | Igual a onScroll, mas durante a fase de captura | React.UIEvent |
window.addEventListener("scroll", ...) | A posição de scroll da página muda | Event nativo (use em useEffect) |
IntersectionObserver | Um elemento entra ou sai da viewport | Não é um evento -- baseado em API (use em useEffect) |
Propriedades chave em um elemento rolável:
| Propriedade | Tipo | Descrição |
|---|---|---|
scrollTop | number | Pixels rolados a partir do topo |
scrollLeft | number | Pixels rolados a partir da esquerda |
scrollHeight | number | Altura total rolável, incluindo o overflow |
clientHeight | number | Altura visível do elemento |
scrollWidth | number | Largura total rolável, incluindo o overflow |
clientWidth | number | Largura visível do elemento |
Cartão de receita de referência rápida -- pronto para copiar e colar.
// Evento de scroll do container
"use client";
import { useCallback } from "react";
export function ScrollableList() {
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
const scrollPercentage = scrollTop / (scrollHeight - clientHeight);
if (scrollPercentage > 0.9) {
// Perto do final -- carregar mais
}
}, []);
return (
<div className="h-96 overflow-y-auto" onScroll={handleScroll}>
{/* conteúdo */}
</div>
);
}// Listener de scroll da janela via useEffect
"use client";
import { useState, useEffect } from "react";
export function useWindowScroll() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = () => setScrollY(window.scrollY);
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return scrollY;
}Quando usar isso: Você precisa responder a quão longe um usuário rolou -- barras de progresso, carregamento preguiçoso (lazy loading), elementos fixos ou botões de voltar ao topo.
// ScrollProgressPage.tsx
"use client";
import { useState, useEffect, useCallback } from "react";
export default function ScrollProgressPage() {
const [progress, setProgress] = useState(0);
const [showBackToTop, setShowBackToTop] = useState(false);
useEffect(() => {
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } =
document.documentElement;
const totalScrollable = scrollHeight - clientHeight;
const currentProgress =
totalScrollable > 0 ? (scrollTop / totalScrollable) * 100 : 0;
setProgress(currentProgress);
setShowBackToTop(scrollTop > 400);
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const scrollToTop = useCallback(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
return (
<>
{/* Barra de progresso fixa no topo */}
<div className="fixed top-0 left-0 w-full h-1 bg-gray-200 z-50">
<div
className="h-full bg-blue-600 transition-[width] duration-100"
style={{ width: `${progress}%` }}
/>
</div>
{/* Conteúdo da página */}
<main className="max-w-2xl mx-auto p-6 pt-8">
<h1 className="text-3xl font-bold mb-6">Artigo Longo</h1>
{Array.from({ length: 20 }, (_, i) => (
<section key={i} className="mb-8">
<h2 className="text-xl font-semibold mb-2">Seção {i + 1}</h2>
<p className="text-gray-700 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris.
</p>
</section>
))}
</main>
{/* Botão Voltar ao topo */}
{showBackToTop && (
<button
onClick={scrollToTop}
className="fixed bottom-6 right-6 w-12 h-12 bg-blue-600 text-white
rounded-full shadow-lg flex items-center justify-center
hover:bg-blue-700 transition-colors"
aria-label="Voltar ao topo"
>
↑
</button>
)}
</>
);
}O que isso demonstra:
window.addEventListener("scroll", ...) em useEffect para rastreamento de scroll em nível de página{ passive: true } para otimização de desempenho do scrollonScroll dispara em qualquer elemento com overflow: auto ou overflow: scroll quando sua posição de scroll muda.onScroll do React no elemento raiz é não confiável -- use window.addEventListener("scroll", ...) dentro de useEffect em vez disso.IntersectionObserver é uma alternativa performática para detectar quando elementos entram na viewport, pois roda fora da thread principal.scrollHeight - clientHeight fornece o valor máximo de scrollTop para qualquer container rolável.Scroll infinito com IntersectionObserver:
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
type Item = { id: number; title: string };
export function InfiniteList() {
const [items, setItems] = useState<Item[]>(() =>
Array.from({ length: 20 }, (_, i) => ({ id: i, title: `Item ${i}` }))
);
const [loading, setLoading] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(null);
const loadMore = useCallback(async () => {
setLoading(true);
// Simular chamada de API
await new Promise((r) => setTimeout(r, 500));
setItems((prev) => {
const start = prev.length;
const next = Array.from({ length: 20 }, (_, i) => ({
id: start + i,
title: `Item ${start + i}`,
}));
return [...prev, ...next];
});
setLoading(false);
}, []);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !loading) {
loadMore();
}
},
{ rootMargin: "200px" }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [loading, loadMore]);
return (
<div className="max-w-md mx-auto">
{items.map((item) => (
<div key={item.id} className="p-4 border-b">
{item.title}
</div>
))}
<div ref={sentinelRef} className="h-4" />
{loading && <p className="text-center p-4">Carregando...</p>}
</div>
);
}Cabeçalho fixo ao rolar:
"use client";
import { useState, useEffect } from "react";
export function StickyHeader() {
const [isSticky, setIsSticky] = useState(false);
useEffect(() => {
const handleScroll = () => {
setIsSticky(window.scrollY > 80);
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<header
className={`w-full transition-all duration-200 ${
isSticky
? "fixed top-0 bg-white/95 backdrop-blur shadow-sm z-40"
: "relative bg-transparent"
}`}
>
<nav className="max-w-6xl mx-auto px-6 py-4">
<h1 className="text-lg font-bold">Meu Site</h1>
</nav>
</header>
);
}Restauração da posição de scroll:
"use client";
import { useEffect, useRef } from "react";
export function useScrollRestoration(key: string) {
const restored = useRef(false);
useEffect(() => {
// Restaurar posição
if (!restored.current) {
const saved = sessionStorage.getItem(`scroll-${key}`);
if (saved) {
window.scrollTo(0, Number(saved));
}
restored.current = true;
}
// Salvar posição ao rolar
const handleScroll = () => {
sessionStorage.setItem(`scroll-${key}`, String(window.scrollY));
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, [key]);
}Container de scroll horizontal:
"use client";
import { useRef, useCallback } from "react";
export function HorizontalScroll({ children }: { children: React.ReactNode }) {
const containerRef = useRef<HTMLDivElement>(null);
const scroll = useCallback((direction: "left" | "right") => {
containerRef.current?.scrollBy({
left: direction === "right" ? 300 : -300,
behavior: "smooth",
});
}, []);
return (
<div className="relative">
<button
onClick={() => scroll("left")}
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-white/80 p-2 rounded-full shadow"
aria-label="Scroll para a esquerda"
>
←
</button>
<div
ref={containerRef}
className="flex gap-4 overflow-x-auto snap-x snap-mandatory scrollbar-hide px-12"
>
{children}
</div>
<button
onClick={() => scroll("right")}
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 bg-white/80 p-2 rounded-full shadow"
aria-label="Scroll para a direita"
>
→
</button>
</div>
);
}Manipulador de scroll com debounce:
"use client";
import { useEffect, useRef, useState } from "react";
export function useDebouncedScroll(delay = 100) {
const [scrollY, setScrollY] = useState(0);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const handleScroll = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setScrollY(window.scrollY);
}, delay);
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => {
window.removeEventListener("scroll", handleScroll);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [delay]);
return scrollY;
}// Manipulador onScroll tipado para um elemento específico
function handleScroll(e: React.UIEvent<HTMLDivElement>) {
const target = e.currentTarget; // HTMLDivElement
const top: number = target.scrollTop;
const height: number = target.scrollHeight;
const visible: number = target.clientHeight;
}
// Scroll da janela em useEffect -- usa Event nativo, não React.UIEvent
useEffect(() => {
const handler = (e: Event) => {
// window.scrollY é a forma padrão de ler o scroll da página
console.log(window.scrollY);
};
window.addEventListener("scroll", handler);
return () => window.removeEventListener("scroll", handler);
}, []);
// Tipagem do IntersectionObserver
const observer = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
entries.forEach((entry: IntersectionObserverEntry) => {
const ratio: number = entry.intersectionRatio;
const isVisible: boolean = entry.isIntersecting;
const target: Element = entry.target;
});
},
{
root: null, // viewport
rootMargin: "0px",
threshold: [0, 0.25, 0.5, 0.75, 1.0],
} satisfies IntersectionObserverInit
);
// Tipagem de Ref para containers de scroll
const scrollRef = useRef<HTMLDivElement>(null);onScroll no body/document não funciona no React -- O onScroll do React só dispara em elementos que têm sua própria barra de rolagem, não na página em si. Correção: Use window.addEventListener("scroll", handler) dentro de useEffect para scroll em nível de página.
Manipuladores de scroll causam travamentos ao realizar trabalho caro -- onScroll dispara a cada frame durante o scroll ativo. Definir estado a cada evento causa re-renderizações que bloqueiam a thread principal. Correção: Use debounce no manipulador, use throttling com requestAnimationFrame ou mude para IntersectionObserver para verificações de visibilidade.
Falta { passive: true } em listeners de scroll da janela -- Sem essa dica, o navegador não pode otimizar o scroll porque espera para ver se preventDefault() é chamado. Correção: Sempre passe { passive: true } ao adicionar listeners de scroll que não chamam preventDefault().
scrollHeight é 0 na renderização inicial -- Se você lê scrollHeight durante a primeira renderização ou em um useEffect antes que o conteúdo seja pintado, o valor pode estar incorreto. Correção: Espere até depois do layout com useLayoutEffect ou meça dentro do próprio manipulador de scroll.
Posição de scroll perdida na re-renderização -- Quando itens são adicionados ao topo de uma lista (como um chat), a posição de scroll salta. Correção: Salve scrollTop antes da atualização e restaure-a depois usando useLayoutEffect, ou use a utilidade flushSync para atualizações síncronas do DOM.
IntersectionObserver dispara na montagem -- O callback dispara imediatamente quando observe() é chamado se o elemento já estiver na viewport. Correção: Proteja com um flag ou verifique entry.isIntersecting antes de acionar sua lógica de carregar mais.
scroll-behavior: smooth em CSS conflita com scrollTo programático -- Se o CSS aplica scroll suave globalmente, suas chamadas scrollTo({ behavior: "instant" }) ainda podem animar. Correção: Use behavior: "instant" explicitamente ou remova a regra CSS e aplique o scroll suave apenas via JS.
| Alternativa | Use Quando | Não Use Quando |
|---|---|---|
IntersectionObserver | Você precisa detectar a visibilidade do elemento (lazy load, scroll infinito) | Você precisa do valor exato da posição de scroll |
CSS position: sticky | Você quer um cabeçalho ou barra lateral fixos | Você precisa de lógica JS baseada no estado fixo |
CSS scroll-snap | Você quer scroll que "trava" em itens | Você precisa de controle programático sobre o comportamento de "travamento" |
react-virtuoso / @tanstack/virtual | Você tem milhares de itens em uma lista rolável | A lista é curta (menos de 100 itens) |
requestAnimationFrame throttle | Você precisa de animações suaves ligadas ao scroll | Um valor com debounce é suficiente |
function handleScroll(e: React.UIEvent<HTMLDivElement>) {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
if (scrollTop + clientHeight >= scrollHeight - 10) {
// No final ou perto do final
}
}onScroll do React só funciona em elementos que têm sua própria barra de rolagem (com overflow: auto ou overflow: scroll)document ou window, não no seu componentewindow.addEventListener("scroll", handler) dentro de useEffect para scroll da páginaconst ticking = useRef(false);
useEffect(() => {
const handleScroll = () => {
if (!ticking.current) {
requestAnimationFrame(() => {
// Sua lógica de scroll aqui
ticking.current = false;
});
ticking.current = true;
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);IntersectionObserver é fortemente preferido -- ele roda fora da thread principal e é mais performático<div> sentinela no final da sua lista e observe-oonScroll requer matemática manual e dispara a cada frame de scroll, o que é desperdícioconst targetRef = useRef<HTMLDivElement>(null);
function scrollToTarget() {
targetRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}Revisado por Chris St. John·Última atualização: 19 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥