HextaUI

Scroll area

Native scrolling with a minimal scrollbar, edges that fade only where there’s more to see, and an optional peek that cuts the last item in half.

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

const names = [
  "Olivia Martin",
  "Jackson Lee",
  "Isabella Nguyen",
  "William Kim",
  "Sofia Davis",
  "Liam Patel",
  "Emma Garcia",
  "Noah Wilson",
]
const roles = ["Design", "Engineering", "Product", "Support"]

const people = Array.from({ length: 40 }, (_, index) => ({
  id: index + 1,
  name: names[index % names.length],
  role: roles[index % roles.length],
}))

export function ScrollAreaDemo() {
  return (
    <ScrollArea peek className="h-80 w-full max-w-sm rounded-lg border">
      <ul className="flex flex-col gap-1 p-2">
        {people.map((person) => (
          <li
            key={person.id}
            className="flex items-center justify-between gap-3 rounded-md px-3 py-2.5 text-sm hover:bg-muted"
          >
            <span className="truncate font-medium">
              {person.id}. {person.name}
            </span>
            <span className="text-muted-foreground">{person.role}</span>
          </li>
        ))}
      </ul>
    </ScrollArea>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/scroll-area.json

Adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

Give the scroll area a fixed height or width. Content beyond it scrolls with the native scroll behavior, and a thin scrollbar appears while you hover or scroll.

import { ScrollArea } from "@/components/ui/scroll-area"
<ScrollArea className="h-72 rounded-lg border">
  <div className="p-4">{/* long content */}</div>
</ScrollArea>

Peek

Both lists have the same height. With peek, on the right, the box trims itself so the last visible item is cut partway through, which shows there is more to scroll before anyone tries. It never trims the box below half its height.

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

const names = [
  "Olivia Martin",
  "Jackson Lee",
  "Isabella Nguyen",
  "William Kim",
  "Sofia Davis",
  "Liam Patel",
  "Emma Garcia",
  "Noah Wilson",
]
const roles = ["Design", "Engineering", "Product", "Support"]

const people = Array.from({ length: 40 }, (_, index) => ({
  id: index + 1,
  name: names[index % names.length],
  role: roles[index % roles.length],
}))

function PeopleList() {
  return (
    <ul className="flex flex-col gap-1 p-2">
      {people.map((person) => (
        <li
          key={person.id}
          className="flex items-center justify-between gap-3 rounded-md px-3 py-2.5 text-sm hover:bg-muted"
        >
          <span className="truncate font-medium">
            {person.id}. {person.name}
          </span>
          <span className="text-muted-foreground">{person.role}</span>
        </li>
      ))}
    </ul>
  )
}

export function ScrollAreaPeek() {
  return (
    <div className="grid w-full max-w-xl grid-cols-2 gap-4">
      <ScrollArea className="h-80 rounded-lg border">
        <PeopleList />
      </ScrollArea>
      <ScrollArea peek className="h-80 rounded-lg border">
        <PeopleList />
      </ScrollArea>
    </div>
  )
}

Text

Edges fade only where there is more content to scroll to. At the top only the bottom fades, and the fade grows in as you scroll away from an edge.

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

export function ScrollAreaText() {
  return (
    <ScrollArea className="h-48 w-full max-w-md rounded-lg border">
      <div className="flex flex-col gap-3 p-4 text-sm leading-6">
        {Array.from({ length: 6 }, (_, index) => (
          <p key={index}>
            Vernacular architecture is building done outside any academic
            tradition, and without professional guidance. It reflects local
            traditions, materials and climate, and makes up most of the world’s
            built environment.
          </p>
        ))}
      </div>
    </ScrollArea>
  )
}

Start at the bottom

viewportRef gives you the scrolling element. Set its scrollTop to start a chat at the latest message, so only the top edge fades.

"use client"

import * as React from "react"

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

export function ScrollAreaChat() {
  const viewportRef = React.useRef<HTMLDivElement>(null)

  React.useLayoutEffect(() => {
    const viewport = viewportRef.current
    if (viewport) {
      viewport.scrollTop = viewport.scrollHeight
    }
  }, [])

  return (
    <ScrollArea
      viewportRef={viewportRef}
      aria-label="Messages"
      className="h-56 w-full max-w-sm rounded-lg border"
    >
      <div className="flex flex-col gap-2 p-3 text-sm">
        {Array.from({ length: 24 }, (_, index) => (
          <p
            key={index}
            className={
              index % 3 === 0
                ? "self-end rounded-lg bg-primary px-3 py-2 text-primary-foreground"
                : "self-start rounded-lg bg-muted px-3 py-2"
            }
          >
            Message {index + 1}
          </p>
        ))}
      </div>
    </ScrollArea>
  )
}

Horizontal

Set scrollbars="horizontal" and give the content w-max so it can grow past the box.

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

const tags = [
  "Design systems",
  "Motion",
  "Accessibility",
  "Typography",
  "Color",
  "Layout",
  "Forms",
  "Data tables",
  "Charts",
  "Navigation",
  "Overlays",
  "Feedback",
]

export function ScrollAreaHorizontal() {
  return (
    <ScrollArea
      scrollbars="horizontal"
      className="w-full max-w-md rounded-lg border"
    >
      <div className="flex w-max gap-2 p-3">
        {tags.map((tag) => (
          <span
            key={tag}
            className="rounded-full border px-3 py-1 text-sm whitespace-nowrap"
          >
            {tag}
          </span>
        ))}
      </div>
    </ScrollArea>
  )
}

Both axes

scrollbars="both" shows both scrollbars and a corner where they meet, and fades all four edges.

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

export function ScrollAreaBoth() {
  return (
    <ScrollArea
      scrollbars="both"
      className="h-64 w-full max-w-md rounded-lg border"
    >
      <div className="grid w-max grid-cols-[repeat(10,6rem)] gap-2 p-3">
        {Array.from({ length: 100 }, (_, index) => (
          <div
            key={index}
            className="grid aspect-square place-items-center rounded-md bg-muted text-sm"
          >
            {index + 1}
          </div>
        ))}
      </div>
    </ScrollArea>
  )
}

Dynamic content

Peek and the fades update as items are added or removed. With too few items to scroll, the box keeps its height and nothing fades.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"

export function ScrollAreaDynamic() {
  const [count, setCount] = React.useState(4)

  return (
    <div className="flex w-full max-w-sm flex-col gap-3">
      <ScrollArea peek className="h-48 rounded-lg border">
        <ul className="flex flex-col gap-1 p-2">
          {Array.from({ length: count }, (_, index) => (
            <li key={index} className="rounded-md bg-muted px-3 py-3 text-sm">
              Item {index + 1}
            </li>
          ))}
        </ul>
      </ScrollArea>
      <div className="flex gap-2">
        <Button size="sm" onClick={() => setCount(count + 1)}>
          Add item
        </Button>
        <Button
          size="sm"
          variant="ghost"
          onClick={() => setCount(Math.max(0, count - 1))}
        >
          Remove item
        </Button>
      </div>
    </div>
  )
}

On a colored surface

The fade is a mask on the content, not an overlay color, so it works on any background.

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

const names = [
  "Olivia Martin",
  "Jackson Lee",
  "Isabella Nguyen",
  "William Kim",
  "Sofia Davis",
  "Liam Patel",
  "Emma Garcia",
  "Noah Wilson",
]
const roles = ["Design", "Engineering", "Product", "Support"]

const people = Array.from({ length: 40 }, (_, index) => ({
  id: index + 1,
  name: names[index % names.length],
  role: roles[index % roles.length],
}))

export function ScrollAreaColoredSurface() {
  return (
    <div className="w-full max-w-sm rounded-xl bg-muted p-2">
      <ScrollArea className="h-56">
        <ul className="flex flex-col gap-1 p-2">
          {people.map((person) => (
            <li
              key={person.id}
              className="flex items-center justify-between gap-3 rounded-md px-3 py-2.5 text-sm hover:bg-background"
            >
              <span className="truncate font-medium">
                {person.id}. {person.name}
              </span>
              <span className="text-muted-foreground">{person.role}</span>
            </li>
          ))}
        </ul>
      </ScrollArea>
    </div>
  )
}

Inside a sheet

Inside a flex column, wrap it in an element with min-h-0 flex-1 and give the scroll area h-full so it fills the remaining space.

import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
  Sheet,
  SheetContent,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

const names = [
  "Olivia Martin",
  "Jackson Lee",
  "Isabella Nguyen",
  "William Kim",
  "Sofia Davis",
  "Liam Patel",
  "Emma Garcia",
  "Noah Wilson",
]
const roles = ["Design", "Engineering", "Product", "Support"]

const people = Array.from({ length: 40 }, (_, index) => ({
  id: index + 1,
  name: names[index % names.length],
  role: roles[index % roles.length],
}))

export function ScrollAreaSheet() {
  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>
        Open members
      </SheetTrigger>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>Members</SheetTitle>
        </SheetHeader>
        <div className="min-h-0 flex-1 px-4 pb-4">
          <ScrollArea peek className="h-full">
            <ul className="flex flex-col gap-1 p-2">
              {people.map((person) => (
                <li
                  key={person.id}
                  className="flex items-center justify-between gap-3 rounded-md px-3 py-2.5 text-sm hover:bg-muted"
                >
                  <span className="truncate font-medium">
                    {person.id}. {person.name}
                  </span>
                  <span className="text-muted-foreground">{person.role}</span>
                </li>
              ))}
            </ul>
          </ScrollArea>
        </div>
      </SheetContent>
    </Sheet>
  )
}

Without fade

Turn the edge fades off with fade={false}.

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

const names = [
  "Olivia Martin",
  "Jackson Lee",
  "Isabella Nguyen",
  "William Kim",
  "Sofia Davis",
  "Liam Patel",
  "Emma Garcia",
  "Noah Wilson",
]
const roles = ["Design", "Engineering", "Product", "Support"]

const people = Array.from({ length: 40 }, (_, index) => ({
  id: index + 1,
  name: names[index % names.length],
  role: roles[index % roles.length],
}))

export function ScrollAreaWithoutFade() {
  return (
    <ScrollArea fade={false} className="h-40 w-full max-w-sm rounded-lg border">
      <ul className="flex flex-col gap-1 p-2">
        {people.map((person) => (
          <li
            key={person.id}
            className="flex items-center justify-between gap-3 rounded-md px-3 py-2.5 text-sm hover:bg-muted"
          >
            <span className="truncate font-medium">
              {person.id}. {person.name}
            </span>
            <span className="text-muted-foreground">{person.role}</span>
          </li>
        ))}
      </ul>
    </ScrollArea>
  )
}

Right to left

Content starts at the right edge, the scrollbar and fades follow the reading direction, and the direction is picked up from the page.

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

const tags = [
  "أنظمة التصميم",
  "الحركة",
  "إمكانية الوصول",
  "الطباعة",
  "الألوان",
  "التخطيط",
  "النماذج",
  "جداول البيانات",
  "الرسوم البيانية",
  "التنقل",
]

export function ScrollAreaRtl() {
  return (
    <div dir="rtl" className="w-full max-w-md">
      <ScrollArea scrollbars="horizontal" className="rounded-lg border">
        <div className="flex w-max gap-2 p-3">
          {tags.map((tag) => (
            <span
              key={tag}
              className="rounded-full border px-3 py-1 text-sm whitespace-nowrap"
            >
              {tag}
            </span>
          ))}
        </div>
      </ScrollArea>
    </div>
  )
}

The viewport joins the tab order only when it has something to scroll. Once focused, it scrolls with the browser’s native keys.

KeyAction
TabFocuses the viewport when its content overflows.
↑↓Scrolls vertically.
←→Scrolls horizontally.
Page UpPage DownSpaceScrolls by a page.
HomeEndJumps to the start or end.
  • Give the scroll area an aria-label when its purpose isn’t clear from the surrounding content, like the “Messages” label in the chat example.
  • The focus ring is drawn on the scroll area itself, so it stays visible above the faded edges.

Built on the Base UI scroll area. ScrollArea renders the root, viewport, content and scrollbars together.

PropTypeDefault
scrollbars
"vertical" | "horizontal" | "both""vertical"
fadeFade the edges that have more content.
booleantrue
peekTrim the height so the last visible item is cut partway. Mark items with data-scroll-area-item to choose which elements count.
booleanfalse
viewportRefThe element that scrolls.
Ref<HTMLDivElement>–
overflowEdgeThresholdPixels to scroll before an edge counts as overflowing.
number | { xStart, xEnd, yStart, yEnd }0
className
string | (state) => string–
render
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="scroll-area"The root.
data-slot="scroll-area-viewport"The element that scrolls.
data-slot="scroll-area-content"Wraps your content inside the viewport.
data-peekPresent when peek is on.
data-peekingPresent while peek is trimming the height.
data-scrollingPresent while the user scrolls.
data-has-overflow-xPresent when content is wider than the viewport.
data-has-overflow-yPresent when content is taller than the viewport.
data-overflow-y-startPresent when there is more content above. Matching -y-end, -x-start and -x-end attributes exist for the other edges.
--scroll-area-fadeSize of the edge fade. Defaults to 2.5rem.
--scroll-area-overflow-y-startDistance from the top edge in pixels. Matching -y-end, -x-start and -x-end variables exist.

Rendered for you by ScrollArea. Export it only if you compose the Base UI parts yourself.

PropTypeDefault
orientation
"vertical" | "horizontal""vertical"
render
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="scroll-area-scrollbar"The scrollbar track.
data-slot="scroll-area-thumb"The draggable thumb.
data-orientationvertical or horizontal.
data-hoveringPresent while the pointer is over the area.
data-scrollingPresent while the user scrolls.
--scroll-area-thumb-heightThe thumb’s height.
--scroll-area-thumb-widthThe thumb’s width.