GitHub

ScrollArea

Scrollable container with consistent custom scrollbar styling across all browsers and platforms. Includes keyboard navigation and configurable visibility options.

Cross-browserKeyboard NavigationCustom ScrollbarAccessibleDark Mode

Install

$npx react-principles add scrollarea
01

Live Demo

Explore all variants and interactive states in Storybook.

Open Storybookopen_in_new

Vertical Scrolling

This is a vertically scrollable area. Use the scrollbar or arrow keys to navigate through the content.

Item 1

Item 2

Item 3

Item 4

Item 5

Item 6

Item 7

Item 8

Item 9

Item 10

Item 11

Item 12

Item 13

Item 14

Item 15

Item 16

Item 17

Item 18

Item 19

Item 20


Horizontal Scrolling

Item 1
Item 2
Item 3
Item 4
Item 5
Item 6
Item 7
Item 8
Item 9
Item 10

Scrollbar Visibility

Auto (default)

Line 1
Line 2
Line 3
Line 4
Line 5
Line 6
Line 7
Line 8
Line 9
Line 10

Always

Line 1
Line 2
Line 3
Line 4
Line 5

Hover

Line 1
Line 2
Line 3
Line 4
Line 5
Line 6
Line 7
Line 8
Line 9
Line 10

Keyboard Navigation

Keyboard Shortcuts

  • Arrow Up/Down - Scroll by small amount
  • Page Up/Down - Scroll by page
  • Home/End - Jump to start/end
  • Tab - Focus on scrollable area

Click on this area and use keyboard shortcuts to navigate. Try Arrow Up/Down, Page Up/Down, and Home/End keys.

Item 1

Item 2

Item 3

Item 4

Item 5

Item 6

Item 7

Item 8

Item 9

Item 10

Item 11

Item 12

Item 13

Item 14

Item 15

02

Code Snippet

src/ui/ScrollArea.tsx
import { ScrollArea } from "@/ui/ScrollArea";

// Vertical scroll (default)
<ScrollArea className="h-64">
  <p>Long content that scrolls vertically...</p>
</ScrollArea>

// Horizontal scroll
<ScrollArea orientation="horizontal" className="w-96">
  <div className="flex gap-4">
    {/* Wide content that scrolls horizontally */}
  </div>
</ScrollArea>

// Both orientations
<ScrollArea orientation="both" className="h-64 w-96">
  <table>
    {/* Large table that scrolls both directions */}
  </table>
</ScrollArea>

// Always visible scrollbar
<ScrollArea type="always" className="h-64">
  {/* Scrollbar always visible */}
</ScrollArea>

// On hover
<ScrollArea type="hover" className="h-64">
  {/* Scrollbar appears on hover */}
</ScrollArea>
03

Copy-Paste (Single File)

ScrollArea.tsx
import { cn } from "@/lib/utils";

// ─── Types ────────────────────────────────────────────────────────────────────

export type ScrollAreaOrientation = "vertical" | "horizontal" | "both";
export type ScrollAreaType = "auto" | "always" | "scroll" | "hover";

export interface ScrollAreaProps extends HTMLAttributes<HTMLDivElement> {
  orientation?: ScrollAreaOrientation;
  type?: ScrollAreaType;
  children: ReactNode;
  className?: string;
}

// ─── Constants ────────────────────────────────────────────────────────────────

const ORIENTATION_CLASSES: Record<ScrollAreaOrientation, string> = {
  vertical: "overflow-y-auto overflow-x-hidden",
  horizontal: "overflow-x-auto overflow-y-hidden",
  both: "overflow-auto",
};

const TYPE_MODIFIERS: Record<ScrollAreaType, string> = {
  auto: "",
  always: "",
  scroll: "",
  hover: "overflow-hidden hover:overflow-auto",
};

// ─── Component ────────────────────────────────────────────────────────────────

export function ScrollArea({
  orientation = "vertical",
  type = "auto",
  children,
  className,
  onKeyDown,
  ...props
}: ScrollAreaProps) {
  const scrollAreaRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const element = scrollAreaRef.current;
    if (!element) return;

    const handleKeyDown = (e: KeyboardEvent) => {
      const scrollAmount = 64;
      const pageAmount = element.clientHeight * 0.9;

      switch (e.key) {
        case "ArrowDown":
          if (orientation === "vertical" || orientation === "both") {
            e.preventDefault();
            element.scrollBy({ top: scrollAmount, behavior: "smooth" });
          }
          break;
        case "ArrowUp":
          if (orientation === "vertical" || orientation === "both") {
            e.preventDefault();
            element.scrollBy({ top: -scrollAmount, behavior: "smooth" });
          }
          break;
        case "ArrowRight":
          if (orientation === "horizontal" || orientation === "both") {
            e.preventDefault();
            element.scrollBy({ left: scrollAmount, behavior: "smooth" });
          }
          break;
        case "ArrowLeft":
          if (orientation === "horizontal" || orientation === "both") {
            e.preventDefault();
            element.scrollBy({ left: -scrollAmount, behavior: "smooth" });
          }
          break;
        case "PageDown":
          if (orientation === "vertical" || orientation === "both") {
            e.preventDefault();
            element.scrollBy({ top: pageAmount, behavior: "smooth" });
          }
          break;
        case "PageUp":
          if (orientation === "vertical" || orientation === "both") {
            e.preventDefault();
            element.scrollBy({ top: -pageAmount, behavior: "smooth" });
          }
          break;
        case "Home":
          if (orientation === "vertical" || orientation === "both") {
            e.preventDefault();
            element.scrollTo({ top: 0, behavior: "smooth" });
          }
          break;
        case "End":
          if (orientation === "vertical" || orientation === "both") {
            e.preventDefault();
            element.scrollTo({ top: element.scrollHeight, behavior: "smooth" });
          }
          break;
      }

      if (onKeyDown) {
        const reactEvent = e as unknown as ReactKeyboardEvent<HTMLDivElement>;
        onKeyDown(reactEvent);
      }
    };

    element.addEventListener("keydown", handleKeyDown);
    return () => element.removeEventListener("keydown", handleKeyDown);
  }, [orientation, onKeyDown]);

  return (
    <div
      ref={scrollAreaRef}
      tabIndex={0}
      role="region"
      aria-orientation={orientation === "both" ? "vertical" : orientation}
      className={cn(
        "scrollarea-scrollbar",
        ORIENTATION_CLASSES[orientation],
        TYPE_MODIFIERS[type],
        className
      )}
      {...props}
    >
      {children}
    </div>
  );
}
04

Props

ScrollArea extends all standard div HTML attributes and accepts the following props:

PropTypeDefaultDescription
orientation"vertical" | "horizontal" | "both"verticalDirection of scrolling. Vertical scrolls up/down, horizontal scrolls left/right, both allows scrolling in all directions.
type"auto" | "always" | "scroll" | "hover"autoControls scrollbar visibility. Auto shows when needed, always keeps it visible, scroll forces it, hover shows on interaction.
childrenReactNodeContent to display inside the scrollable area.
classNamestringAdditional CSS classes to apply (merged with base styles).
...propsHTMLAttributes<HTMLDivElement>All standard div attributes like onClick, onFocus, etc.
05

Accessibility

Keyboard Navigation

ScrollArea is fully keyboard accessible:

  • Tab into the scrollable area to focus it
  • Use Arrow keys for directional scrolling
  • Page Up/Down for page-by-page navigation
  • Home/End to jump to start or end
  • Smooth scrolling for better UX

ARIA Attributes

Built-in accessibility features:

  • role="region" - Identifies as a scrollable region
  • aria-orientation - Indicates scroll direction
  • tabIndex=0 - Makes the area focusable
  • Orientation-aware keyboard handling

Screen Reader Support

Screen readers announce the scrollable region and its orientation. Users can navigate using standard keyboard shortcuts.

React Principles