Cómo crear un acordeón FAQ animado en React
Un acordeón FAQ en React que permite abrir solo un elemento a la vez almacena el índice activo en un único useState, y luego envuelve cada respuesta en el AnimatePresence de Framer Motion para animar desde height 0 hasta 'auto' al montar y de vuelta a 0 al desmontar, logrando una apertura y cierre fluidos sin medir manualmente los elementos del DOM.
- Stack: React 18 + Framer Motion 11 + Tailwind v4 + lucide-react, ~140 líneas.
- Un solo elemento abierto a la vez mediante un único useState<number | null>, sin context ni reducer.
- La animación de altura usa AnimatePresence con initial={false} para omitir la animación de apertura en el primer render.
- Accesible: cada disparador es un <button> nativo, legible por los lectores de pantalla sin trucos de ARIA.
- Responsive de fábrica, columna centrada en max-w-3xl, funciona en todos los anchos de viewport.
FAQ Accordion es un componente de sección en React que lista preguntas en vertical y despliega una respuesta a la vez con una transición de altura fluida. El icono Plus gira 45 grados para convertirse en una cruz de cierre, y la respuesta se desliza hacia abajo mediante el AnimatePresence de Framer Motion para que el plegado sea tan limpio como la apertura. No hace falta ninguna librería de acordeón externa.
Anatomía
La sección envuelve una columna centrada en max-w-3xl. Arriba se sitúa un motion.div de cabecera con un badge opcional, un título h2 y un subtítulo opcional, todos animados al entrar en la vista durante el scroll con un único whileInView. Debajo, una columna flex lista cada elemento de la FAQ: cada elemento es a su vez un motion.div que aparece en fundido y sube al hacer scroll (escalonado 40ms por índice). Dentro de cada elemento, un botón a todo lo ancho alinea el texto de la pregunta a la izquierda y un div de icono circular a la derecha. El bloque AnimatePresence bajo el botón contiene el párrafo de la respuesta.
Cómo funciona
La animación de altura es la técnica central. Framer Motion puede animar hasta `height: 'auto'`, algo que las transiciones CSS no pueden hacer de forma nativa, midiendo internamente el elemento tras el montaje. El div de la respuesta parte de `height: 0, opacity: 0`, anima hacia `height: 'auto', opacity: 1`, y luego sale de vuelta a `height: 0, opacity: 0`. Poner `initial={false}` en AnimatePresence evita que todos los elementos animen su cierre en el primer render. La rotación del icono es un `motion.div` aparte con `animate={{ rotate: isOpen ? 45 : 0 }}`, que convierte el Plus en una X sin cambiar de componente.
Cómo crearlo en React
Preparar el estado y los datos
Declara un único useState<number | null>(null) para seguir qué elemento está abierto. Un valor null significa que todo está cerrado. Pasa los items como una prop tipada `{ question: string; answer: string }[]` para que el componente siga siendo puramente presentacional.
const [openIndex, setOpenIndex] = useState<number | null>(null);Conectar el botón de alternancia
Cada elemento renderiza un botón a todo lo ancho. Al hacer clic, lo abre (define su índice) o lo cierra (lo restablece a null). Este handler de una sola expresión garantiza que solo un elemento permanezca abierto a la vez sin ninguna lógica adicional.
<button onClick={() => setOpenIndex(isOpen ? null : i)}>Animar la altura con AnimatePresence
Envuelve la respuesta en AnimatePresence con `initial={false}`. Renderiza un motion.div de forma condicional solo cuando isOpen es true. Anima de `{ height: 0, opacity: 0 }` a `{ height: 'auto', opacity: 1 }` y de vuelta. La clase overflow-hidden en el div evita que el contenido asome durante el plegado.
<AnimatePresence initial={false}> {isOpen && ( <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }} className="overflow-hidden" > <p>{item.answer}</p> </motion.div> )} </AnimatePresence>Rotar el icono
En lugar de alternar entre un icono Plus y una X, envuelve el Plus en un motion.div y anima su rotación a 45 grados cuando el elemento está abierto. Al mismo tiempo, haz que el color de fondo transicione de una superficie atenuada hacia el color de acento para que el icono confirme visualmente el estado activo.
<motion.div animate={{ rotate: isOpen ? 45 : 0 }} transition={{ duration: 0.2 }}> <Plus size={14} /> </motion.div>
Cuándo usarlo
Usa este acordeón en cualquier página que necesite mostrar de 5 a 10 preguntas sin saturar el diseño: páginas de precios (gestión de objeciones), páginas de producto (detalles técnicos), páginas de soporte, o al final de una página de marketing SaaS. Evítalo cuando solo tengas 2 o 3 elementos, una lista plana se lee más rápido. Evítalo también cuando las respuestas sean muy cortas (una frase); en ese caso mostrarlo todo desplegado resulta más legible.
Usado por
- Stripe, Usa una sección FAQ plegable al final de su página de precios para resolver preguntas sobre casos límite de facturación sin recargar el contenido principal.
- Linear, Acordeón FAQ bajo los planes de precios para responder en línea a las preguntas de comparación de planes, manteniendo a los usuarios en la página.
- Vercel, Bloque FAQ plegable en la página de precios que cubre la facturación, los límites y las particularidades de empresa.
- Notion, FAQ en formato acordeón en la página de precios para reunir las preguntas habituales sobre mejoras de plan y planes en un espacio mínimo.
FAQ
¿Se pueden tener varios elementos abiertos a la vez?
No en esta variante, impone un modelo de apertura única por diseño. Para permitir varios elementos abiertos, reemplaza useState<number | null> por useState<Set<number>>, comprueba el conjunto en lugar de comparar índices, y actualízalo añadiendo y quitando valores.
¿Por qué usar Framer Motion para la animación de altura en lugar de CSS?
CSS no puede animar de height 0 a height auto, tienes que usar max-height con un valor fijo estimado, lo que provoca tiempos incoherentes entre elementos de distinta longitud. Framer Motion mide la altura real tras el montaje y anima hasta ese valor exacto, así que la duración es coherente sea cual sea la longitud de la respuesta.
¿Cómo añado contenido enriquecido (bloques de código, enlaces) dentro de una respuesta?
Cambia el campo `answer` de string a React.ReactNode y renderízalo con `{item.answer}` directamente en lugar de envolverlo en un párrafo. La animación de altura de AnimatePresence funciona con cualquier contenido dentro, incluidos los componentes anidados.
¿El escalonado durante el scroll afecta al SEO?
No. El escalonado es puramente visual (opacity/translateY mediante whileInView) y el contenido textual está completamente presente en el DOM desde el HTML inicial. Los rastreadores de los motores de búsqueda leen el DOM, no los estados visuales calculados, así que todas las preguntas y respuestas quedan indexadas.