HextaUI

Scroll area

最小限のスクロールバーを備えたネイティブスクロールです。端は続きがある側だけフェードし、最後の項目を半分で切って見せるピークも任意で使えます。

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

コンポーネント、HextaUIのテーマトークン、依存するHextaUIコンポーネントを追加します。

スクロール領域に固定の高さまたは幅を指定します。それを超えるコンテンツはネイティブのスクロール動作でスクロールし、ホバー中やスクロール中は細いスクロールバーが現れます。

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

Peek

どちらのリストも同じ高さです。右側の peek では、最後に見える項目が途中で切れるようにボックスが自動で高さを詰め、誰かが試す前にスクロールできることを示します。ボックスの高さを半分未満には詰めません。

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>
  )
}

テキスト

フェードは、スクロール先にさらにコンテンツがある端にのみ付きます。上端にいる間は下端だけがフェードし、端から離れるほどフェードが強くなります。

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>
  )
}

下端から開始

viewportRef はスクロールする要素を返します。その scrollTop を設定してチャットを最新のメッセージから始めると、上端だけがフェードします。

"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>
  )
}

水平

scrollbars="horizontal" を設定し、コンテンツがボックスを超えて伸びられるよう w-max を指定します。

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>
  )
}

両方の軸

scrollbars="both" は両方のスクロールバーとその交点のコーナーを表示し、四辺すべてをフェードします。

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>
  )
}

動的なコンテンツ

Peek とフェードは、項目の追加や削除に応じて更新されます。スクロールするには項目が少なすぎる場合、ボックスは高さを保ち、何もフェードしません。

"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>
  )
}

色付きの面の上

フェードはオーバーレイの色ではなくコンテンツへのマスクなので、どんな背景でも機能します。

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>
  )
}

シートの中

flex の列の内側では、min-h-0 flex-1 を付けた要素で囲み、スクロール領域に h-full を指定して、残りの空間を埋めます。

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>
  )
}

フェードなし

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>
  )
}

右から左

コンテンツは右端から始まり、スクロールバーとフェードは文字の読み方向に従い、方向はページから取得されます。

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>
  )
}

ビューポートは、スクロールできるものがあるときにだけタブ順に入ります。フォーカスされると、ブラウザーのネイティブのキーでスクロールします。

キーアクション
Tabコンテンツがあふれているとき、ビューポートにフォーカスします。
↑↓縦にスクロールします。
←→横にスクロールします。
Page UpPage DownSpace1ページ分スクロールします。
HomeEnd先頭または末尾へジャンプします。
  • チャットの例の「Messages」ラベルのように、周囲のコンテンツから用途が明確でない場合は、スクロール領域に aria-label を付けてください。
  • フォーカスリングはスクロール領域自体に描画されるため、フェードした端の上でも見え続けます。

Base UI の scroll area 上に構築されています。ScrollArea は、ルート、ビューポート、コンテンツ、スクロールバーをまとめて描画します。

プロパティ型デフォルト
scrollbars
"vertical" | "horizontal" | "both""vertical"
fadeさらにコンテンツがある端をフェードします。
booleantrue
peek最後に見える項目が途中で切れるように高さを詰めます。data-scroll-area-item を付けた項目が対象になるため、どの要素を数えるか選べます。
booleanfalse
viewportRefスクロールする要素。
Ref<HTMLDivElement>–
overflowEdgeThreshold端があふれているとみなすまでにスクロールするピクセル数。
number | { xStart, xEnd, yStart, yEnd }0
className
string | (state) => string–
render
ReactElement | (props, state) => ReactElement<div>
属性説明
data-slot="scroll-area"ルート。
data-slot="scroll-area-viewport"スクロールする要素。
data-slot="scroll-area-content"コンテンツをビューポートの内側で囲みます。
data-peekpeek が有効なときに付きます。
data-peekingpeek が高さを詰めている間付きます。
data-scrollingユーザーがスクロールしている間付きます。
data-has-overflow-xコンテンツがビューポートより広いときに付きます。
data-has-overflow-yコンテンツがビューポートより高いときに付きます。
data-overflow-y-start上にさらにコンテンツがあるときに付きます。他の端には、-y-end、-x-start、-x-end の対応する属性があります。
--scroll-area-fade端のフェードのサイズ。デフォルトは 2.5rem です。
--scroll-area-overflow-y-start上端からの距離(ピクセル)。-y-end、-x-start、-x-end の対応する変数もあります。

ScrollArea が自動で描画します。Base UI のパーツを自分で組み立てる場合にのみエクスポートしてください。

プロパティ型デフォルト
orientation
"vertical" | "horizontal""vertical"
render
ReactElement | (props, state) => ReactElement<div>
属性説明
data-slot="scroll-area-scrollbar"スクロールバーのトラック。
data-slot="scroll-area-thumb"ドラッグ可能なつまみ。
data-orientationvertical または horizontal。
data-hoveringポインターが領域上にある間付きます。
data-scrollingユーザーがスクロールしている間付きます。
--scroll-area-thumb-heightつまみの高さ。
--scroll-area-thumb-widthつまみの幅。