Eventos de scroll
Responde a cambios de posición de scroll para scroll infinito, encabezados pegajosos, botones volver al inicio e indicadores de progreso.
Referencia de eventos
| Evento / Método | Se dispara cuando | Objeto de evento / Notas |
|---|---|---|
onScroll | La posición de scroll del elemento cambia | React.UIEvent |
onScrollCapture | Igual a onScroll pero durante la fase de captura | React.UIEvent |
window.addEventListener("scroll", ...) | La posición de scroll a nivel de página cambia | Event nativo (usar en useEffect) |
IntersectionObserver | Un elemento entra o sale del viewport | No es un evento -- API basada (usar en useEffect) |
Propiedades clave en un elemento desplazable:
| Propiedad | Tipo | Descripción |
|---|---|---|
scrollTop | number | Píxeles desplazados desde la parte superior |
scrollLeft | number | Píxeles desplazados desde la izquierda |
scrollHeight | number | Altura total desplazable incluyendo desbordamiento |
clientHeight | number | Altura visible del elemento |
scrollWidth | number | Ancho total desplazable incluyendo desbordamiento |
clientWidth | number | Ancho visible del elemento |
Receta
Tarjeta de receta de referencia rápida -- lista para copiar y pegar.
// Evento de scroll del contenedor
"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) {
// Cerca del fondo -- cargar más
}
}, []);
return (
<div className="h-96 overflow-y-auto" onScroll={handleScroll}>
{/* contenido */}
</div>
);
}// Escuchador de scroll de ventana 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;
}Cuándo usarlo: Necesitas responder a qué tan lejos ha hecho scroll un usuario -- barras de progreso, carga perezosa, elementos pegajosos o botones volver al inicio.
Ejemplo funcional
// 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 progreso fija en la parte superior */}
<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>
{/* Contenido de la página */}
<main className="max-w-2xl mx-auto p-6 pt-8">
<h1 className="text-3xl font-bold mb-6">Artículo largo</h1>
{Array.from({ length: 20 }, (_, i) => (
<section key={i} className="mb-8">
<h2 className="text-xl font-semibold mb-2">Sección {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ón volver al inicio */}
{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="Volver al inicio"
>
↑
</button>
)}
</>
);
}Lo que esto demuestra:
- Usar
window.addEventListener("scroll", ...)enuseEffectpara el seguimiento de scroll a nivel de página - Calcular el progreso de scroll como porcentaje de la altura total desplazable
- Mostrar condicionalmente un botón volver al inicio basado en la posición de scroll
- Usar
{ passive: true }para optimización del rendimiento del scroll - Limpiar el escuchador de eventos al desmontar
Profundización
Cómo funciona
- El evento
onScrollse dispara en cualquier elemento conoverflow: autouoverflow: scrollcuando su posición de scroll cambia. - Para el desplazamiento a nivel de página,
onScrollde React en el elemento raíz es poco confiable -- usawindow.addEventListener("scroll", ...)dentro deuseEffecten su lugar. - Los eventos de scroll se disparan a una tasa muy alta (cada fotograma durante el desplazamiento activo). Los manejadores pesados causan jank porque se ejecutan en el hilo principal.
- La API
IntersectionObserveres una alternativa eficiente para detectar cuándo los elementos entran en el viewport, ya que se ejecuta fuera del hilo principal. scrollHeight - clientHeightda el valor máximo descrollToppara cualquier contenedor desplazable.
Variaciones
Scroll infinito con 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 llamada a 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">Cargando...</p>}
</div>
);
}Encabezado pegajoso en scroll:
"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">Mi sitio</h1>
</nav>
</header>
);
}Restauración de posición de scroll:
"use client";
import { useEffect, useRef } from "react";
export function useScrollRestoration(key: string) {
const restored = useRef(false);
useEffect(() => {
// Restaurar posición
if (!restored.current) {
const saved = sessionStorage.getItem(`scroll-${key}`);
if (saved) {
window.scrollTo(0, Number(saved));
}
restored.current = true;
}
// Guardar posición en scroll
const handleScroll = () => {
sessionStorage.setItem(`scroll-${key}`, String(window.scrollY));
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, [key]);
}Contenedor 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="Desplazarse a la izquierda"
>
←
</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="Desplazarse a la derecha"
>
→
</button>
</div>
);
}Manejador de scroll con 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;
}Notas de TypeScript
// Manejador onScroll tipado a un 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 de ventana en useEffect -- usa Event nativo, no React.UIEvent
useEffect(() => {
const handler = (e: Event) => {
// window.scrollY es la forma estándar de leer el scroll de página
console.log(window.scrollY);
};
window.addEventListener("scroll", handler);
return () => window.removeEventListener("scroll", handler);
}, []);
// Tipado de 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
);
// Tipado de ref para contenedores de scroll
const scrollRef = useRef<HTMLDivElement>(null);Trampas
-
onScrollen el body/document no funciona en React --onScrollde React solo se dispara en elementos con su propia barra de desplazamiento, no en la página misma. Solución: Usawindow.addEventListener("scroll", handler)dentro deuseEffectpara scroll a nivel de página. -
Los manejadores de scroll causan jank cuando se realiza trabajo costoso --
onScrollse dispara en cada fotograma durante el desplazamiento activo. Establecer estado en cada evento causa re-renderizados que bloquean el hilo principal. Solución: Aplica debounce al manejador, usa throttling conrequestAnimationFrame, o cambia aIntersectionObserverpara comprobaciones de visibilidad. -
Falta
{ passive: true }en escuchadores de scroll de ventana -- Sin esta pista, el navegador no puede optimizar el desplazamiento porque espera a ver si se llama apreventDefault(). Solución: Siempre pasa{ passive: true }cuando agregues escuchadores de scroll que no llamen apreventDefault(). -
scrollHeightes 0 en el render inicial -- Si leesscrollHeightdurante el primer render o en unuseEffectantes de que se pinte el contenido, el valor puede ser incorrecto. Solución: Espera hasta después del layout conuseLayoutEffecto mide dentro del manejador de scroll mismo. -
Posición de scroll perdida en re-render -- Cuando se agregan elementos al inicio de una lista (como un chat), la posición de scroll salta. Solución: Guarda
scrollTopantes de la actualización y restáurala después usandouseLayoutEffect, o usa la utilidadflushSyncpara actualizaciones DOM síncronas. -
IntersectionObserver se dispara en mount -- El callback se ejecuta inmediatamente cuando se llama a
observe()si el elemento ya está en el viewport. Solución: Protege con una bandera o verificaentry.isIntersectingantes de disparar tu lógica de carga. -
scroll-behavior: smoothen CSS entra en conflicto con scrollTo programático -- Si CSS aplica desplazamiento suave globalmente, tus llamadas ascrollTo({ behavior: "instant" })aún pueden animarse. Solución: Usabehavior: "instant"explícitamente o elimina la regla CSS y aplica desplazamiento suave solo vía JS.
Alternativas
| Alternativa | Usar cuando | No usar cuando |
|---|---|---|
IntersectionObserver | Necesitas detectar visibilidad de elementos (carga perezosa, scroll infinito) | Necesitas el valor exacto de posición de scroll |
CSS position: sticky | Quieres un encabezado o barra lateral pegajosos | Necesitas lógica JS basada en estado pegajoso |
CSS scroll-snap | Quieres desplazamiento de snap a elemento | Necesitas control programático sobre el comportamiento de snap |
react-virtuoso / @tanstack/virtual | Tienes miles de elementos en una lista desplazable | La lista es corta (menos de 100 elementos) |
requestAnimationFrame throttle | Necesitas animaciones vinculadas a scroll suave | Un valor debounced es suficiente |
Preguntas frecuentes
¿Cómo detecto cuándo un usuario llega al fondo de un contenedor desplazable?
function handleScroll(e: React.UIEvent<HTMLDivElement>) {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
if (scrollTop + clientHeight >= scrollHeight - 10) {
// En o cerca del fondo
}
}¿Por qué onScroll no se dispara en mi componente de página?
onScrollde React solo funciona en elementos que tienen su propia barra de desplazamiento (conoverflow: autouoverflow: scroll)- El desplazamiento a nivel de página ocurre en
documentowindow, no en tu componente - Usa
window.addEventListener("scroll", handler)dentro deuseEffectpara scroll de página
¿Cómo limito un manejador de scroll sin una librería?
const ticking = useRef(false);
useEffect(() => {
const handleScroll = () => {
if (!ticking.current) {
requestAnimationFrame(() => {
// Tu lógica de scroll aquí
ticking.current = false;
});
ticking.current = true;
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);¿Debo usar IntersectionObserver u onScroll para scroll infinito?
IntersectionObserveres fuertemente preferido -- se ejecuta fuera del hilo principal y es más eficiente- Coloca un
<div>centinela en el fondo de tu lista y obsérvalo onScrollrequiere matemáticas manuales y se dispara en cada fotograma de scroll, lo cual es derrochador
¿Cómo hago scroll a un elemento específico programáticamente?
const targetRef = useRef<HTMLDivElement>(null);
function scrollToTarget() {
targetRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}Relacionado
- Eventos táctiles -- Manejo de interacciones táctiles para deslizamiento y gesto
- Eventos de puntero -- Entrada unificada entre ratón, táctil y lápiz