---
title: Scroll Area
description: Native overflow with styled scrollbars.
group: Components
order: 56
---

Native overflow with styled scrollbars. Use the default vertical direction or add horizontal scrolling for wide content.

```tsx
import { Fragment } from "react"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"

const tags = Array.from({ length: 50 }).map(
  (_, i, a) => `v1.2.0-beta.${a.length - i}`,
)

export function Example() {
  return (
    <ScrollArea className="h-72 w-48 rounded-md border border-border">
      <div className="p-4">
        <h4 className="mb-4 text-sm font-medium tracking-wide text-foreground">
          Tags
        </h4>
        {tags.map((tag, i) => (
          <Fragment key={tag}>
            <div className="text-sm font-normal tracking-wide text-foreground">
              {tag}
            </div>
            {i < tags.length - 1 ? <Separator className="my-2" /> : null}
          </Fragment>
        ))}
      </div>
    </ScrollArea>
  )
}
```

## Installation

```bash
npx @arctis-sh/@arctis-sh/ui@latest add scroll-area
```

## Usage

```tsx
import { ScrollArea } from "@/components/ui/scroll-area"
```

```tsx
<ScrollArea className="h-[200px] w-[350px] rounded-md border border-border">
  <div className="p-4">Scrollable content.</div>
</ScrollArea>
```

## Composition

Vertical and horizontal scrollbars are included automatically.

```tree
ScrollArea
├── ScrollBar (vertical)
└── ScrollBar (horizontal)
```

## Basic

A fixed-height area with a long list. Put padding on the content rather than the root.

```tsx
import { Fragment } from "react"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"

const tags = Array.from({ length: 50 }).map(
  (_, i, a) => `v1.2.0-beta.${a.length - i}`,
)

export function Example() {
  return (
    <ScrollArea className="h-72 w-48 rounded-md border border-border">
      <div className="p-4">
        <h4 className="mb-4 text-sm font-medium tracking-wide text-foreground">
          Tags
        </h4>
        {tags.map((tag, i) => (
          <Fragment key={tag}>
            <div className="text-sm font-normal tracking-wide text-foreground">
              {tag}
            </div>
            {i < tags.length - 1 ? <Separator className="my-2" /> : null}
          </Fragment>
        ))}
      </div>
    </ScrollArea>
  )
}
```

## Horizontal

Wide content reveals the horizontal scrollbar. Give the root a width and keep the row at `w-max`.

```tsx
import { ScrollArea } from "@/components/ui/scroll-area"

const works = [
  {
    label: "Attachment 1",
    art: "/assets/brand/demos/attachments/attachment-1.png",
  },
  {
    label: "Attachment 2",
    art: "/assets/brand/demos/attachments/attachment-2.png",
  },
  {
    label: "Attachment 3",
    art: "/assets/brand/demos/attachments/attachment-3.png",
  },
  {
    label: "Attachment 4",
    art: "/assets/brand/demos/attachments/attachment-4.png",
  },
  {
    label: "Attachment 5",
    art: "/assets/brand/demos/attachments/attachment-5.png",
  },
]

export function Example() {
  return (
    <ScrollArea className="w-96 whitespace-nowrap rounded-md border border-border">
      <div className="flex w-max gap-4 p-4">
        {works.map((work) => (
          <figure key={work.label} className="shrink-0">
            <div className="overflow-hidden rounded-md">
              <img
                src={work.art}
                alt={work.label}
                className="aspect-[3/4] h-fit w-[150px] object-cover"
              />
            </div>
            <figcaption className="pt-2 text-xs font-normal tracking-wide text-muted-foreground">
              {work.label}
            </figcaption>
          </figure>
        ))}
      </div>
    </ScrollArea>
  )
}
```

## API Reference

### ScrollArea

The sized root contains the scroll viewport. Vertical and horizontal scrollbars appear only when content overflows.

| Prop | Type | Default |
| --- | --- | --- |
| className | string | — |

```tsx
<ScrollArea className="h-48 w-64">
  <div className="p-4">Long content…</div>
</ScrollArea>
```

### ScrollBar

Rendered by `ScrollArea` when needed. Exported for the composition API.

| Prop | Type | Default |
| --- | --- | --- |
| orientation | "vertical" \| "horizontal" | "vertical" |
| className | string | — |

```tsx
<ScrollBar orientation="vertical" />
```

## Source

```tsx
"use client";

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useRef,
  useState,
  type HTMLAttributes,
  type PointerEvent,
  type RefObject,
} from "react";
import { cn } from "@/lib/utils";

type ScrollAreaContextValue = {
  viewportRef: RefObject<HTMLDivElement | null>;
};

const ScrollAreaContext = createContext<ScrollAreaContextValue | null>(null);

function ScrollArea({
  className,
  children,
  ...props
}: HTMLAttributes<HTMLDivElement>) {
  const viewportRef = useRef<HTMLDivElement>(null);

  return (
    <ScrollAreaContext.Provider value={{ viewportRef }}>
      <div
        data-slot="scroll-area"
        className={cn("relative overflow-hidden", className)}
        {...props}
      >
        <div
          ref={viewportRef}
          data-slot="scroll-area-viewport"
          className={cn(
            "size-full overflow-auto rounded-[inherit]",
            "[scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden",
          )}
        >
          {children}
        </div>
        <ScrollBar />
        <ScrollBar orientation="horizontal" />
      </div>
    </ScrollAreaContext.Provider>
  );
}

function ScrollBar({
  className,
  orientation = "vertical",
  ...props
}: HTMLAttributes<HTMLDivElement> & {
  orientation?: "vertical" | "horizontal";
}) {
  const ctx = useContext(ScrollAreaContext);
  const trackRef = useRef<HTMLDivElement>(null);
  const [thumb, setThumb] = useState({ size: 0, offset: 0, needed: false });
  const dragRef = useRef<{
    pointerId: number;
    start: number;
    scrollStart: number;
  } | null>(null);

  const update = useCallback(() => {
    const viewport = ctx?.viewportRef.current;
    const track = trackRef.current;
    if (!viewport) return;

    if (orientation === "vertical") {
      const { clientHeight, scrollHeight, scrollTop } = viewport;
      const needed = scrollHeight > clientHeight + 1;
      if (!needed) {
        setThumb({ size: 0, offset: 0, needed: false });
        return;
      }
      const trackSize = track?.clientHeight || clientHeight;
      const size = Math.max((clientHeight / scrollHeight) * trackSize, 16);
      const maxOffset = Math.max(trackSize - size, 0);
      const offset =
        maxOffset === 0
          ? 0
          : (scrollTop / (scrollHeight - clientHeight)) * maxOffset;
      setThumb({ size, offset, needed: true });
      return;
    }

    const { clientWidth, scrollWidth, scrollLeft } = viewport;
    const needed = scrollWidth > clientWidth + 1;
    if (!needed) {
      setThumb({ size: 0, offset: 0, needed: false });
      return;
    }
    const trackSize = track?.clientWidth || clientWidth;
    const size = Math.max((clientWidth / scrollWidth) * trackSize, 16);
    const maxOffset = Math.max(trackSize - size, 0);
    const offset =
      maxOffset === 0
        ? 0
        : (scrollLeft / (scrollWidth - clientWidth)) * maxOffset;
    setThumb({ size, offset, needed: true });
  }, [ctx, orientation]);

  useEffect(() => {
    const viewport = ctx?.viewportRef.current;
    if (!viewport) return;

    update();
    viewport.addEventListener("scroll", update, { passive: true });
    const ro = new ResizeObserver(update);
    ro.observe(viewport);
    const content = viewport.firstElementChild;
    if (content) ro.observe(content);
    if (trackRef.current) ro.observe(trackRef.current);

    return () => {
      viewport.removeEventListener("scroll", update);
      ro.disconnect();
    };
  }, [ctx, update]);

  function onThumbPointerDown(event: PointerEvent<HTMLDivElement>) {
    const viewport = ctx?.viewportRef.current;
    if (!viewport) return;

    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    dragRef.current = {
      pointerId: event.pointerId,
      start: orientation === "vertical" ? event.clientY : event.clientX,
      scrollStart:
        orientation === "vertical" ? viewport.scrollTop : viewport.scrollLeft,
    };
  }

  function onThumbPointerMove(event: PointerEvent<HTMLDivElement>) {
    const drag = dragRef.current;
    const viewport = ctx?.viewportRef.current;
    const track = trackRef.current;
    if (!drag || drag.pointerId !== event.pointerId || !viewport || !track) {
      return;
    }

    if (orientation === "vertical") {
      const maxOffset = track.clientHeight - thumb.size;
      if (maxOffset <= 0) return;
      const maxScroll = viewport.scrollHeight - viewport.clientHeight;
      const delta = event.clientY - drag.start;
      viewport.scrollTop = drag.scrollStart + (delta / maxOffset) * maxScroll;
      return;
    }

    const maxOffset = track.clientWidth - thumb.size;
    if (maxOffset <= 0) return;
    const maxScroll = viewport.scrollWidth - viewport.clientWidth;
    const delta = event.clientX - drag.start;
    viewport.scrollLeft = drag.scrollStart + (delta / maxOffset) * maxScroll;
  }

  function onThumbPointerUp(event: PointerEvent<HTMLDivElement>) {
    if (dragRef.current?.pointerId === event.pointerId) {
      dragRef.current = null;
    }
  }

  return (
    <div
      ref={trackRef}
      data-slot="scroll-area-scrollbar"
      data-orientation={orientation}
      aria-hidden={!thumb.needed}
      className={cn(
        "pointer-events-none absolute z-10 touch-none select-none",
        orientation === "vertical" && "top-1 bottom-1 right-0 w-2.5",
        orientation === "horizontal" && "right-1 bottom-0 left-1 h-2.5",
        !thumb.needed && "invisible",
        className,
      )}
      {...props}
    >
      <div
        data-slot="scroll-area-thumb"
        className="pointer-events-auto absolute rounded-full bg-border"
        style={
          orientation === "vertical"
            ? {
                top: thumb.offset,
                height: thumb.size,
                left: 1,
                right: 1,
              }
            : {
                left: thumb.offset,
                width: thumb.size,
                top: 1,
                bottom: 1,
              }
        }
        onPointerDown={onThumbPointerDown}
        onPointerMove={onThumbPointerMove}
        onPointerUp={onThumbPointerUp}
        onPointerCancel={onThumbPointerUp}
      />
    </div>
  );
}

export { ScrollArea, ScrollBar };
```
