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.
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:
"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-contentte da un asidero sobre la región de contenido oculta, que antes no podías seleccionar en absoluto.interpolate-size: allow-keywordspermite animarblock-sizeentre0y la altura intrínsecaauto— justo lo que CSS históricamente se negaba a hacer.transition-behavior: allow-discrete(al incluircontent-visibilityen 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
| Aspecto | Acordeón JS (estado + hook) | <code><details></code> nativo + CSS |
|---|---|---|
| Abrir / cerrar | Estado de React + onClick | Integrado en el navegador |
| JavaScript enviado | Sí (componente de cliente, se hidrata) | Ninguno |
| Animación fluida | Sí (grid / altura medida) | Sí (::details-content) |
| Solo uno abierto | Estado manual (openId) | Atributo name |
| Teclado + a11y | Lo cableas tú (o una biblioteca) | Nativo |
| Funciona antes de la hidratación / con JS desactivado | No | Sí |
| Giro del chevron | Clase alternada por JS | CSS 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-keywordsaniman la altura entre0yauto.- El truco que hace que el cierre se anime (no solo la apertura) es
transition-behavior: allow-discretesobrecontent-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
transformde 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.