← Componentes

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

Type
Variant

No. Cada componente se copia a tu proyecto — no hay paquete npm que versionar ni actualizar.

Sí, con type="multiple". Con type="single" (el default) solo un ítem permanece abierto a la vez.

No — queda montado siempre. Lo que cambia es su alto y opacidad, nunca su presencia en el DOM.
<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.

No, se copia el código.

La librería es gratis.

line

Lista corrida — solo hairlines entre ítems. El default.

No, se copia el código.

La librería es gratis.

border

Cada ítem en su propio marco; el abierto resalta el borde en dorado.

No, se copia el código.

La librería es gratis.

card

Cada ítem como superficie tintada, sin borde — el abierto profundiza el tinte.

Código

No. Cada componente se copia a tu proyecto — no hay paquete npm que versionar ni actualizar.

Sí, con type="multiple". Con type="single" (el default) solo un ítem permanece abierto a la vez.

No — queda montado siempre. Lo que cambia es su alto y opacidad, nunca su presencia en el DOM.
"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.

No. Cada componente se copia a tu proyecto.

No — queda montado siempre.
<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

ComponentePropTipoDefault
Accordiontypesingle | multiplesingle
defaultValuestring | string[]
collapsiblebooleantrue
variantline | border | cardline
AccordionItemvaluestring
disabledbooleanfalse
AccordionTriggeratributos nativos de button
AccordionContentatributos nativos de div

Cuatro piezas que se componen, igual que Alert — cada una resuelve una sola cosa.