React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

scrollscrollbaroverflowcontainercustom-scrollbarcomponenttailwind

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 axis prop controls which direction scrolls, automatically setting the correct overflow-x/overflow-y combination 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 onScrollEnd prop 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, and trackWidth props 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 useEffect cleanup to prevent state updates on unmounted components.

Gotchas

  • WebKit scrollbar styles do not work in Firefox -- the ::-webkit-scrollbar pseudo-elements are Chrome/Safari/Edge only. Firefox uses scrollbar-width and scrollbar-color CSS properties. You need both for cross-browser custom scrollbars.

  • overflow: auto hides content behind the scrollbar -- on Windows and Linux where scrollbars are always visible, the scrollbar takes up space and shifts content. Use scrollbar-gutter: stable to 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: contain intentionally.

  • maxHeight with percentage values inside flex -- percentage-based max-height values do not resolve correctly when the parent has no explicit height. Use rem, 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 equivalent touch-auto) to restore native momentum scrolling.

  • Sidebar -- Sidebars commonly use scroll areas for navigation lists
  • Dropdown -- Dropdowns with many items need a scrollable container
  • Modal -- Modals with long content require an internal scroll area
  • Card -- Cards with dynamic content may need constrained scroll areas