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
<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
line
card
"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.
<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.
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.
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
Uso
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "./components/ui/collapsible";
<Collapsible>
<CollapsibleTrigger>Especificaciones técnicas</CollapsibleTrigger>
<CollapsibleContent>...</CollapsibleContent>
</Collapsible>API
| Componente | Prop | Tipo | Default |
|---|---|---|---|
| Collapsible | variant | ghost | line | card | ghost |
| open / defaultOpen | boolean | false | |
| onOpenChange | (open: boolean) => void | — | |
| disabled | boolean | false | |
| CollapsibleTrigger | asChild | boolean | false |