← Componentes

Collapsible

Un trigger, un panel — no una lista. Accordion agrupa varios ítems con su propio índice y su propia regla de "solo uno abierto a la vez"; Collapsible es la pieza suelta debajo de eso, para un "ver más" que no pertenece a ningún grupo.

Playground

Variant
Next.js 15, Tailwind 3, TypeScript. Doce secciones, modo oscuro nativo, sin dependencias fuera de Recharts para las gráficas.
<Collapsible>
  <CollapsibleTrigger>Especificaciones técnicas</CollapsibleTrigger>
  <CollapsibleContent>
    Next.js 15, Tailwind 3, TypeScript. Doce secciones, modo oscuro nativo,
    sin dependencias fuera de Recharts para las gráficas.
  </CollapsibleContent>
</Collapsible>

Variantes

ghost es la pieza suelta — sin marco, para un "ver más" adentro de otro bloque de texto. line son dos hairlines, como una fila de Accordion que vive sola. card tiene superficie propia — para una sección que se sostiene sola en la página, no metida dentro de otra cosa. La cruz mecánica del trigger (barra vertical que rota 90° hasta fundirse con la horizontal) es la misma en las tres — y la misma que usa Accordion.

ghost

Next.js 15, Tailwind 3, TypeScript. Doce secciones, modo oscuro nativo.

line

Next.js 15, Tailwind 3, TypeScript. Doce secciones, modo oscuro nativo.

card

Next.js 15, Tailwind 3, TypeScript. Doce secciones, modo oscuro nativo.
"use client";

import * as React from "react";
import { cn } from "./lib/cn";
import { EASE } from "./lib/motion";

type CollapsibleVariant = "ghost" | "line" | "card";

type CollapsibleContextValue = {
  open: boolean;
  disabled: boolean;
  toggle: () => void;
  variant: CollapsibleVariant;
};

const CollapsibleContext = React.createContext<CollapsibleContextValue | null>(null);

function useCollapsibleContext(component: string) {
  const ctx = React.useContext(CollapsibleContext);
  if (!ctx) throw new Error(`${component} debe usarse dentro de Collapsible`);
  return ctx;
}

// ghost: sin marco, para un "ver más" inline dentro de otro contenido.
// line: hairline arriba y abajo, como una fila suelta de Accordion.
// card: superficie propia — para una sección que se sostiene sola en la
// página, no metida dentro de otra cosa.
const rootVariants: Record<CollapsibleVariant, string> = {
  ghost: "flex flex-col",
  line: "flex flex-col border-y border-border",
  card: "flex flex-col rounded-md border border-border data-[state=open]:border-primary/30",
};

export interface CollapsibleProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  disabled?: boolean;
  variant?: CollapsibleVariant;
}

export const Collapsible = React.forwardRef<HTMLDivElement, CollapsibleProps>(
  ({ open: openProp, defaultOpen = false, onOpenChange, disabled = false, variant = "ghost", className, ...props }, ref) => {
    const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
    const isControlled = openProp !== undefined;
    const open = isControlled ? openProp : uncontrolledOpen;

    const toggle = React.useCallback(() => {
      const next = !open;
      if (!isControlled) setUncontrolledOpen(next);
      onOpenChange?.(next);
    }, [open, isControlled, onOpenChange]);

    const value = React.useMemo(() => ({ open, disabled, toggle, variant }), [open, disabled, toggle, variant]);

    return (
      <CollapsibleContext.Provider value={value}>
        <div
          ref={ref}
          data-state={open ? "open" : "closed"}
          className={cn(rootVariants[variant], "transition-colors duration-300", EASE, className)}
          {...props}
        />
      </CollapsibleContext.Provider>
    );
  },
);
Collapsible.displayName = "Collapsible";

const triggerVariants: Record<CollapsibleVariant, string> = {
  ghost: "gap-2 py-1 text-sm font-medium",
  line: "gap-4 px-6 py-5 text-sm font-medium",
  card: "gap-4 px-5 py-4 text-sm font-medium",
};

export interface CollapsibleTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  /** clona el hijo en vez de renderizar <button> — para un trigger custom que ya es interactivo */
  asChild?: boolean;
}

export const CollapsibleTrigger = React.forwardRef<HTMLButtonElement, CollapsibleTriggerProps>(
  ({ asChild = false, className, children, onClick, ...props }, ref) => {
    const ctx = useCollapsibleContext("CollapsibleTrigger");

    const sharedProps = {
      "data-state": ctx.open ? ("open" as const) : ("closed" as const),
      "aria-expanded": ctx.open,
      onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
        onClick?.(e);
        if (!ctx.disabled) ctx.toggle();
      },
    };

    if (asChild && React.isValidElement(children)) {
      const child = children as React.ReactElement<{
        className?: string;
        onClick?: React.MouseEventHandler<HTMLButtonElement>;
        "data-state"?: "open" | "closed";
        "aria-expanded"?: boolean;
      }>;
      return React.cloneElement(child, { ...sharedProps, className: cn(child.props.className, className) });
    }

    return (
      <button
        ref={ref}
        type="button"
        disabled={ctx.disabled}
        className={cn(
          "group flex w-full items-center text-left outline-none",
          "focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
          "disabled:pointer-events-none disabled:opacity-50",
          triggerVariants[ctx.variant],
          className,
        )}
        {...sharedProps}
        {...props}
      >
        <span className={cn("flex-1 transition-colors duration-200", EASE, "group-hover:text-primary group-data-[state=open]:text-primary")}>
          {children}
        </span>
        {/* misma cruz mecánica que Accordion — la barra vertical rota 90°
            hasta fundirse con la horizontal, no una flecha genérica */}
        <span
          className={cn(
            "relative flex h-3 w-3 shrink-0 items-center justify-center text-muted-foreground",
            "transition-colors duration-200",
            EASE,
            "group-hover:text-primary group-data-[state=open]:text-primary",
          )}
        >
          <span aria-hidden="true" className="absolute h-px w-3 bg-current" />
          <span
            aria-hidden="true"
            className={cn("absolute h-3 w-px bg-current transition-transform duration-300", EASE, "group-data-[state=open]:rotate-90")}
          />
        </span>
      </button>
    );
  },
);
CollapsibleTrigger.displayName = "CollapsibleTrigger";

const contentVariants: Record<CollapsibleVariant, string> = {
  ghost: "pt-2 text-sm text-muted-foreground",
  line: "px-6 pb-5 text-sm text-muted-foreground",
  card: "px-5 pb-4 text-sm text-muted-foreground",
};

export interface CollapsibleContentProps extends React.HTMLAttributes<HTMLDivElement> {}

export const CollapsibleContent = React.forwardRef<HTMLDivElement, CollapsibleContentProps>(
  ({ className, children, ...props }, ref) => {
    const ctx = useCollapsibleContext("CollapsibleContent");

    return (
      // mismo truco que Accordion: el alto anima vía grid-template-rows
      // (0fr → 1fr), nunca un alto en px medido por JS. El contenido
      // sigue montado siempre — no se pierde el estado de lo que hay adentro.
      <div className={cn("grid transition-[grid-template-rows] duration-300", EASE)} style={{ gridTemplateRows: ctx.open ? "1fr" : "0fr" }}>
        <div className="overflow-hidden">
          <div
            ref={ref}
            className={cn(contentVariants[ctx.variant], "transition-opacity duration-200", EASE, ctx.open ? "opacity-100 delay-100" : "opacity-0")}
            {...props}
          >
            {children}
          </div>
        </div>
      </div>
    );
  },
);
CollapsibleContent.displayName = "CollapsibleContent";

Código real de packages/ui/src/collapsible.tsx.

Con trigger propio

asChild clona el hijo en vez de envolverlo en su propio <button> — para usar Button como trigger sin terminar con un botón adentro de otro botón.

Todo lo que Button ya sabe hacer (variantes, tamaños, loading) lo hereda gratis — Collapsible solo le agrega el toggle.
<Collapsible>
  <CollapsibleTrigger asChild>
    <Button variant="ghost" size="sm">Ver más</Button>
  </CollapsibleTrigger>
  <CollapsibleContent>...</CollapsibleContent>
</Collapsible>
{/* asChild clona el hijo en vez de anidar un <button> propio dentro de
    otro — mismo mecanismo que AlertDialogTrigger y BreadcrumbItem */}

Árbol de archivos

Cada carpeta es su propio Collapsible — no hace falta un componente Tree aparte, ni estado compartido entre niveles. El trigger aquí no es el de arriba (esa cruz mecánica es para una sola sección, no para una fila densa): es asChild con un chevron que rota 90°, el mismo lenguaje que Calendar usa para navegar. La guía vertical entre hijos es un hairline, no una línea decorativa.

layout.tsx
page.tsx
globals.css
button.tsx
card.tsx
favicon.svg
package.json
type TreeEntry =
  | { type: "file"; name: string }
  | { type: "folder"; name: string; defaultOpen?: boolean; children: TreeEntry[] };

function TreeNode({ entry }: { entry: TreeEntry }) {
  if (entry.type === "file") {
    return (
      <div className="flex items-center gap-1.5 text-muted-foreground">
        <FileIcon className="h-4 w-4" />
        <span className="font-mono text-xs">{entry.name}</span>
      </div>
    );
  }
  return (
    <Collapsible defaultOpen={entry.defaultOpen}>
      <CollapsibleTrigger asChild>
        <button className="group flex items-center gap-1.5">
          <ChevronRightIcon className="h-3.5 w-3.5 transition-transform group-data-[state=open]:rotate-90" />
          <FolderIcon className="h-4 w-4 text-primary/80" />
          <span className="font-mono text-xs">{entry.name}</span>
        </button>
      </CollapsibleTrigger>
      <CollapsibleContent>
        {/* la guía vertical es un hairline, no un borde decorativo */}
        <div className="ml-[9px] border-l border-border pl-3">
          {entry.children.map((child) => <TreeNode key={child.name} entry={child} />)}
        </div>
      </CollapsibleContent>
    </Collapsible>
  );
}
{/* cada carpeta es su propio Collapsible sin estado compartido — no hace
    falta un componente Tree aparte, esto ya es todo lo que hay */}

Controlado

open / onOpenChange — para abrirlo desde donde quieras, no solo desde su propio trigger.

El trigger interno y el botón de arriba controlan el mismo estado — open vive en tu componente, no adentro de Collapsible.
const [open, setOpen] = useState(false);

<Collapsible open={open} onOpenChange={setOpen}>
  <CollapsibleTrigger>Especificaciones técnicas</CollapsibleTrigger>
  <CollapsibleContent>...</CollapsibleContent>
</Collapsible>

Disabled

No deberías ver esto — el trigger está apagado.

Uso

import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "./components/ui/collapsible";

<Collapsible>
  <CollapsibleTrigger>Especificaciones técnicas</CollapsibleTrigger>
  <CollapsibleContent>...</CollapsibleContent>
</Collapsible>

API

ComponentePropTipoDefault
Collapsiblevariantghost | line | cardghost
open / defaultOpenbooleanfalse
onOpenChange(open: boolean) => void
disabledbooleanfalse
CollapsibleTriggerasChildbooleanfalse