Accordion
El alto anima vía grid-template-rows, no un alto en px medido por JS — la forma se abre primero, el contenido aparece un instante después. Mismo lenguaje de dos pasos que el bloque de código de esta misma documentación.
Playground
<Accordion defaultValue="item-1">
<AccordionItem value="item-1">
<AccordionTrigger>¿Necesito instalar algo?</AccordionTrigger>
<AccordionContent>No. Cada componente se copia a tu proyecto — no hay paquete npm que versionar ni actualizar.</AccordionContent>
</AccordionItem>
{/* … */}
</Accordion>Variantes
variant cambia la superficie, no el movimiento — las tres comparten el mismo alto animado y la misma cruz mecánica.
line
Lista corrida — solo hairlines entre ítems. El default.
border
Cada ítem en su propio marco; el abierto resalta el borde en dorado.
card
Cada ítem como superficie tintada, sin borde — el abierto profundiza el tinte.
Código
type="multiple". Con type="single" (el default) solo un ítem permanece abierto a la vez."use client";
import * as React from "react";
import { cn } from "./lib/cn";
const EASE = "ease-[cubic-bezier(0.16,1,0.3,1)]";
type AccordionVariant = "line" | "border" | "card";
type AccordionContextValue = {
openItems: Set<string>;
toggle: (value: string) => void;
variant: AccordionVariant;
};
const AccordionContext = React.createContext<AccordionContextValue | null>(null);
const AccordionItemContext = React.createContext<{ value: string; open: boolean; disabled: boolean } | null>(null);
// las clases de counter-reset / counter-increment / content-[counter(...)]
// deben quedar como texto literal (no interpoladas) para que el scanner
// de Tailwind las detecte — ver los tres [counter-*] de abajo.
const rootVariants: Record<AccordionVariant, string> = {
line: "flex flex-col border-y border-border [counter-reset:kenza-accordion]",
border: "flex flex-col gap-3 [counter-reset:kenza-accordion]",
card: "flex flex-col gap-3 [counter-reset:kenza-accordion]",
};
const itemVariants: Record<AccordionVariant, string> = {
line: "border-b border-border last:border-b-0 data-[state=open]:bg-muted/30",
border: "rounded-md border border-border data-[state=open]:border-primary/30 data-[state=open]:bg-muted/20",
card: "rounded-md bg-muted/30 data-[state=open]:bg-primary/5",
};
export interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
/** "single" cierra los demás ítems al abrir uno; "multiple" permite varios abiertos a la vez */
type?: "single" | "multiple";
defaultValue?: string | string[];
/** solo aplica a type="single" — si es false, siempre queda un ítem abierto */
collapsible?: boolean;
/** line: lista con hairlines. border: cada ítem en su propio marco. card: cada ítem en superficie tintada */
variant?: AccordionVariant;
}
export const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
({ type = "single", defaultValue, collapsible = true, variant = "line", className, ...props }, ref) => {
const [openItems, setOpenItems] = React.useState<Set<string>>(() => {
if (!defaultValue) return new Set();
return new Set(Array.isArray(defaultValue) ? defaultValue : [defaultValue]);
});
const toggle = React.useCallback(
(value: string) => {
setOpenItems((prev) => {
const isOpen = prev.has(value);
if (type === "multiple") {
const next = new Set(prev);
if (isOpen) next.delete(value);
else next.add(value);
return next;
}
if (isOpen) return collapsible ? new Set() : prev;
return new Set([value]);
});
},
[type, collapsible],
);
const value = React.useMemo(() => ({ openItems, toggle, variant }), [openItems, toggle, variant]);
return (
<AccordionContext.Provider value={value}>
{/* counter-reset delimita un índice propio por instancia — dos
Accordion en la misma página no se pisan la numeración */}
<div ref={ref} className={cn(rootVariants[variant], className)} {...props} />
</AccordionContext.Provider>
);
},
);
Accordion.displayName = "Accordion";
export interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
value: string;
disabled?: boolean;
}
export const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
({ value, disabled = false, className, ...props }, ref) => {
const accordionCtx = React.useContext(AccordionContext);
if (!accordionCtx) throw new Error("AccordionItem debe usarse dentro de Accordion");
const open = accordionCtx.openItems.has(value);
const itemValue = React.useMemo(() => ({ value, open, disabled }), [value, open, disabled]);
return (
<AccordionItemContext.Provider value={itemValue}>
<div
ref={ref}
data-state={open ? "open" : "closed"}
data-disabled={disabled ? "" : undefined}
className={cn(
"transition-colors duration-300",
EASE,
itemVariants[accordionCtx.variant],
"[counter-increment:kenza-accordion]",
className,
)}
{...props}
/>
</AccordionItemContext.Provider>
);
},
);
AccordionItem.displayName = "AccordionItem";
export interface AccordionTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
export const AccordionTrigger = React.forwardRef<HTMLButtonElement, AccordionTriggerProps>(
({ className, children, ...props }, ref) => {
const accordionCtx = React.useContext(AccordionContext);
const itemCtx = React.useContext(AccordionItemContext);
if (!accordionCtx || !itemCtx) throw new Error("AccordionTrigger debe usarse dentro de AccordionItem");
return (
<h3 className="flex">
<button
ref={ref}
type="button"
data-state={itemCtx.open ? "open" : "closed"}
aria-expanded={itemCtx.open}
disabled={itemCtx.disabled}
onClick={() => accordionCtx.toggle(itemCtx.value)}
className={cn(
"group flex flex-1 items-center gap-6 px-6 py-6 text-left text-sm font-medium",
"focus-visible: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",
className,
)}
{...props}
>
{/* índice editorial — puramente tipográfico, generado por CSS
counter así que se ajusta solo sin importar cuántos ítems haya */}
<span
aria-hidden="true"
className={cn(
"w-8 shrink-0 font-mono text-xs tabular-nums tracking-wide text-accent/70",
"transition-colors duration-200",
EASE,
"before:content-[counter(kenza-accordion,decimal-leading-zero)]",
"group-hover:text-primary group-data-[state=open]:text-primary",
)}
/>
<span
className={cn(
"flex-1 transition-colors duration-200",
EASE,
"group-hover:text-primary group-data-[state=open]:text-primary",
)}
>
{children}
</span>
{/* cruz mecánica: la barra vertical rota 90° hasta fundirse con
la horizontal — se convierte en un signo menos, no un ícono
de flecha genérico */}
<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>
</h3>
);
},
);
AccordionTrigger.displayName = "AccordionTrigger";
export interface AccordionContentProps extends React.HTMLAttributes<HTMLDivElement> {}
export const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>(
({ className, children, ...props }, ref) => {
const itemCtx = React.useContext(AccordionItemContext);
if (!itemCtx) throw new Error("AccordionContent debe usarse dentro de AccordionItem");
return (
// el alto anima vía grid-template-rows (0fr → 1fr), no un alto en px
// medido por JS — la forma se abre primero, el contenido (montado
// siempre, nunca se desmonta) aparece un instante después.
<div
className={cn("grid transition-[grid-template-rows] duration-300", EASE)}
style={{ gridTemplateRows: itemCtx.open ? "1fr" : "0fr" }}
>
<div className="overflow-hidden">
<div
ref={ref}
className={cn(
"pl-20 pr-6 pb-6 text-sm text-muted-foreground",
"transition-opacity duration-200",
EASE,
itemCtx.open ? "opacity-100 delay-100" : "opacity-0",
className,
)}
{...props}
>
{children}
</div>
</div>
</div>
);
},
);
AccordionContent.displayName = "AccordionContent";Código real de packages/ui/src/accordion.tsx.
Sin colapsar
collapsible={false} — solo aplica a type="single" — evita que el último ítem abierto se pueda cerrar sin abrir otro. Útil cuando siempre debe quedar algo visible.
<Accordion type="single" defaultValue="item-1" collapsible={false}>
{/* … */}
</Accordion>Deshabilitado
disabled va en AccordionItem, no en el trigger — se propaga solo. El ítem no responde a click ni foco por teclado.
<AccordionItem value="item-2" disabled>
<AccordionTrigger>No disponible todavía</AccordionTrigger>
<AccordionContent>…</AccordionContent>
</AccordionItem>Uso
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./components/ui/accordion";
<Accordion type="single" defaultValue="item-1">
<AccordionItem value="item-1">
<AccordionTrigger>¿Necesito instalar algo?</AccordionTrigger>
<AccordionContent>No. Cada componente se copia a tu proyecto.</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>¿Puedo tener más de un ítem abierto?</AccordionTrigger>
<AccordionContent>Sí, con type="multiple".</AccordionContent>
</AccordionItem>
</Accordion>API
| Componente | Prop | Tipo | Default |
|---|---|---|---|
| Accordion | type | single | multiple | single |
| defaultValue | string | string[] | — | |
| collapsible | boolean | true | |
| variant | line | border | card | line | |
| AccordionItem | value | string | — |
| disabled | boolean | false | |
| AccordionTrigger | — | atributos nativos de button | |
| AccordionContent | — | atributos nativos de div | |
Cuatro piezas que se componen, igual que Alert — cada una resuelve una sola cosa.