HextaUI

Collapsible

Un panel que se muestra y se oculta con un movimiento de altura que puedes invertir a mitad de camino, sin saltos en el diseño.

@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

Añade el componente, los tokens del tema de HextaUI y los componentes de HextaUI de los que depende.

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

Abierto por defecto

defaultOpen inicia el panel abierto sin tomar el control de su estado.

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

Mostrar más

El panel se sitúa sobre su trigger. Cuando se cierra, el hueco del diseño flex del padre se colapsa en la misma animación, así que el botón de debajo nunca salta.

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

Controlado

Pasa open y onOpenChange para controlar el panel desde tu propio estado. Alternar a mitad de la animación revierte desde donde está el panel.

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

Deshabilitado

disabled en la raíz mantiene el panel en su estado actual y hace que el trigger ignore clics y teclas.

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

Icono personalizado

Los hijos de <CollapsibleTriggerIcon /> reemplazan el chevron. Da estilo al estado abierto con 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>
  )
}

Árbol de archivos

Cada carpeta es su propio collapsible. Los paneles exteriores crecen suavemente a medida que se abren los interiores.

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

Dentro de una tarjeta

Los paneles cerrados permanecen en la página con hidden=“until-found”, así que Cmd/Ctrl+F para “webhook” abre este.

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

Desmontado al cerrarse

Define hiddenUntilFound={false} en el contenido para quitarlo del DOM mientras está cerrado, para paneles pesados que no quieres renderizar de antemano.

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

Contenido largo

Las cadenas sin espacios se ajustan dentro del panel en lugar de ensanchar la página.

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

De derecha a izquierda

Los chevrons se reflejan y las sangrías anidadas siguen la dirección de lectura.

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>
  )
}
KeyAcción
EnterSpaceAbre o cierra el panel desde el trigger.
TabMueve el foco del trigger al panel abierto. Los paneles cerrados se omiten.
  • El trigger recibe aria-expanded y aria-controls automáticamente.
  • Los triggers solo con icono necesitan un aria-label. <CollapsibleTriggerIcon /> está oculto para las tecnologías de asistencia.
  • Con movimiento reducido, el panel se abre y se cierra sin animar su altura.

Construido sobre el collapsible de Base UI. Cada parte acepta las props de la primitiva que envuelve.

PropTipoPredeterminado
open
boolean–
defaultOpen
booleanfalse
onOpenChange
(open: boolean, details) => void–
disabled
booleanfalse
render
ReactElement | (props, state) => ReactElement<div>
AtributoDescripción
data-slot="collapsible"Apunta a la raíz en CSS.
data-openPresente cuando está abierto.
data-closedPresente cuando está cerrado.
PropTipoPredeterminado
renderNormalmente un Button.
ReactElement | (props, state) => ReactElement<button>
AtributoDescripción
data-slot="collapsible-trigger"Apunta al trigger en CSS.
data-panel-openPresente cuando el panel está abierto. Da estilo a los hijos con group-data-panel-open/collapsible-trigger.
PropTipoPredeterminado
childrenEl chevron por defecto se voltea cuando se abre el panel.
ReactNode<IconChevronDown />
render
ReactElement | (props, state) => ReactElement<span>
AtributoDescripción
data-slot="collapsible-trigger-icon"Oculto para las tecnologías de asistencia. Baja ligeramente mientras se pulsa el trigger.
PropTipoPredeterminado
classNameSe aplica al contenedor interno, para que el padding nunca choque con la animación de altura.
string–
hiddenUntilFoundMantiene el panel cerrado en el DOM para que la búsqueda de página pueda encontrarlo y abrirlo.
booleantrue
keepMountedSe ignora mientras hiddenUntilFound está activado.
booleanfalse
AtributoDescripción
data-slot="collapsible-content"Selecciona el panel en CSS.
data-openPresente cuando está abierto.
data-closedPresente cuando está cerrado.
data-starting-stylePresente mientras el panel se anima al abrirse.
data-ending-stylePresente mientras el panel se anima al cerrarse.
--collapsible-panel-heightLa altura medida del panel.
--collapsible-panel-widthEl ancho medido del panel.
--collapsible-gap-startEl gap del padre antes del panel, colapsado mientras está cerrado.
--collapsible-gap-endEl gap del padre después del panel, colapsado mientras está cerrado.

Usado en bloques

Bloques que se construyen sobre Collapsible.