ScrollArea
Un contenedor desplazable con una barra de desplazamiento personalizada que reemplaza la barra de desplazamiento nativa del navegador, proporcionando un aspecto consistente en sistemas operativos y navegadores.
Casos de uso
- Mostrar una lista larga de elementos en una barra lateral o panel restringido
- Mostrar un menú desplegable desplazable con muchas opciones
- Crear un área de mensajes de chat que se desplaza verticalmente
- Construir un carrusel de imágenes o tarjetas desplazable horizontalmente
- Contener bloques de código o salida de registro que pueden exceder el viewport
- Proporcionar un área desplazable dentro de un modal sin que el modal se desplace
- Mostrar una tabla de datos con encabezado fijo y cuerpo desplazable
Implementación más simple
interface ScrollAreaProps {
children: React.ReactNode;
className?: string;
}
export function ScrollArea({ children, className }: ScrollAreaProps) {
return (
<div
className={`overflow-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 ${className ?? ""}`}
>
{children}
</div>
);
}Utiliza la sintaxis de selectores arbitrarios de Tailwind para personalizar los pseudo-elementos de la barra de desplazamiento WebKit. overflow-auto asegura que la barra de desplazamiento solo aparezca cuando el contenido se desborda. No se necesita "use client" ya que no hay estado ni interactividad.
Variaciones
Desplazamiento vertical
interface ScrollAreaProps {
children: React.ReactNode;
maxHeight: string;
className?: string;
}
export function ScrollAreaVertical({ children, maxHeight, className }: ScrollAreaProps) {
return (
<div
className={`overflow-y-auto overflow-x-hidden [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 ${className ?? ""}`}
style={{ maxHeight }}
>
{children}
</div>
);
}
// Uso
<ScrollAreaVertical maxHeight="400px">
{items.map((item) => (
<div key={item.id} className="border-b p-3">{item.name}</div>
))}
</ScrollAreaVertical>Bloquea el desplazamiento al eje vertical con overflow-x-hidden. La prop maxHeight se pasa como un estilo en línea ya que las clases de Tailwind para alturas máximas arbitrarias añaden verbosidad. El contenido más corto que la altura máxima se renderiza sin barra de desplazamiento.
Desplazamiento horizontal
interface ScrollAreaProps {
children: React.ReactNode;
className?: string;
}
export function ScrollAreaHorizontal({ children, className }: ScrollAreaProps) {
return (
<div
className={`overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 ${className ?? ""}`}
>
<div className="flex gap-4 whitespace-nowrap">
{children}
</div>
</div>
);
}
// Uso
<ScrollAreaHorizontal>
{images.map((src) => (
<img key={src} src={src} alt="" className="h-40 w-60 shrink-0 rounded-lg object-cover" />
))}
</ScrollAreaHorizontal>El contenedor flex interno con whitespace-nowrap previene que los elementos secundarios se envuelvan a la siguiente línea. Cada hijo debe usar shrink-0 para mantener su ancho. La barra de desplazamiento horizontal usa h-2 en lugar de w-2.
Ambos ejes
interface ScrollAreaProps {
children: React.ReactNode;
maxHeight: string;
maxWidth: string;
className?: string;
}
export function ScrollAreaBoth({ children, maxHeight, maxWidth, className }: ScrollAreaProps) {
return (
<div
className={`overflow-auto [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-corner]:bg-transparent ${className ?? ""}`}
style={{ maxHeight, maxWidth }}
>
{children}
</div>
);
}
// Uso: tabla de datos ancha u hoja de cálculo
<ScrollAreaBoth maxHeight="500px" maxWidth="100%">
<table className="min-w-[800px]">
{/* table content */}
</table>
</ScrollAreaBoth>Cuando ambos ejes se desplazan, la esquina donde se encuentran las dos barras de desplazamiento necesita un estilo explícito mediante [&::-webkit-scrollbar-corner] para evitar un artefacto de cuadrado blanco. La tabla interna usa min-w- para forzar un desbordamiento horizontal.
Barra de desplazamiento de ocultamiento automático
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
interface ScrollAreaProps {
children: React.ReactNode;
maxHeight: string;
className?: string;
}
export function ScrollAreaAutoHide({ children, maxHeight, className }: ScrollAreaProps) {
const [isScrolling, setIsScrolling] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleScroll = useCallback(() => {
setIsScrolling(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setIsScrolling(false), 1200);
}, []);
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
return (
<div
onScroll={handleScroll}
className={`overflow-y-auto overflow-x-hidden transition-colors [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full ${
isScrolling
? "[&::-webkit-scrollbar-thumb]:bg-gray-400"
: "[&::-webkit-scrollbar-thumb]:bg-transparent"
} ${className ?? ""}`}
style={{ maxHeight }}
>
{children}
</div>
);
}El pulgar de la barra de desplazamiento comienza transparente y se vuelve visible solo mientras el usuario se desplaza. Un timeout lo oculta nuevamente después de 1,2 segundos de inactividad. Requiere "use client" para la gestión del estado y el manejo de eventos.
Con encabezado fijo
interface ScrollAreaWithHeaderProps {
header: React.ReactNode;
children: React.ReactNode;
maxHeight: string;
className?: string;
}
export function ScrollAreaWithHeader({
header,
children,
maxHeight,
className,
}: ScrollAreaWithHeaderProps) {
return (
<div className={`flex flex-col ${className ?? ""}`} style={{ maxHeight }}>
<div className="shrink-0 border-b border-gray-200 bg-white px-4 py-3">
{header}
</div>
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300">
{children}
</div>
</div>
);
}
// Uso
<ScrollAreaWithHeader
header={<h3 className="font-semibold">Notificaciones</h3>}
maxHeight="400px"
>
{notifications.map((n) => (
<div key={n.id} className="border-b p-4">{n.message}</div>
))}
</ScrollAreaWithHeader>El diseño de columna flex con shrink-0 en el encabezado lo mantiene fijado en la parte superior mientras el cuerpo se desplaza. El flex-1 en el contenedor de desplazamiento le permite llenar la altura restante. Este es un patrón común para paneles de notificaciones y listas de barras laterales.
Restringido por altura máxima
interface ScrollAreaProps {
children: React.ReactNode;
maxHeight?: string;
className?: string;
}
export function ScrollArea({ children, maxHeight = "20rem", className }: ScrollAreaProps) {
return (
<div
className={`overflow-y-auto rounded-lg border border-gray-200 p-4 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 ${className ?? ""}`}
style={{ maxHeight }}
>
{children}
</div>
);
}Una caja desplazable independiente con borde y relleno, adecuada para incrustar en cualquier diseño. El valor predeterminado de max-height de 20rem es sensato para la mayoría de contextos de barra lateral o tarjeta, pero puede anularse por instancia.
Implementación compleja
"use client";
import {
forwardRef,
useRef,
useState,
useEffect,
useCallback,
type ReactNode,
type UIEvent,
} from "react";
type ScrollAxis = "vertical" | "horizontal" | "both";
interface ScrollAreaProps {
children: ReactNode;
axis?: ScrollAxis;
maxHeight?: string;
maxWidth?: string;
autoHide?: boolean;
autoHideDelay?: number;
thumbColor?: string;
thumbHoverColor?: string;
trackWidth?: string;
onScrollEnd?: () => void;
scrollEndThreshold?: number;
className?: string;
}
export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
function ScrollArea(
{
children,
axis = "vertical",
maxHeight,
maxWidth,
autoHide = false,
autoHideDelay = 1200,
thumbColor = "bg-gray-300",
thumbHoverColor = "hover:bg-gray-400",
trackWidth = "w-2",
onScrollEnd,
scrollEndThreshold = 20,
className,
},
ref
) {
const innerRef = useRef<HTMLDivElement>(null);
const [isActive, setIsActive] = useState(!autoHide);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearHideTimer = useCallback(() => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
}, []);
const scheduleHide = useCallback(() => {
if (!autoHide) return;
clearHideTimer();
hideTimer.current = setTimeout(() => setIsActive(false), autoHideDelay);
}, [autoHide, autoHideDelay, clearHideTimer]);
const handleScroll = useCallback(
(e: UIEvent<HTMLDivElement>) => {
if (autoHide) {
setIsActive(true);
scheduleHide();
}
if (onScrollEnd) {
const el = e.currentTarget;
const isNearBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < scrollEndThreshold;
const isNearRight =
el.scrollWidth - el.scrollLeft - el.clientWidth < scrollEndThreshold;
if (axis === "vertical" && isNearBottom) onScrollEnd();
else if (axis === "horizontal" && isNearRight) onScrollEnd();
else if (axis === "both" && isNearBottom && isNearRight) onScrollEnd();
}
},
[autoHide, scheduleHide, onScrollEnd, scrollEndThreshold, axis]
);
useEffect(() => {
return clearHideTimer;
}, [clearHideTimer]);
const overflowClass =
axis === "vertical"
? "overflow-y-auto overflow-x-hidden"
: axis === "horizontal"
? "overflow-x-auto overflow-y-hidden"
: "overflow-auto";
const thumbClass = isActive ? thumbColor : "bg-transparent";
const trackHeightClass = axis === "horizontal" || axis === "both" ? `[&::-webkit-scrollbar]:h-2` : "";
return (
<div
ref={ref}
onScroll={handleScroll}
onMouseEnter={autoHide ? () => setIsActive(true) : undefined}
onMouseLeave={autoHide ? () => scheduleHide() : undefined}
className={[
overflowClass,
`[&::-webkit-scrollbar]:${trackWidth}`,
trackHeightClass,
"[&::-webkit-scrollbar-track]:bg-transparent",
`[&::-webkit-scrollbar-thumb]:rounded-full`,
`[&::-webkit-scrollbar-thumb]:${thumbClass}`,
`[&::-webkit-scrollbar-thumb]:${thumbHoverColor}`,
"[&::-webkit-scrollbar-corner]:bg-transparent",
"transition-colors",
className ?? "",
].join(" ")}
style={{
maxHeight: axis !== "horizontal" ? maxHeight : undefined,
maxWidth: axis !== "vertical" ? maxWidth : undefined,
}}
>
<div ref={innerRef}>{children}</div>
</div>
);
}
);
// Uso: lista de desplazamiento infinito
function NotificationList() {
const [items, setItems] = useState<string[]>(
Array.from({ length: 30 }, (_, i) => `Notificación ${i + 1}`)
);
const [loading, setLoading] = useState(false);
const loadMore = useCallback(() => {
if (loading) return;
setLoading(true);
setTimeout(() => {
setItems((prev) => [
...prev,
...Array.from({ length: 10 }, (_, i) => `Notificación ${prev.length + i + 1}`),
]);
setLoading(false);
}, 500);
}, [loading]);
return (
<ScrollArea
maxHeight="400px"
autoHide
onScrollEnd={loadMore}
className="rounded-lg border border-gray-200"
>
{items.map((item, i) => (
<div key={i} className="border-b border-gray-100 px-4 py-3 text-sm">
{item}
</div>
))}
{loading && (
<div className="px-4 py-3 text-center text-sm text-gray-400">Cargando...</div>
)}
</ScrollArea>
);
}Aspectos clave:
- Soporte de tres ejes -- la prop
axiscontrola la dirección del desplazamiento, configurando automáticamente la combinación correcta deoverflow-x/overflow-yy aplicando dimensiones de barra de desplazamiento al eje correcto. - Ocultamiento automático con conciencia de hover -- la barra de desplazamiento se desvanece después de la inactividad pero reaparece inmediatamente al entrar el ratón, imitando el comportamiento de la barra de desplazamiento superpuesta de macOS sin depender de configuraciones a nivel de sistema operativo.
- Callback de fin de desplazamiento -- la prop
onScrollEndse dispara cuando el usuario se desplaza dentro de un umbral del fondo (o derecha, o ambos), habilitando patrones de desplazamiento infinito sin necesidad de un intersection observer separado. - Apariencia de barra de desplazamiento configurable -- las props
thumbColor,thumbHoverColorytrackWidthpermiten tematizar la barra de desplazamiento sin duplicar los selectores de pseudo-elementos WebKit detallados en cada sitio de llamada. - forwardRef con ref interno -- la ref externa expone el contenedor desplazable para desplazamiento imperativo (
ref.current.scrollTo()), mientras que la ref interna envuelve el contenido para una posible medición. - Limpieza al desmontar -- el timer de ocultamiento se limpia en una limpieza de
useEffectpara prevenir actualizaciones de estado en componentes desmontados.
Cosas a tener en cuenta
-
Los estilos de barra de desplazamiento WebKit no funcionan en Firefox -- los pseudo-elementos
::-webkit-scrollbarsolo funcionan en Chrome/Safari/Edge. Firefox usa las propiedades CSSscrollbar-widthyscrollbar-color. Necesitas ambas para barras de desplazamiento personalizadas entre navegadores. -
overflow: autooculta contenido detrás de la barra de desplazamiento -- en Windows y Linux donde las barras de desplazamiento siempre son visibles, la barra de desplazamiento ocupa espacio y desplaza el contenido. Usascrollbar-gutter: stablepara reservar espacio incluso cuando no hay desbordamiento. -
La barra de desplazamiento de ocultamiento automático rompe la detectabilidad del desplazamiento por teclado -- cuando la barra de desplazamiento es invisible, los usuarios que confían en pistas visuales pueden no darse cuenta de que el área es desplazable. Siempre asegúrate de que el contenedor sea enfocable y desplazable por teclado con
tabIndex={0}. -
Las áreas de desplazamiento anidadas atrapan eventos de desplazamiento -- si un área de desplazamiento está dentro de otra área de desplazamiento, el contenedor interno captura todos los eventos de rueda, haciendo imposible desplazar el externo. Evita anidar o usa
overscroll-behavior: containintencionalmente. -
maxHeightcon valores de porcentaje dentro de flex -- los valores demax-heightbasados en porcentaje no se resuelven correctamente cuando el padre no tiene una altura explícita. Usarem,px, o unidades de viewport, o asegúrate de que el padre tenga una altura computada. -
Desplazamiento de impulso táctil deshabilitado -- en iOS, los contenedores de barra de desplazamiento personalizada pueden perder el efecto de rebote elástico. Agrega
-webkit-overflow-scrolling: touch(o el equivalente de Tailwindtouch-auto) para restaurar el desplazamiento de impulso nativo.
Relacionado
- Barra lateral -- Las barras laterales comúnmente usan áreas de desplazamiento para listas de navegación
- Menú desplegable -- Los menús desplegables con muchos elementos necesitan un contenedor desplazable
- Modal -- Los modales con contenido largo requieren un área de desplazamiento interna
- Tarjeta -- Las tarjetas con contenido dinámico pueden necesitar áreas de desplazamiento restringidas