ScrollArea
Um contêiner rolável com uma barra de rolagem estilizada personalizada que substitui a barra de rolagem nativa do navegador, proporcionando uma aparência consistente entre sistemas operacionais e navegadores.
Busque em todas as páginas da documentação
Um contêiner rolável com uma barra de rolagem estilizada personalizada que substitui a barra de rolagem nativa do navegador, proporcionando uma aparência consistente entre sistemas operacionais e navegadores.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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>
);
}Usa a sintaxe de seletor arbitrário do Tailwind para estilizar os pseudo-elementos da barra de rolagem WebKit. O overflow-auto garante que a barra de rolagem apareça apenas quando o conteúdo transbordar. Não é necessário "use client", pois não há estado nem interatividade.
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>Bloqueia a rolagem no eixo vertical com overflow-x-hidden. A prop maxHeight é passada como um estilo inline, pois as classes do Tailwind para alturas máximas arbitrárias adicionam verbosidade. Conteúdo menor que a altura máxima é renderizado sem barra de rolagem.
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>O contêiner flex interno com whitespace-nowrap impede que os filhos quebrem para a próxima linha. Cada filho deve usar shrink-0 para manter sua largura. A barra de rolagem horizontal usa h-2 em vez de w-2.
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: tabela de dados ampla ou planilha
<ScrollAreaBoth maxHeight="500px" maxWidth="100%">
<table className="min-w-[800px]">
{/* conteúdo da tabela */}
</table>
</ScrollAreaBoth>Quando ambos os eixos rolam, o canto onde as duas barras de rolagem se encontram precisa de estilo explícito via [&::-webkit-scrollbar-corner] para evitar um artefato de quadrado branco. A tabela interna usa min-w- para forçar o transbordamento horizontal.
"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>
);
}O polegar da barra de rolagem começa transparente e só fica visível enquanto o usuário está rolando. Um tempo limite o oculta novamente após 1,2 segundos de inatividade. Requer "use client" para gerenciamento de estado e manipulação de eventos.
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">Notificações</h3>}
maxHeight="400px"
>
{notifications.map((n) => (
<div key={n.id} className="border-b p-4">{n.message}</div>
))}
</ScrollAreaWithHeader>O layout de coluna flexível com shrink-0 no cabeçalho o mantém fixo no topo enquanto o corpo rola. O flex-1 no contêiner de rolagem permite que ele preencha a altura restante. Este é um padrão comum para painéis de notificação e listas de barra lateral.
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>
);
}Uma caixa rolável autocontida com borda e preenchimento, adequada para incorporar em qualquer layout. O padrão max-height de 20rem é razoável para a maioria dos contextos de barra lateral ou cartão, mas pode ser substituído por instância.
"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 rolagem infinita
function NotificationList() {
const [items, setItems] = useState<string[]>(
Array.from({ length: 30 }, (_, i) => `Notificação ${i + 1}`)
);
const [loading, setLoading] = useState(false);
const loadMore = useCallback(() => {
if (loading) return;
setLoading(true);
setTimeout(() => {
setItems((prev) => [
...prev,
...Array.from({ length: 10 }, (_, i) => `Notificação ${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">Carregando...</div>
)}
</ScrollArea>
);
}Aspectos Principais:
axis controla qual direção rola, definindo automaticamente a combinação correta de overflow-x/overflow-y e aplicando dimensões de barra de rolagem ao eixo correto.onScrollEnd é acionada quando o usuário rola dentro de um limite inferior (ou direito, ou ambos), permitindo padrões de rolagem infinita sem um observador de interseção separado.thumbColor, thumbHoverColor e trackWidth permitem a tematização da barra de rolagem sem duplicar os seletores de pseudo-elementos WebKit verbosos em cada local de chamada.ref.current.scrollTo()), enquanto o ref interno envolve o conteúdo para medição potencial.useEffect para evitar atualizações de estado em componentes desmontados.Estilos de barra de rolagem WebKit não funcionam no Firefox -- os pseudo-elementos ::-webkit-scrollbar são apenas para Chrome/Safari/Edge. O Firefox usa as propriedades CSS scrollbar-width e scrollbar-color. Você precisa de ambos para barras de rolagem personalizadas entre navegadores.
overflow: auto oculta o conteúdo atrás da barra de rolagem -- no Windows e Linux, onde as barras de rolagem estão sempre visíveis, a barra de rolagem ocupa espaço e desloca o conteúdo. Use scrollbar-gutter: stable para reservar espaço mesmo quando não houver transbordamento.
Barra de rolagem com ocultação automática quebra a descoberta de rolagem por teclado -- quando a barra de rolagem está invisível, os usuários que dependem de pistas visuais podem não perceber que a área é rolável. Sempre garanta que o contêiner seja focável e rolável por teclado com tabIndex={0}.
Áreas de rolagem aninhadas prendem eventos de rolagem -- se uma área de rolagem estiver dentro de outra área de rolagem, o contêiner interno captura todos os eventos de roda, tornando impossível rolar o externo. Evite aninhar ou use overscroll-behavior: contain intencionalmente.
Valores de maxHeight em porcentagem dentro do flex -- valores de max-height baseados em porcentagem não são resolvidos corretamente quando o pai não tem altura explícita. Use unidades rem, px ou de viewport, ou garanta que o pai tenha uma altura computada.
Rolagem de momentum em toque desativada -- no iOS, contêineres de barra de rolagem personalizados podem perder o efeito de bounce elástico. Adicione -webkit-overflow-scrolling: touch (ou o equivalente do Tailwind touch-auto) para restaurar a rolagem de momentum nativa.
Revisado por Chris St. John·Última atualização: 7 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥