ScrollArea
A scrollable container with a custom-styled scrollbar that replaces the browser's native scrollbar, providing a consistent look across operating systems and browsers.
Use Cases
- Display a long list of items in a constrained sidebar or panel
- Show a scrollable dropdown menu with many options
- Create a chat message area that scrolls vertically
- Build a horizontally scrollable image or card carousel
- Contain code blocks or log output that may exceed the viewport
- Provide a scrollable area inside a modal without the modal itself scrolling
- Display a data table with a fixed header and scrollable body
Simplest Implementation
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>
);
}Uses Tailwind's arbitrary selector syntax to style the WebKit scrollbar pseudo-elements. The overflow-auto ensures the scrollbar only appears when content overflows. No "use client" is needed since there is no state or interactivity.
Variations
Vertical Scroll
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>
);
}
// Usage
<ScrollAreaVertical maxHeight="400px">
{items.map((item) => (
<div key={item.id} className="border-b p-3">{item.name}</div>
))}
</ScrollAreaVertical>Locks scrolling to the vertical axis with overflow-x-hidden. The maxHeight prop is passed as an inline style since Tailwind classes for arbitrary max-heights add verbosity. Content shorter than the max height renders without a scrollbar.
Horizontal Scroll
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>
);
}
// Usage
<ScrollAreaHorizontal>
{images.map((src) => (
<img key={src} src={src} alt="" className="h-40 w-60 shrink-0 rounded-lg object-cover" />
))}
</ScrollAreaHorizontal>The inner flex container with whitespace-nowrap prevents children from wrapping to the next line. Each child should use shrink-0 to maintain its width. The horizontal scrollbar uses h-2 instead of w-2.
Both Axes
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>
);
}
// Usage: wide data table or spreadsheet
<ScrollAreaBoth maxHeight="500px" maxWidth="100%">
<table className="min-w-[800px]">
{/* table content */}
</table>
</ScrollAreaBoth>When both axes scroll, the corner where the two scrollbars meet needs explicit styling via [&::-webkit-scrollbar-corner] to avoid a white square artifact. The table inside uses min-w- to force horizontal overflow.
Auto-hide Scrollbar
"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>
);
}The scrollbar thumb starts transparent and becomes visible only while the user is scrolling. A timeout hides it again after 1.2 seconds of inactivity. Requires "use client" for state management and event handling.
With Fixed Header
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>
);
}
// Usage
<ScrollAreaWithHeader
header={<h3 className="font-semibold">Notifications</h3>}
maxHeight="400px"
>
{notifications.map((n) => (
<div key={n.id} className="border-b p-4">{n.message}</div>
))}
</ScrollAreaWithHeader>The flex column layout with shrink-0 on the header keeps it pinned at the top while the body scrolls. The flex-1 on the scroll container lets it fill the remaining height. This is a common pattern for notification panels and sidebar lists.
Max-Height Constrained
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>
);
}A self-contained scrollable box with border and padding, suitable for embedding in any layout. The max-height default of 20rem is sensible for most sidebar or card contexts but can be overridden per instance.
Complex Implementation
"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>
);
}
);
// Usage: infinite scroll list
function NotificationList() {
const [items, setItems] = useState<string[]>(
Array.from({ length: 30 }, (_, i) => `Notification ${i + 1}`)
);
const [loading, setLoading] = useState(false);
const loadMore = useCallback(() => {
if (loading) return;
setLoading(true);
setTimeout(() => {
setItems((prev) => [
...prev,
...Array.from({ length: 10 }, (_, i) => `Notification ${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">Loading...</div>
)}
</ScrollArea>
);
}Key aspects:
- Three-axis support -- the
axisprop controls which direction scrolls, automatically setting the correctoverflow-x/overflow-ycombination and applying scrollbar dimensions to the right axis. - Auto-hide with hover awareness -- the scrollbar fades out after inactivity but reappears immediately on mouse enter, mimicking macOS overlay scrollbar behavior without relying on OS-level settings.
- Scroll-end callback -- the
onScrollEndprop fires when the user scrolls within a threshold of the bottom (or right, or both), enabling infinite scroll patterns without a separate intersection observer. - Configurable scrollbar appearance --
thumbColor,thumbHoverColor, andtrackWidthprops allow theming the scrollbar without duplicating the verbose WebKit pseudo-element selectors at every call site. - forwardRef with inner ref -- the outer ref exposes the scrollable container for imperative scrolling (
ref.current.scrollTo()), while the inner ref wraps content for potential measurement. - Cleanup on unmount -- the hide timer is cleared in a
useEffectcleanup to prevent state updates on unmounted components.
Gotchas
-
WebKit scrollbar styles do not work in Firefox -- the
::-webkit-scrollbarpseudo-elements are Chrome/Safari/Edge only. Firefox usesscrollbar-widthandscrollbar-colorCSS properties. You need both for cross-browser custom scrollbars. -
overflow: autohides content behind the scrollbar -- on Windows and Linux where scrollbars are always visible, the scrollbar takes up space and shifts content. Usescrollbar-gutter: stableto reserve space even when no overflow exists. -
Auto-hide scrollbar breaks keyboard scrolling discoverability -- when the scrollbar is invisible, users relying on visual cues may not realize the area is scrollable. Always ensure the container is focusable and keyboard-scrollable with
tabIndex={0}. -
Nested scroll areas trap scroll events -- if a scroll area is inside another scroll area, the inner container captures all wheel events, making it impossible to scroll the outer one. Avoid nesting or use
overscroll-behavior: containintentionally. -
maxHeightwith percentage values inside flex -- percentage-basedmax-heightvalues do not resolve correctly when the parent has no explicit height. Userem,px, or viewport units, or ensure the parent has a computed height. -
Touch momentum scrolling disabled -- on iOS, custom scrollbar containers may lose the elastic bounce effect. Add
-webkit-overflow-scrolling: touch(or the Tailwind equivalenttouch-auto) to restore native momentum scrolling.