Skip to main content
Volver al blog
CSSHTMLAccordionFrontendPerformance

Un acordeón fluido con cero JavaScript

Todo el mundo recurre al estado de React y a un hook que mide la altura para construir un acordeón. El navegador incluye uno nativo desde hace años — y desde 2024 anima la apertura y el cierre con CSS puro. Aquí tienes la técnica completa: details[name], ::details-content, interpolate-size y la única propiedad que también anima el cierre.

Publicado 28 de julio de 20269 min de lectura

Un acordeón es uno de los widgets más reimplementados de la web. La receta habitual: un trozo de estado de React para lo que está abierto, un onClick para alternarlo y algo de CSS (o un hook que mide la altura) para animar el despliegue. Funciona, pero convierte un contenido estático en un componente de cliente que envía e hidrata JavaScript — solo para abrir y cerrar.

El navegador tiene desde hace años un elemento de acordeón nativo: <details>. Su única debilidad histórica — no se podía animar — desapareció en 2024. Este artículo construye un acordeón exclusivo y totalmente animado con un chevron que rota y cero JavaScript: sin estado, sin hooks, sin hidratación.

El acordeón que todos publican

Aquí tienes el patrón en su forma más común — estado de React para el elemento abierto, un manejador de clic para alternarlo y el truco grid-template-rows: 0fr → 1fr para animar la altura sin medir nada:

Accordion.tsx
"use client"; // ← the whole component is now a client bundle

import { useState } from "react";

export function Accordion({ items }: { items: Item[] }) {
  const [openId, setOpenId] = useState<string | null>(null);

  return (
    <div>
      {items.map((item) => {
        const isOpen = openId === item.id;
        return (
          <div key={item.id}>
            <button onClick={() => setOpenId(isOpen ? null : item.id)}>
              {item.title}
            </button>
            {/* grid 0fr → 1fr is the CSS trick that animates the height */}
            <div className="body" data-open={isOpen}>
              <div>{item.content}</div>
            </div>
          </div>
        );
      })}
    </div>
  );
}
.body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 300ms;
}
.body[data-open="true"] { grid-template-rows: 1fr; }
.body > div { overflow: hidden; }

Está bien, y el truco de grid es realmente ingenioso. Pero fíjate en lo que cuesta: el "use client" de arriba. Este componente ahora se descarga, se analiza y se hidrata en cada página en la que aparece — y hace todo eso para gestionar un único booleano. Para unas FAQ meramente presentacionales que nunca necesitaron ser interactivas en el sentido de React, eso es puro sobrecoste.

El elemento nativo que olvidaste

<details> y <summary> son un widget de divulgación integrado. El navegador se encarga del estado abierto/cerrado, de la interacción con clic y teclado y de la accesibilidad — gratis:

<details>
  <summary>What does it cost?</summary>
  <p>Nothing — it's a native browser element.</p>
</details>

Eso es un acordeón funcional sin JavaScript y sin CSS. Haz clic (o pulsa Enter) sobre el summary y se alterna. El inconveniente, durante años, era que se alternaba al instante — no había forma de animar el despliegue, que es exactamente la razón por la que los equipos recurrían a la versión de JavaScript.

Por qué no se podía animar — hasta hace poco

La razón es sutil. Cuando un <details> está cerrado, el navegador no solo oculta visualmente su contenido — lo saca por completo del renderizado (un ocultamiento al estilo content-visibility). No se puede hacer una transición hacia o desde "no renderizado", y durante mucho tiempo tampoco se podía hacer una transición de height hacia o desde auto. Así que no había nada entre lo que animar.

La gente probó el mismo truco de grid que usa la versión de JavaScript, trasladándolo al atributo [open]:

/* Looks like it should work — but only animates OPENING. */
details > .body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 300ms;
}
details[open] > .body { grid-template-rows: 1fr; }
/* On close, the browser hides the content the instant [open] is gone,
   so the collapse never animates — it just snaps shut. */

Parece correcto e incluso anima la apertura. Pero falla en el cierre: en el momento en que se elimina el atributo [open], el navegador oculta el contenido de inmediato, de modo que el colapso nunca tiene ocasión de animarse — simplemente se cierra de golpe. Media solución es lo que mantuvo a todos en JavaScript.

La solución: ::details-content + interpolate-size

Tres características de CSS que llegaron en 2024 lo resuelven en conjunto. La clave es un nuevo pseudoelemento, ::details-content, que apunta exactamente a la parte plegable de un <details> — de modo que puedes darle estilo y animarlo directamente:

:root {
  /* lets height/block-size transition to and from the 'auto' keyword */
  interpolate-size: allow-keywords;
}

details::details-content {
  block-size: 0;
  overflow: clip;
  transition:
    block-size 300ms ease,
    /* keeps the content rendered while it collapses, instead of vanishing */
    content-visibility 300ms allow-discrete;
}

details[open]::details-content {
  block-size: auto;
}

Leído como una unidad, son tres cosas cooperando:

  • ::details-content te da un asidero sobre la región de contenido oculta, que antes no podías seleccionar en absoluto.
  • interpolate-size: allow-keywords permite animar block-size entre 0 y la altura intrínseca auto — justo lo que CSS históricamente se negaba a hacer.
  • transition-behavior: allow-discrete (al incluir content-visibility en la transición) mantiene el contenido renderizado durante el colapso, de modo que el cierre se anima en lugar de cerrarse de golpe.

Juntas animan ambas direcciones — apertura y cierre — con la altura suavizándose de forma fluida hacia y desde el tamaño natural del contenido. Sin medir, sin hook, sin estado.

Acordeón exclusivo, con un solo atributo

La mayoría de los acordeones quieren "solo uno abierto a la vez". En la versión de JavaScript, para eso está el estado openId. De forma nativa, es un solo atributo: dale a cada <details> del grupo el mismo name, y el navegador impone la exclusividad — abrir uno cierra los demás:

<!-- Same name = one exclusive group. Opening one closes the others. -->
<details name="faq"><summary>First</summary><p>…</p></details>
<details name="faq"><summary>Second</summary><p>…</p></details>
<details name="faq"><summary>Third</summary><p>…</p></details>

Una advertencia que conviene conocer: name define un grupo exclusivo en toda la página, así que si renderizas dos acordeones independientes, dale a cada uno su propio name único — de lo contrario, abrir un elemento en uno cerrará un elemento en el otro.

El chevron, sin JavaScript

La última pieza para la que la gente recurre a JS es la pequeña flecha que gira cuando la sección se abre. Es solo un transform asociado al atributo [open] — el navegador establece ese atributo, así que la rotación es CSS puro. Ya que estás, oculta también el triángulo de divulgación predeterminado:

summary {
  list-style: none;            /* remove the default triangle marker */
  cursor: pointer;
}
summary::-webkit-details-marker { display: none; }  /* Safari */

summary .chevron {
  transition: transform 200ms ease;
}
details[open] summary .chevron {
  transform: rotate(180deg);   /* flip on open — pure CSS, no JS */
}

Poniéndolo todo junto

Aquí está todo — un elemento de acordeón exclusivo y animado con un chevron que rota y un espacio entre la pregunta y la respuesta:

<details name="faq" class="item">
  <summary>
    <h3>How does it work?</h3>
    <svg class="chevron" aria-hidden="true"><!-- ↓ --></svg>
  </summary>
  <div class="answer">
    <p>Native open/close, exclusive grouping, smooth animation — zero JS.</p>
  </div>
</details>
:root { interpolate-size: allow-keywords; }

.item::details-content {
  block-size: 0;
  overflow: clip;
  transition:
    block-size 300ms ease,
    content-visibility 300ms allow-discrete;
}
.item[open]::details-content { block-size: auto; }

.item[open] summary .chevron { transform: rotate(180deg); }
.answer { padding-top: 12px; }   /* gap between question and answer */

Esa es la funcionalidad completa. Apertura/cierre, soporte de teclado, accesibilidad, exclusividad, una animación fluida en ambos sentidos y un chevron que gira — y no envía ni un solo byte de JavaScript. Se renderiza como HTML estático en el servidor y no necesita nada en el cliente para funcionar.

Compatibilidad de navegadores

::details-content, interpolate-size y allow-discrete son recientes (Chromium 129–131, Safari 18.x, Firefox a continuación). Eso suena arriesgado, pero la degradación es de la mejor clase — no necesitas @supports en absoluto:

/* No @supports needed: browsers that don't know ::details-content simply
   ignore these rules. The <details> still opens and closes — just instantly.
   The animation is a progressive enhancement, never a requirement. */

En un navegador que no entiende estas reglas, el <details> sigue abriéndose y cerrándose correctamente; simplemente lo hace al instante, sin la animación. La animación es una mejora progresiva superpuesta a un elemento que ya funciona en todas partes — así que no hay ningún fallback que escribir ni nada que se pueda romper.

El comportamiento — abrir, cerrar, teclado, accesibilidad, exclusividad — es nativo y funciona en todas partes. La animación es CSS puro por encima. Si un navegador es demasiado antiguo para la animación, el acordeón sigue funcionando; simplemente no se suaviza.

CSS vs. JavaScript, con honestidad

AspectoAcordeón JS (estado + hook)<code>&lt;details&gt;</code> nativo + CSS
Abrir / cerrarEstado de React + onClickIntegrado en el navegador
JavaScript enviadoSí (componente de cliente, se hidrata)Ninguno
Animación fluidaSí (grid / altura medida)Sí (::details-content)
Solo uno abiertoEstado manual (openId)Atributo name
Teclado + a11yLo cableas tú (o una biblioteca)Nativo
Funciona antes de la hidratación / con JS desactivadoNo
Giro del chevronClase alternada por JSCSS sobre [open]

JavaScript sigue ganando cuando el contenido del panel es genuinamente dinámico — cargado bajo demanda, gobernado por el estado de la app, o cuando la apertura debe disparar efectos secundarios que controlas en JS. Pero para el caso abrumadoramente común — unas FAQ presentacionales o una lista de divulgación — el elemento nativo iguala a la versión de JS característica por característica, sin nada del bundle.

Conclusiones

  • El acordeón de JavaScript que todos escriben existe principalmente porque el <details> nativo no se podía animar — una limitación que ya no existe.
  • ::details-content + interpolate-size: allow-keywords animan la altura entre 0 y auto.
  • El truco que hace que el cierre se anime (no solo la apertura) es transition-behavior: allow-discrete sobre content-visibility.
  • <details name="…"> te da un grupo exclusivo de "uno abierto a la vez" con cero JavaScript — usa un nombre único por acordeón.
  • El giro del chevron es un transform de CSS asociado al atributo [open].
  • Se degrada con elegancia sin @supports: los navegadores antiguos abren/cierran al instante, y la animación es una mejora pura.

Ninguna de estas piezas es exótica — <details> tiene décadas, y las funciones de animación son CSS aburrido y bien especificado. El cambio consiste en darse cuenta de que ya no tienes que recurrir al estado de React para conseguir un acordeón pulido. Elimina el componente de cliente, envía HTML estático y deja que el navegador haga lo que discretamente ha sido capaz de hacer todo este tiempo.