useScrollToTop — Desplaza la ventana al inicio y muestra un botón "volver al inicio"
Receta
import { useState, useEffect, useCallback } from "react";
interface UseScrollToTopOptions {
/** Distancia de desplazamiento (px) antes de que aparezca el botón. Por defecto: 300 */
threshold?: number;
/** Usa desplazamiento suave. Por defecto: true */
smooth?: boolean;
}
interface UseScrollToTopReturn {
/** Si la página se ha desplazado más allá del umbral */
isVisible: boolean;
/** Llama esto para desplazarse al inicio */
scrollToTop: () => void;
/** Posición de desplazamiento actual */
scrollY: number;
}
function useScrollToTop(
options: UseScrollToTopOptions = {}
): UseScrollToTopReturn {
const { threshold = 300, smooth = true } = options;
const [isVisible, setIsVisible] = useState(false);
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = () => {
const y = window.scrollY;
setScrollY(y);
setIsVisible(y > threshold);
};
// Verifica la posición inicial
handleScroll();
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, [threshold]);
const scrollToTop = useCallback(() => {
window.scrollTo({
top: 0,
behavior: smooth ? "smooth" : "instant",
});
}, [smooth]);
return { isVisible, scrollToTop, scrollY };
}Cuándo usarlo: Tienes una página larga y deseas dar a los usuarios una forma rápida de volver al inicio, con el botón apareciendo solo después de que hayan desplazado la página una distancia significativa.
Ejemplo Funcional
"use client";
function BackToTopButton() {
const { isVisible, scrollToTop } = useScrollToTop({
threshold: 400,
smooth: true,
});
return (
<button
onClick={scrollToTop}
aria-label="Scroll to top"
style={{
position: "fixed",
bottom: 24,
right: 24,
width: 48,
height: 48,
borderRadius: "50%",
border: "none",
background: "#111",
color: "#fff",
fontSize: 20,
cursor: "pointer",
opacity: isVisible ? 1 : 0,
transform: isVisible ? "translateY(0)" : "translateY(16px)",
transition: "opacity 0.3s, transform 0.3s",
pointerEvents: isVisible ? "auto" : "none",
}}
>
↑
</button>
);
}
function LongPage() {
return (
<div>
<h1>Título del Artículo</h1>
{Array.from({ length: 50 }, (_, i) => (
<p key={i}>Párrafo {i + 1} del contenido...</p>
))}
<BackToTopButton />
</div>
);
}Lo que esto demuestra:
- El botón se desvanece después de desplazarse más allá de 400 px
- Al hacer clic en el botón, la página se desplaza suavemente al inicio
- Las transiciones CSS manejan la animación de mostrar/ocultar
pointerEvents: "none"previene que el botón invisible bloquee clicsaria-labelasegura accesibilidad del lector de pantalla
Análisis Profundo
Cómo Funciona
- Un único listener de evento
scroll(con{ passive: true }para rendimiento) rastreawindow.scrollY. isVisiblese pone en true cuandoscrollYsupera el umbral, dando al componente consumidor un booleano reactivo para renderizar.scrollToTopllama awindow.scrollToconbehavior: "smooth"para una animación de desplazamiento suave nativa.- La opción
passive: truele dice al navegador que el handler no llamará apreventDefault, permitiendo optimizaciones de rendimiento de desplazamiento. - La posición inicial se verifica inmediatamente en el efecto para que el estado del botón sea correcto al montar (por ejemplo, si el usuario actualiza la página en el medio).
Parámetros y Valores de Retorno
| Parámetro | Tipo | Por Defecto | Descripción |
|---|---|---|---|
options.threshold | number | 300 | Píxeles desplazados antes de que isVisible sea true |
options.smooth | boolean | true | Si usar comportamiento de desplazamiento suave |
| Retorno | Tipo | Descripción |
|---|---|---|
isVisible | boolean | Si la posición de desplazamiento supera el umbral |
scrollToTop | () => void | Función para desplazarse al inicio |
scrollY | number | Posición de desplazamiento Y actual |
Variaciones
Handler de desplazamiento con throttle: Para páginas con renderizado pesado, envuelve el handler de desplazamiento con useThrottledCallback para reducir actualizaciones de estado:
const handleScroll = useThrottledCallback(() => {
setScrollY(window.scrollY);
setIsVisible(window.scrollY > threshold);
}, 100);Desplazarse a elemento: Extiende para desplazarse a cualquier ref de elemento en lugar del inicio:
const scrollToElement = useCallback((ref: React.RefObject<HTMLElement>) => {
ref.current?.scrollIntoView({ behavior: smooth ? "smooth" : "instant" });
}, [smooth]);Notas de TypeScript
- El objeto de opciones utiliza una interfaz con propiedades opcionales, dando autocompletado de IDE.
- El tipo de retorno es una interfaz nombrada para fácil reutilización y documentación.
- No se necesitan genéricos ya que todos los tipos son concretos.
Trampa Oculta
- Rendimiento en páginas pesadas — Actualizar el estado en cada evento de desplazamiento puede causar tartamudeo en páginas complejas. Solución: Throttle del handler o usa
requestAnimationFrame. - Crash de SSR —
windowno está disponible durante la renderización del lado del servidor. Solución: EluseEffectsolo se ejecuta en el cliente, por lo que el hook es seguro para SSR tal como está escrito. El estado por defecto (isVisible: false) es correcto para SSR. - Desplazamiento suave no soportado — Los navegadores muy antiguos ignoran
behavior: "smooth". Solución: La página aún se desplaza instantáneamente, lo cual es un alternativa aceptable. - Conflictos de posición fija — Un botón fijo puede superponerse con barras de navegación móvil. Solución: Ajusta el valor
bottomo añadez-indexpara capas correctamente.
Alternativas
| Paquete | Hook/Componente | Notas |
|---|---|---|
react-scroll | animateScroll.scrollToTop() | Librería de desplazamiento completa con componentes de enlace |
usehooks-ts | useScrollPosition | Rastrea posición pero sin función de desplazamiento |
ahooks | useScroll | Retorna estado completo de desplazamiento para cualquier elemento |
framer-motion | useScroll | Rastreo de desplazamiento enfocado en animación |
| CSS Nativo | scroll-behavior: smooth | Solo CSS, sin lógica de botón |
Preguntas Frecuentes
¿Qué representa el booleano isVisible y cuándo se cambia?
isVisibleestruecuandowindow.scrollYsupera el valorthreshold.- Se cambia a
falsecuando el usuario desplaza de nuevo por encima del umbral. - Se verifica en cada evento de desplazamiento y también al montar.
¿Por qué el listener de desplazamiento se registra con { passive: true }?
El indicador passive le dice al navegador que el handler nunca llamará a preventDefault(). Esto permite que el navegador optimice el rendimiento de desplazamiento sin esperar a que el handler finalice antes de desplazarse.
¿Cómo funciona el desplazamiento suave y qué sucede en navegadores que no lo soportan?
window.scrollTo({ behavior: "smooth" })activa una animación de desplazamiento suave nativa.- Los navegadores no compatibles ignoran la propiedad
behaviory desplazan al instante. - El desplazamiento instantáneo es una alternativa aceptable ya que el usuario aún llega al inicio.
¿Por qué el efecto llama a handleScroll() inmediatamente después de registrar el listener?
Esto maneja el caso donde el usuario actualiza la página mientras ya está desplazado hacia abajo. Sin la llamada inicial, isVisible permanecería false hasta el siguiente evento de desplazamiento.
¿Cómo puedo hacer throttle del handler de desplazamiento para reducir actualizaciones de estado en páginas pesadas?
Envuelve el handler con useThrottledCallback:
const handleScroll = useThrottledCallback(() => {
setScrollY(window.scrollY);
setIsVisible(window.scrollY > threshold);
}, 100);Trampa: Mi botón fijo "volver al inicio" se superpone con la barra de navegación móvil. ¿Cómo lo arreglo?
- Aumenta el valor CSS
bottompara empujar el botón por encima de la barra de navegación. - Añade un
z-indexpara asegurar capas correctas. - Prueba en iOS y Android ya que sus alturas de barra de navegación difieren.
Trampa: El botón bloquea clics en contenido detrás de él incluso cuando es invisible. ¿Por qué?
Si se establece opacity: 0 sin pointerEvents: "none", el botón aún recibe eventos de clic. El ejemplo funcional establece pointerEvents: isVisible ? "auto" : "none" para evitar esto.
¿Es este hook seguro para SSR?
Sí. El useEffect solo se ejecuta en el cliente, y los valores de estado por defecto (isVisible: false, scrollY: 0) son correctos para salida renderizada por servidor. No se necesita protección typeof window fuera del efecto.
¿Cómo escribiría los tipos de opciones y valor de retorno en TypeScript si quisiera pasarlos como props?
Usa las interfaces nombradas directamente:
interface Props {
scrollOptions: UseScrollToTopOptions;
}
function MyComponent({ scrollOptions }: Props) {
const result: UseScrollToTopReturn = useScrollToTop(scrollOptions);
}¿Cómo podría extender este hook para desplazarse a cualquier elemento en lugar del inicio?
Añade una función scrollToElement usando scrollIntoView:
const scrollToElement = useCallback(
(ref: React.RefObject<HTMLElement>) => {
ref.current?.scrollIntoView({
behavior: smooth ? "smooth" : "instant",
});
},
[smooth]
);Relacionado
- useThrottle — throttle del handler de desplazamiento
- useEventListener — binding de eventos declarativo
- useIntersectionObserver — disparadores basados en desplazamiento alternativos