HextaUI

Collapsible

高さのモーションで表示と非表示を切り替えるパネルです。途中で反転でき、レイアウトが跳ねません。

@preetsuthar17 starred 3 repositories

@radix-ui/primitives
import { Button } from "@/components/ui/button"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  CollapsibleTriggerIcon,
} from "@/components/ui/collapsible"

export function CollapsibleDemo() {
  return (
    <Collapsible className="flex w-full max-w-sm flex-col gap-2">
      <div className="flex items-center justify-between gap-4 ps-4">
        <h4 className="text-sm font-semibold">
          <span dir="ltr">@preetsuthar17</span> starred 3 repositories
        </h4>
        <CollapsibleTrigger
          render={<Button variant="ghost" size="icon-sm" />}
          aria-label="Toggle repositories"
        >
          <CollapsibleTriggerIcon />
        </CollapsibleTrigger>
      </div>
      <div className="rounded-md border px-4 py-2 font-mono text-sm">
        <span dir="ltr">@radix-ui/primitives</span>
      </div>
      <CollapsibleContent className="flex flex-col gap-2">
        <div className="rounded-md border px-4 py-2 font-mono text-sm">
          <span dir="ltr">@base-ui/react</span>
        </div>
        <div className="rounded-md border px-4 py-2 font-mono text-sm">
          <span dir="ltr">@tabler/icons</span>
        </div>
      </CollapsibleContent>
    </Collapsible>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/collapsible.json

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

import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  CollapsibleTriggerIcon,
} from "@/components/ui/collapsible"
<Collapsible>
  <CollapsibleTrigger render={<Button variant="outline" />}>
    Details
    <CollapsibleTriggerIcon data-icon="inline-end" />
  </CollapsibleTrigger>
  <CollapsibleContent>
    Shipping takes 2 business days.
  </CollapsibleContent>
</Collapsible>
Collapsible
├── CollapsibleTrigger
│   └── CollapsibleTriggerIcon
└── CollapsibleContent

デフォルトで開く

defaultOpen は、state を引き受けずに、パネルを開いた状態で開始します。

import { Button } from "@/components/ui/button"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  CollapsibleTriggerIcon,
} from "@/components/ui/collapsible"

export function CollapsibleOpenByDefault() {
  return (
    <Collapsible defaultOpen className="flex w-full max-w-sm flex-col gap-2">
      <div className="flex items-center justify-between gap-4 ps-4">
        <h4 className="text-sm font-semibold">
          <span dir="ltr">@preetsuthar17</span> starred 3 repositories
        </h4>
        <CollapsibleTrigger
          render={<Button variant="ghost" size="icon-sm" />}
          aria-label="Toggle repositories"
        >
          <CollapsibleTriggerIcon />
        </CollapsibleTrigger>
      </div>
      <div className="rounded-md border px-4 py-2 font-mono text-sm">
        <span dir="ltr">@radix-ui/primitives</span>
      </div>
      <CollapsibleContent className="flex flex-col gap-2">
        <div className="rounded-md border px-4 py-2 font-mono text-sm">
          <span dir="ltr">@base-ui/react</span>
        </div>
        <div className="rounded-md border px-4 py-2 font-mono text-sm">
          <span dir="ltr">@tabler/icons</span>
        </div>
      </CollapsibleContent>
    </Collapsible>
  )
}

さらに表示

パネルはトリガーの上に配置されます。閉じるときは、親の flex レイアウトの余白が同じアニメーションで詰まるため、下のボタンが跳ねることはありません。

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  CollapsibleTriggerIcon,
} from "@/components/ui/collapsible"

export function CollapsibleShowMore() {
  const [open, setOpen] = React.useState(false)

  return (
    <Collapsible
      open={open}
      onOpenChange={setOpen}
      className="flex max-w-md flex-col gap-3"
    >
      <p className="text-sm text-muted-foreground">
        HextaUI components are built on Base UI primitives and styled with
        Tailwind. You own the source, so every class can be changed.
      </p>
      <CollapsibleContent>
        <p className="text-sm text-muted-foreground">
          Motion is interruptible: clicking again mid-animation reverses from
          where the panel is instead of snapping. The gap between this paragraph
          and its neighbours collapses in the same animation, so nothing below
          jumps when it finishes.
        </p>
      </CollapsibleContent>
      <div>
        <CollapsibleTrigger render={<Button variant="outline" size="sm" />}>
          {open ? "Show less" : "Show more"}
          <CollapsibleTriggerIcon data-icon="inline-end" />
        </CollapsibleTrigger>
      </div>
    </Collapsible>
  )
}

制御

パネルを自分の state で制御するには、open と onOpenChange を渡します。アニメーションの途中で切り替えると、パネルの現在位置から逆再生されます。

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  CollapsibleTriggerIcon,
} from "@/components/ui/collapsible"

export function CollapsibleControlled() {
  const [open, setOpen] = React.useState(false)

  return (
    <div className="flex w-full max-w-sm flex-col gap-3">
      <div className="flex items-center gap-2">
        <Button variant="outline" size="sm" onClick={() => setOpen(true)}>
          Open
        </Button>
        <Button variant="outline" size="sm" onClick={() => setOpen(false)}>
          Close
        </Button>
        <span className="text-sm text-muted-foreground">
          {open ? "Open" : "Closed"}
        </span>
      </div>
      <Collapsible
        open={open}
        onOpenChange={setOpen}
        className="flex flex-col gap-2"
      >
        <div className="flex items-center justify-between gap-4 ps-4">
          <h4 className="text-sm font-semibold">Release notes</h4>
          <CollapsibleTrigger
            render={<Button variant="ghost" size="icon-sm" />}
            aria-label="Toggle release notes"
          >
            <CollapsibleTriggerIcon />
          </CollapsibleTrigger>
        </div>
        <div className="rounded-md border px-4 py-2 text-sm">v2.4.0</div>
        <CollapsibleContent className="flex flex-col gap-2">
          <div className="rounded-md border px-4 py-2 text-sm">v2.3.1</div>
          <div className="rounded-md border px-4 py-2 text-sm">v2.3.0</div>
        </CollapsibleContent>
      </Collapsible>
    </div>
  )
}

無効

ルートの disabled は、パネルを現在の状態に保ち、トリガーがクリックとキー操作を無視するようにします。

import { Button } from "@/components/ui/button"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  CollapsibleTriggerIcon,
} from "@/components/ui/collapsible"

export function CollapsibleDisabled() {
  return (
    <Collapsible disabled className="flex w-full max-w-sm flex-col gap-2">
      <div className="flex items-center justify-between gap-4 ps-4">
        <h4 className="text-sm font-semibold">
          <span dir="ltr">@preetsuthar17</span> starred 3 repositories
        </h4>
        <CollapsibleTrigger
          render={<Button variant="ghost" size="icon-sm" />}
          aria-label="Toggle repositories"
        >
          <CollapsibleTriggerIcon />
        </CollapsibleTrigger>
      </div>
      <div className="rounded-md border px-4 py-2 font-mono text-sm">
        <span dir="ltr">@radix-ui/primitives</span>
      </div>
      <CollapsibleContent className="flex flex-col gap-2">
        <div className="rounded-md border px-4 py-2 font-mono text-sm">
          <span dir="ltr">@base-ui/react</span>
        </div>
        <div className="rounded-md border px-4 py-2 font-mono text-sm">
          <span dir="ltr">@tabler/icons</span>
        </div>
      </CollapsibleContent>
    </Collapsible>
  )
}

カスタムアイコン

<CollapsibleTriggerIcon /> の子要素は山形アイコンを置き換えます。開いた状態のスタイルは group-data-panel-open/collapsible-trigger で指定します。

import { IconPlus } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  CollapsibleTriggerIcon,
} from "@/components/ui/collapsible"

export function CollapsibleCustomIcon() {
  return (
    <Collapsible className="flex w-full max-w-sm flex-col gap-2">
      <CollapsibleTrigger
        render={<Button variant="ghost" />}
        className="self-start"
      >
        <CollapsibleTriggerIcon data-icon="inline-start">
          <IconPlus className="transition-transform duration-200 ease-out-cubic group-data-panel-open/collapsible-trigger:rotate-45 motion-reduce:transition-none" />
        </CollapsibleTriggerIcon>
        Add a note
      </CollapsibleTrigger>
      <CollapsibleContent>
        <p className="ps-3 text-sm text-muted-foreground">
          Notes are only visible to you.
        </p>
      </CollapsibleContent>
    </Collapsible>
  )
}

ファイルツリー

各フォルダーは独立した collapsible です。内側が開くと、外側のパネルがなめらかに広がります。

import { IconChevronRight, IconFile, IconFolder } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  CollapsibleTriggerIcon,
} from "@/components/ui/collapsible"

type TreeNode = { name: string; children?: TreeNode[] }

const tree: TreeNode[] = [
  {
    name: "components",
    children: [
      {
        name: "ui",
        children: [
          { name: "button.tsx" },
          { name: "collapsible.tsx" },
          { name: "card.tsx" },
        ],
      },
      { name: "theme-provider.tsx" },
    ],
  },
  { name: "app", children: [{ name: "layout.tsx" }, { name: "page.tsx" }] },
  { name: "package.json" },
]

function TreeItem({ node }: { node: TreeNode }) {
  if (!node.children) {
    return (
      <li className="flex h-8 items-center gap-2 ps-8 text-sm">
        <IconFile className="size-4 text-muted-foreground" />
        {node.name}
      </li>
    )
  }

  return (
    <li>
      <Collapsible defaultOpen={node.name === "components"}>
        <CollapsibleTrigger
          render={<Button variant="ghost" size="sm" />}
          className="w-full justify-start"
        >
          <CollapsibleTriggerIcon>
            <IconChevronRight className="transition-transform duration-200 ease-out-cubic group-data-panel-open/collapsible-trigger:rotate-90 motion-reduce:transition-none rtl:-scale-x-100 rtl:group-data-panel-open/collapsible-trigger:-rotate-90" />
          </CollapsibleTriggerIcon>
          <IconFolder className="text-muted-foreground" />
          {node.name}
        </CollapsibleTrigger>
        <CollapsibleContent>
          <ul className="ms-4 border-s ps-1">
            {node.children.map((child) => (
              <TreeItem key={child.name} node={child} />
            ))}
          </ul>
        </CollapsibleContent>
      </Collapsible>
    </li>
  )
}

export function CollapsibleFileTree() {
  return (
    <ul className="w-64">
      {tree.map((node) => (
        <TreeItem key={node.name} node={node} />
      ))}
    </ul>
  )
}

カードの内側

閉じたパネルは hidden=“until-found” でページ内に残るため、Cmd/Ctrl+F で「webhook」を検索するとこのパネルが開きます。

import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  CollapsibleTriggerIcon,
} from "@/components/ui/collapsible"

export function CollapsibleCard() {
  return (
    <Card className="w-full max-w-md">
      <CardHeader>
        <CardTitle>Notifications</CardTitle>
        <CardDescription>Choose where alerts are sent.</CardDescription>
      </CardHeader>
      <CardContent>
        <Collapsible className="flex flex-col gap-3">
          <CollapsibleTrigger
            render={<Button variant="outline" />}
            className="justify-between"
          >
            Advanced settings
            <CollapsibleTriggerIcon data-icon="inline-end" />
          </CollapsibleTrigger>
          <CollapsibleContent className="flex flex-col gap-2 text-sm text-muted-foreground">
            <p>Send a webhook to your endpoint for every alert.</p>
            <p>Retry failed deliveries up to five times with backoff.</p>
            <p>Group alerts that arrive within 30 seconds.</p>
          </CollapsibleContent>
        </Collapsible>
      </CardContent>
    </Card>
  )
}

閉じるとアンマウント

コンテンツに hiddenUntilFound={false} を設定すると、閉じている間は DOM から削除されます。最初にレンダリングしたくない重いパネル向けです。

import { Button } from "@/components/ui/button"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  CollapsibleTriggerIcon,
} from "@/components/ui/collapsible"

export function CollapsibleUnmounted() {
  return (
    <Collapsible className="flex w-full max-w-sm flex-col gap-2">
      <CollapsibleTrigger
        render={<Button variant="secondary" />}
        className="self-start"
      >
        Details
        <CollapsibleTriggerIcon data-icon="inline-end" />
      </CollapsibleTrigger>
      <CollapsibleContent hiddenUntilFound={false}>
        <p className="text-sm text-muted-foreground">
          Rendered only while open.
        </p>
      </CollapsibleContent>
    </Collapsible>
  )
}

長いコンテンツ

区切りのない文字列は、ページを広げず、パネル内で折り返されます。

import { Button } from "@/components/ui/button"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  CollapsibleTriggerIcon,
} from "@/components/ui/collapsible"

export function CollapsibleLongContent() {
  return (
    <Collapsible className="flex w-72 max-w-full flex-col gap-2">
      <CollapsibleTrigger
        render={<Button variant="outline" />}
        className="justify-between"
      >
        Token
        <CollapsibleTriggerIcon data-icon="inline-end" />
      </CollapsibleTrigger>
      <CollapsibleContent className="font-mono text-sm wrap-anywhere">
        hx_demo_7Kq2vX9mLpR4tYw8NzB3cF6jHs1DgQe5UaV0iMoTnWbZrE2yPkLxJ4
      </CollapsibleContent>
    </Collapsible>
  )
}

右から左

山形アイコンは反転し、ネストしたインデントは読む方向に従います。

import { IconChevronRight, IconFile, IconFolder } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  CollapsibleTriggerIcon,
} from "@/components/ui/collapsible"

type TreeNode = { name: string; children?: TreeNode[] }

const tree: TreeNode[] = [
  {
    name: "components",
    children: [
      {
        name: "ui",
        children: [
          { name: "button.tsx" },
          { name: "collapsible.tsx" },
          { name: "card.tsx" },
        ],
      },
      { name: "theme-provider.tsx" },
    ],
  },
  { name: "app", children: [{ name: "layout.tsx" }, { name: "page.tsx" }] },
  { name: "package.json" },
]

function TreeItem({ node }: { node: TreeNode }) {
  if (!node.children) {
    return (
      <li className="flex h-8 items-center gap-2 ps-8 text-sm">
        <IconFile className="size-4 text-muted-foreground" />
        {node.name}
      </li>
    )
  }

  return (
    <li>
      <Collapsible defaultOpen={node.name === "components"}>
        <CollapsibleTrigger
          render={<Button variant="ghost" size="sm" />}
          className="w-full justify-start"
        >
          <CollapsibleTriggerIcon>
            <IconChevronRight className="transition-transform duration-200 ease-out-cubic group-data-panel-open/collapsible-trigger:rotate-90 motion-reduce:transition-none rtl:-scale-x-100 rtl:group-data-panel-open/collapsible-trigger:-rotate-90" />
          </CollapsibleTriggerIcon>
          <IconFolder className="text-muted-foreground" />
          {node.name}
        </CollapsibleTrigger>
        <CollapsibleContent>
          <ul className="ms-4 border-s ps-1">
            {node.children.map((child) => (
              <TreeItem key={child.name} node={child} />
            ))}
          </ul>
        </CollapsibleContent>
      </Collapsible>
    </li>
  )
}

export function CollapsibleRtl() {
  return (
    <div dir="rtl" className="flex w-full max-w-sm flex-col gap-8">
      <Collapsible className="flex flex-col gap-2">
        <div className="flex items-center justify-between gap-4 ps-4">
          <h4 className="text-sm font-semibold">المستودعات المميزة</h4>
          <CollapsibleTrigger
            render={<Button variant="ghost" size="icon-sm" />}
            aria-label="إظهار المستودعات"
          >
            <CollapsibleTriggerIcon />
          </CollapsibleTrigger>
        </div>
        <div className="rounded-md border px-4 py-2 font-mono text-sm">
          <span dir="ltr">@radix-ui/primitives</span>
        </div>
        <CollapsibleContent className="flex flex-col gap-2">
          <div className="rounded-md border px-4 py-2 font-mono text-sm">
            <span dir="ltr">@base-ui/react</span>
          </div>
          <div className="rounded-md border px-4 py-2 font-mono text-sm">
            <span dir="ltr">@tabler/icons</span>
          </div>
        </CollapsibleContent>
      </Collapsible>
      <ul className="w-64">
        {tree.map((node) => (
          <TreeItem key={node.name} node={node} />
        ))}
      </ul>
    </div>
  )
}
キーアクション
EnterSpaceトリガーからパネルを開閉します。
Tabフォーカスをトリガーから開いたパネルの中へ移動します。閉じたパネルはスキップされます。
  • トリガーには aria-expanded と aria-controls が自動で付きます。
  • アイコンのみのトリガーには aria-label が必要です。<CollapsibleTriggerIcon /> は支援技術からは隠されます。
  • モーションの低減が有効な場合、パネルは高さのアニメーションなしで開閉します。

Base UI の collapsible をベースにしています。各パーツは、ラップしているプリミティブの props を受け付けます。

プロパティ型デフォルト
open
boolean–
defaultOpen
booleanfalse
onOpenChange
(open: boolean, details) => void–
disabled
booleanfalse
render
ReactElement | (props, state) => ReactElement<div>
属性説明
data-slot="collapsible"CSSでルートを指定します。
data-open開いているときに付与されます。
data-closed閉じているときに付与されます。
プロパティ型デフォルト
render通常は Button です。
ReactElement | (props, state) => ReactElement<button>
属性説明
data-slot="collapsible-trigger"CSSでトリガーを指定します。
data-panel-openパネルが開いているときに付与されます。子要素のスタイルは group-data-panel-open/collapsible-trigger で指定します。
プロパティ型デフォルト
childrenパネルが開くと、デフォルトの山形アイコンが反転します。
ReactNode<IconChevronDown />
render
ReactElement | (props, state) => ReactElement<span>
属性説明
data-slot="collapsible-trigger-icon"支援技術からは隠されます。トリガーが押されている間、少し下に動きます。
プロパティ型デフォルト
className内側のラッパーに適用されるため、パディングが高さのアニメーションと干渉しません。
string–
hiddenUntilFound閉じたパネルを DOM に残すため、ページ内検索で見つけて開くことができます。
booleantrue
keepMountedhiddenUntilFound が有効な間は無視されます。
booleanfalse
属性説明
data-slot="collapsible-content"CSS でパネルを指定します。
data-open開いているときに付与されます。
data-closed閉じているときに付与されます。
data-starting-styleパネルが開くアニメーションの間、付与されます。
data-ending-styleパネルが閉じるアニメーションの間、付与されます。
--collapsible-panel-heightパネルの計測された高さ。
--collapsible-panel-widthパネルの計測された幅。
--collapsible-gap-startパネルの前にある親の余白。閉じている間は詰められます。
--collapsible-gap-endパネルの後ろにある親の余白。閉じている間は詰められます。

使用しているブロック

Collapsible の上に構築されるブロック。