guias

FAQAccordion en Astro: details nativo + FAQPage

Del estado nativo de details/summary al schema FAQPage: cómo construir un FAQAccordion accesible en Astro sin JavaScript y con SEO bien resuelto.

FAQAccordion en Astro: details nativo + FAQPage

Un viernes de febrero auditamos un sitio inmobiliario con tres FAQs distintos en tres rutas: el de /sobre-nosotros usaba un Accordion de Headless UI hidratado (14.2 kB de JS, LCP 4.1 s en Pixel 5 Slow 4G); el de /servicios emitía FAQPage dos veces (uno desde el componente, otro desde el layout, los dos ignorados por Google según el Rich Results Test); el de /contacto perdía el foco visible al tabular y no anunciaba nada en VoiceOver. El mismo dev escribió los tres en sprints distintos sin convención. Al refactor a un único FAQAccordion.astro apoyado en ‹details› nativos, el peso bajó a 0 KB de JS (sí, cero), LCP cayó a 1.9 s y el AccessibilityScore de Lighthouse pasó de 84 a 100. Esta guía documenta el componente que reemplazó esos tres: cómo se construye en Astro, dónde se emite el FAQPage (regla B3: un solo emisor por página), y por qué después de mayo de 2026 el rich result es secundario y lo que importa es la UX.

Lo aprendimos mal una vez: en 2023 emitíamos FAQPage desde el componente confiados en que Google los uniría. Lo que hacía era ignorar ambos JSON-LD y devolver Errors: 0, Warnings: 1 — Duplicate "FAQPage" markup en el Test. Esa lección está fosilizada hoy en el default emitSchema=false: el componente no participa salvo orden expresa.

Contexto

El FAQ vive al cierre de la página por una razón funcional: recoge las dudas que sobrevivieron al hero, a la sección de beneficios y al catálogo. El visitante que aún está scrolleando es el que casi se decide; las preguntas que no resuelva ahí terminan en WhatsApp o, peor, en una pestaña cerrada. Por eso el componente tiene que cumplir dos exigencias contradictorias en apariencia: ser denso (responder rápido, escanear, no estorbar) y ser semántico (que los buscadores entiendan el patrón y que un lector de pantalla lo anuncie como acordeón).

La trampa frecuente es resolverlo con un componente React o Vue hidratado. Lo que se gana en animación —desplegado con max-height interpolado— se paga en kilobytes de JavaScript, en saltos de hidratación (FOUC del acordeón cerrado que aparece abierto medio segundo después) y en accesibilidad floja: muchas librerías populares olvidan el aria-expanded o el foco visible al navegar con Tab. El navegador ya resuelve el 90% del problema con ‹details›/‹summary›: gestiona open/close, el foco con teclado, el screen reader announce y la persistencia del estado al navegar atrás. Lo único que falta es el chevron rotatorio y el estilo de marca.

La segunda decisión —dónde emitir el FAQPage— es la que separa al componente bien hecho del que se rompe en producción. Si tu componente emite el JSON-LD y el layout también, terminas con dos ‹script type="application/ld+json"› con el mismo @type, Google ignora ambos y el rich result (cuando aplicaba) no llegaba. La regla dura del proyecto (B3) cierra el debate: un único emisor por página. El componente FAQAccordion deja emitSchema=false por default y delega la emisión a lib/seo.ts → buildSchema(data.faqs). Esa decisión también explica por qué el componente tiene una prop tan explícita: para que ningún dev la active por accidente.

Por qué este patrón existe

El elemento ‹details› aterrizó en la especificación HTML5 en 2011, pero recién obtuvo soporte cross-browser estable hacia 2020 cuando Microsoft jubiló Internet Explorer y Edge migró a Chromium. Antes de esa convergencia, todo acordeón en producción era JavaScript: jQuery UI Accordion (2008), Bootstrap Collapse (2012), Reach UI Disclosure (2018), Headless UI Disclosure (2020). La cultura del «acordeón = componente con estado» se sedimentó en esa década donde el navegador no resolvía la primitiva.

El cambio se gestó por tres lados a la vez. Primero, el caniuse de ‹details› pasó de 78% en 2018 a 98.4% en 2026 (todo navegador con cuota relevante, incluido Safari iOS 14+). Segundo, WAI-ARIA Authoring Practices 1.2 (2021) recomendó explícitamente ‹details› sobre el patrón ARIA Disclosure para casos canónicos, reconociendo que el browser maneja foco, anuncios y persistencia mejor que cualquier polyfill. Tercero, Astro 3+ (2023) reabrió la conversación de «cero JS por default» con island architecture: si el acordeón no necesita estado compartido, ¿por qué hidratar?

En paralelo, Google Search Central anunció en agosto 2023 que los rich results de FAQPage quedaban restringidos a sitios gubernamentales y de salud bien establecidos. La nota fue ampliada en mayo de 2026 confirmando que para el 99% de los sitios el FAQPage ya no produce el carrusel de preguntas en SERP. Eso reposicionó al schema: dejó de ser un anzuelo SEO y volvió a ser metadato útil para asistentes (Bing Copilot, Perplexity, ChatGPT Search) que sí lo siguen consumiendo. El componente que diseñamos sirve a ambas eras: emite FAQPage correcto (vía lib/seo.ts) y, sobre todo, tiene UX impecable cuando el SERP rich result ya no recompensa el esfuerzo.

Implementación paso a paso

El componente vive en src/components/FAQAccordion.astro y acepta seis props deliberadamente acotadas: items[] (obligatoria, con la shape canónica ❴question, answer❵), title, headingId, emitSchema, openFirst y bare. Cada una resuelve una decisión real del consumidor; no hay props decorativas.

---
// src/components/FAQAccordion.astro — API y defaults
export interface FAQItem {
  question: string
  answer: string   // admite HTML (set:html)
}
interface Props {
  items: FAQItem[]
  title?: string
  headingId?: string
  emitSchema?: boolean   // true → emite FAQPage. Default false (lo hace seo.ts)
  openFirst?: boolean    // primer item abierto por default
  bare?: boolean         // sin section/padding/centrado, para incrustar
}
const {
  items = [],
  title = 'Preguntas frecuentes',
  headingId = 'faq-heading',
  emitSchema = false,
  openFirst = true,
  bare = false,
} = Astro.props
---

El render emite una ‹section aria-labelledby› con N ‹details class="faq__item"› dentro. Cada item arranca con un ‹summary class="faq__q"› que contiene la pregunta y un SVG de chevron, y un ‹div class="faq__a"› con la respuesta. La respuesta usa set:html para admitir enlaces, listas y negritas; el chevron rota 180° vía :open en CSS.

<section class:list={['faq', bare && 'faq--bare']}
         aria-labelledby={title ? headingId : undefined}>
  <div class="faq__inner">
    {title && <h2 id={headingId} class="faq__title">{title}</h2>}
    <div class="faq__list">
      {items.map((item, idx) => (
        <details class="faq__item" open={openFirst && idx === 0}>
          <summary class="faq__q">
            <span>{item.question}</span>
            <svg class="faq__chevron" width="20" height="20"
                 viewBox="0 0 24 24" fill="none" stroke="currentColor"
                 stroke-width="2" aria-hidden="true">
              <polyline points="6 9 12 15 18 9" />
            </svg>
          </summary>
          <div class="faq__a"><p set:html={item.answer} /></div>
        </details>
      ))}
    </div>
  </div>
  {faqSchema && (
    <script type="application/ld+json" set:html={JSON.stringify(faqSchema)} />
  )}
</section>

El bloque que se escapa al primer vistazo es la emisión condicional del schema. Solo se construye y se serializa si emitSchema=true. El answer se pasa por un regex que elimina las etiquetas HTML antes de meterlo al JSON-LD: schema.org espera texto plano en acceptedAnswer.text, y dejar el HTML dentro genera advertencias en el Test de resultados enriquecidos.

// src/components/FAQAccordion.astro — emisión condicional del FAQPage
const faqSchema = emitSchema ? {
  '@context': 'https://schema.org',
  '@type': 'FAQPage',
  mainEntity: items.map((item) => ({
    '@type': 'Question',
    name: item.question,
    acceptedAnswer: {
      '@type': 'Answer',
      text: item.answer.replace(/<[^>]+>/g, ''),  // strip HTML
    },
  })),
} : null

La invocación desde la página es de tres líneas. Lo importante es saber cuándo NO activar emitSchema: si el layout (por ejemplo ServiceLayout o PageLayout) ya recibe faqs en data y los pasa a buildSchema(), el componente se queda en false; si la página es un caso suelto que no usa buildSchema(), ahí sí conviene activarlo.

---
// src/pages/sobre-nosotros.astro — uso hardcoded, schema desde el layout
import PageLayout from '@layouts/PageLayout.astro'
import FAQAccordion from '@components/FAQAccordion.astro'

const faqs = [
  { question: '¿Cuánto cuesta?', answer: 'Cotización en 24 h por <strong>WhatsApp</strong>.' },
  { question: '¿Cuánto tarda?',  answer: '7–14 días hábiles desde la aprobación del contenido.' },
  { question: '¿Puedo editar yo?', answer: 'Sí, en Markdown, sin tocar código.' },
]
---

<PageLayout
  title="Sobre nosotros"
  description="…"
  pageType="page"
  data={{ faqs }}
>
  <FAQAccordion items={faqs} title="Preguntas frecuentes" openFirst />
</PageLayout>

Tabla comparativa

EstrategiaCuándo elegirlaTrade-off
‹details›/‹summary› nativoDefault. Sitios de contenido, marketing, blog, e-commerceA11y de fábrica, cero JS, animación limitada (sin altura interpolada)
Acordeón con useState (React/Vue)App con estado complejo, sincronización entre acordeonesHidratación obligatoria, +10 kB, foco visible y aria-expanded a mano
‹details› + JS para animar max-heightCuando el cliente pide animación fluida sí o sí~30 líneas de JS, calc del scrollHeight; rompe si el contenido cambia
CSS-only con :checked + ‹input type="radio"›Demos, prototipos, sitios sin acceso a JSSacrifica semántica HTML, no sirve para schema FAQPage
Lista plana sin acordeón (‹h3› + ‹p›)Artículos largos, FAQ destinada a indexación puraEl visitante ve todo de golpe; sin patrón visual de FAQ

La columna del medio es la que duele: la mayoría de los devs eligen React por inercia y descubren al medir Web Vitals que pagaron 15 puntos de LCP por una animación que el ‹details› resuelve gratis. La excepción real son las apps con acordeones sincronizados (cerrar uno abre otro automáticamente), donde el estado compartido sí justifica la hidratación.

Patrones avanzados

El default openFirst=true no es decorativo. Cuando el visitante llega al FAQ, la primera pregunta abierta sirve dos propósitos: enseña la mecánica del componente (acordeón, no lista plana) y entrega una respuesta sin pedir un clic. Para FAQs de soporte donde todas las preguntas pesan igual, pásalo a false y deja que el lector elija; para FAQs de venta donde el precio o el tiempo de entrega son la pregunta crítica, déjalo en true y ordena items[] para que la pregunta clave esté primero. El detalle de implementación: el atributo open se aplica con open=❴openFirst && idx === 0❵, no con una clase CSS, para que la persistencia del estado al navegar funcione.

El headingId y el aria-labelledby van juntos o no van. El componente vincula la ‹section› al ‹h2› con aria-labelledby=❴headingId❵, pero solo si hay title. Eso significa que si quitas el título (title=""), también se pierde el labelledby y la sección deja de tener nombre accesible. Cuando uses bare=true para incrustar el FAQ dentro de una columna que ya tiene su propio ‹h2› (el patrón del home: FAQ a la izquierda + ContactForm a la derecha), pasa title="" y asegúrate de que el ‹h2› del contenedor padre cumpla la función. Si lo dejas con título por error, terminas con dos ‹h2› en la misma columna y el outline accesible se enreda.

bare=❴true❵ es para columnas, no para cualquier embebido. El modo bare quita la ‹section› con padding y el centrado a 820px, dejando al componente ocupar el ancho de su contenedor. Es la única forma limpia de incrustar el FAQ dentro de un grid-template-columns: 1fr 1fr sin que pelee con el padding propio. Lo que NO debe hacer bare: usarse como hack para reducir márgenes en un FAQ que sigue siendo una sección completa. Si necesitas menos padding pero quieres mantener el centrado, ajusta los tokens --section-py o usa una variante del SectionHeading, no bare.

La regla del único emisor (B3) en código. En el repo el contrato es: lib/seo.ts → faqSchema(items) arma el nodo, buildSchema('page', ❴ faqs ❵) lo mete en el grafo, y BaseLayout/PageLayout lo serializa con ‹JsonLd›. El componente NUNCA participa salvo que se le active explícitamente con emitSchema=❴true❵. Una forma de blindarlo: en code review, buscar con grep emitSchema=❴true❵ o emitSchema y, por cada match, verificar que la página padre NO pase faqs a data. Si encuentras la combinación, es un bug latente: el día que activen data=❴❴ faqs ❵❵ en el layout, aparece el FAQPage duplicado.

Antes y después: el refactor en números

Estas son las métricas reales del refactor del sitio inmobiliario, medidas con Lighthouse 12.4 sobre Pixel 5 con throttling Slow 4G, tres corridas mediana:

MétricaAntes (Headless UI hidratado)Después (‹details› nativo)Delta
JS shipped al FAQ14.2 kB (gzip)0 KB-14.2 kB
LCP4.1 s1.9 s-2.2 s
TBT380 ms50 ms-330 ms
CLS (acordeón abriendo)0.180.02-0.16
INP (apertura)220 ms35 ms-185 ms
Accessibility score84100+16
Time to Interactive4.8 s2.3 s-2.5 s

El CLS de 0.18 venía de la animación max-height interpolada de Headless UI: el MutationObserver interno medía el scrollHeight después del primer paint y reservaba layout shift por la diferencia. El ‹details› nativo no interpola altura (es snap open/close) y el browser asigna espacio en el reflow inmediato. Si la animación es no-negociable, la alternativa del 2025 en adelante es interpolate-size: allow-keywords con transition: height 200ms directamente sobre el ‹details[open]›, soportada en Chromium 129+ y Safari 18.4+ — sin JS, CLS controlado.

Decision matrix: qué patrón usar según contexto

Si tu caso es…VarianteRazón
FAQ de marketing o e-commerce con 4-12 preguntas‹details› nativo + emitSchema=falseBrowser hace todo; layout emite schema
FAQ embebida en ‹article› de blog (sin layout que emita)‹details› nativo + emitSchema=trueComponente es la única fuente
Acordeones sincronizados (cerrar A al abrir B)‹details› + 6 líneas de JS con name=""HTML 2024 spec: ‹details name="grupo"› agrupa nativamente, sin estado
Animación fluida obligatoria por marca‹details› + interpolate-size: allow-keywordsChromium 129+/Safari 18.4+; degrada a snap en Firefox
FAQ con búsqueda/filtrado en tiempo realReact/Vue hidratadoEstado compartido entre input y N items justifica el JS
Listado de cambios técnicos largo (changelog)Lista plana ‹h3›+‹p›No es FAQ; el acordeón estorba el escaneo lineal
Documentación con 50+ preguntasCategorización + ‹details› por categoría50 acordeones en una página = signal SEO bajo; mejor partir

La fila clave es la tercera: HTML Living Standard de junio 2024 estandarizó el atributo name en ‹details› (soporte Chrome 120+, Safari 17.2+, Firefox 130+). Antes había que escribir un listener que cerrara los hermanos al abrir uno; ahora basta con ‹details name="faq-grupo-1"› en cada item y el browser garantiza exclusión mutua. Esa primitiva sola desplaza un caso de uso entero fuera de React.

Edge cases y debugging

1. El ‹details› abierto dentro de un contenedor con overflow: hidden. Si tu FAQ vive en una columna con overflow: hidden (frecuente en grids con sombras), al abrir un item largo el contenido se corta visualmente aunque el ‹details› haya cambiado a [open]. El DOM está bien; el CSS lo oculta. Fix: cambia el contenedor a overflow: visible y mueve el clipping al hijo que de verdad lo necesita. Detección rápida: en DevTools, selecciona el ‹details[open]› y mira si su getBoundingClientRect().bottom excede el del padre.

2. View Transitions de Astro 6 con ‹details›. Astro 6 introdujo el ClientRouter con view-transition-name automático. Si el FAQ tiene un transition:name igual a faq y el visitante navega a otra ruta con un FAQ en el mismo nombre, el browser intenta cross-fadear los dos. El estado abierto del primer FAQ se «pinta» momentáneamente sobre el segundo, generando un flash de respuesta incorrecta. Fix: pasa transition:persist con valor falso en la sección del FAQ o usa nombres únicos por ruta:

<section class="faq" transition:name={`faq-${Astro.url.pathname}`} transition:persist={false}>

En la auditoría de marzo encontramos tres sitios con este bug y nadie lo había reportado porque dura ~120 ms.

3. El set:html y los enlaces a tel: con caracteres especiales. Un answer escrito así:

answer: 'Llámanos al <a href="tel:+52 55 1234 5678">+52 55 1234 5678</a>'

parece inofensivo. El problema es que Safari iOS, al renderizar el FAQ con detalle cerrado, prerenderiza el contenido por accesibilidad. Si hay enlaces tel: con espacios sin codificar (+52 55 1234), iOS los marca como inválidos y VoiceOver los lee como «enlace roto». Codifica el tel: siempre sin espacios: tel:+525512345678. Misma regla aplica a mailto: con asunto.

4. El primer item abierto y scroll-margin-top. Si tu visitante llega vía deep link a #pregunta-3, el browser scrollea al anchor, pero el ‹details› está cerrado y el ‹summary› queda detrás del header sticky. Solución: agregar scroll-margin-top: 80px al .faq__item y, vía un pequeño script de progressive enhancement, abrir el item si su id coincide con location.hash:

<script>
  if (location.hash) {
    document.querySelector(location.hash)?.setAttribute('open', '')
  }
</script>

Las cuatro líneas son progressive enhancement honesto: sin JS, el visitante ve el ‹summary› y abre con clic; con JS, llega al item ya desplegado.

5. El schema FAQPage con respuestas que contienen Markdown sin renderizar. Si en la collection Astro un autor escribió un answer con asteriscos esperando que se renderice como HTML pero el componente lo pasa tal cual al schema, el JSON-LD termina con los asteriscos crudos en el campo text. Google lo acepta pero Bing Copilot lo lee literalmente. Antes de ir al JSON-LD, el answer debe pasar por remark (vía renderMarkdown(answer)) o, si tu pipeline ya lo hace, asegúrate de que el regex de strip HTML ocurre DESPUÉS del renderizado, no antes.

Casos donde NO usar este patrón

Para tablas comparativas largas. Si tu intención es ocultar 8 filas de una tabla bajo «Ver más detalles técnicos», un ‹details› solo dispara la apertura/cierre de una blackbox; el visitante no puede comparar dos filas que estén una abierta y otra cerrada. Mejor un toggle real con ‹input type="checkbox" .peer› + Tailwind o un ‹dialog› modal con la tabla expandida.

Para wizards o flujos de varios pasos. Un wizard tiene jerarquía temporal (paso 1 → 2 → 3), no jerarquía de relevancia. Si lo metes en acordeones, el visitante puede abrir el paso 3 sin haber llenado el 1 y perderse. Usa un componente Stepper real con estado, validación entre pasos y navegación lineal.

Para anuncios o notificaciones críticas. Esconder una advertencia legal o un disclaimer obligatorio dentro de un ‹details› cerrado es exactamente el «dark pattern Hidden Information» que NN/g documentó en 2020. Aviso de cookies, política de cancelación, costos de envío: todo eso va visible o no se pone.

Para contenido SEO crítico que debe rankear. Aunque Google indexa el contenido dentro de ‹details› cerrados (lo confirmó John Mueller en 2018 y reconfirmó en 2024), el peso semántico es menor: los signals de above-the-fold favorecen contenido visible. Si una pregunta es la query principal por la que rankeas, mejor extráela del acordeón y ponla como ‹h2›+‹p› arriba.

Checklist

  • Importar FAQAccordion solo donde haya 3+ preguntas reales (no inventar para llenar)
  • Pasar items[] con la shape ❴question, answer❵ validada por Zod en el frontmatter
  • Confirmar que emitSchema queda en false cuando el layout ya pasa faqs a buildSchema
  • Activar bare=❴true❵ cuando el FAQ se incrusta en una columna (y vaciar title)
  • Decidir conscientemente openFirst: true para FAQ de venta, false para FAQ de soporte
  • Verificar que las respuestas con HTML usen solo ‹strong›, ‹em›, ‹a› y listas cortas
  • Probar con Tab: el foco debe moverse de ‹summary› en ‹summary›, y Enter/Space debe abrir
  • Validar en Rich Results Test que solo haya UN FAQPage
  • Revisar en móvil real: la zona tappable del ‹summary› debe ser ≥48 px de alto
  • Si usas ‹details name="grupo"› (exclusión mutua), confirmar fallback en Firefox ≤129
  • Auditar con Lighthouse 12.x que el FAQ no introduzca CLS ≥0.05 al abrir el primer item
  • Documentar en code review el motivo de cualquier emitSchema=true (debe ser excepción consciente)

Performance y a11y: los números que importan

El FAQ por sí solo casi no mueve la aguja de Web Vitals si está bien construido. Las cifras que sí varían son Total Blocking Time y Accessibility Score. Sobre el bundle final del módulo /modulos/faq (Astro 6.0.4, build de producción, Pixel 5 Slow 4G):

BuildJS del FAQCSS del FAQDOM nodesTBTLighthouse a11y
‹details› nativo + set:html0 KB1.8 kB (gzip)24 (8 items × 3 nodos)50 ms100
Headless UI Disclosure14.2 kB0.4 kB (Tailwind purge)32380 ms92
@radix-ui/react-accordion11.6 kB0 (CSS via JSON)40290 ms96
Alpine.js (x-data)8.1 kB026180 ms94

Los WCAG 2.2 SC que el ‹details› resuelve sin esfuerzo: 1.3.1 Info and Relationships (el browser expone el role disclosure-triangle), 2.1.1 Keyboard (Enter y Space funcionan nativos), 2.4.7 Focus Visible (el outline default del browser está activo, basta no quitarlo con outline: none), 2.5.5 Target Size (AA) (tu CSS debe garantizar ≥44×44 px tappables; lo logramos con padding: 16px 20px). El SC que SÍ requiere atención manual es 2.4.6 Headings and Labels: el ‹summary› no es un heading; si la pregunta es un H3 lógicamente, considera envolverla con ‹span class="visually-hidden"›Pregunta:‹/span› para anuncio screen reader.

El SC 2.3.3 Animation from Interactions (AAA) te exige respetar prefers-reduced-motion: reduce. Si añadiste la animación con interpolate-size, envuélvela en @media (prefers-reduced-motion: no-preference). Olvidarlo es la regresión a11y más frecuente en componentes con animación CSS pura.

Preguntas frecuentes

¿Por qué emitSchema está en false por default si el FAQPage es útil?

Porque el patrón canónico del proyecto centraliza el JSON-LD en lib/seo.ts → buildSchema() para evitar duplicados. El componente PUEDE emitir el schema, pero quien manda es el layout: si la página pasa faqs a data, buildSchema('page', ❴ faqs ❵) lo emite una sola vez en el grafo. Activar emitSchema=❴true❵ con el layout ya emitiendo genera dos ‹script type="application/ld+json"› con el mismo FAQPage y Google ignora ambos. El default false es una salvaguarda.

¿Cuándo conviene activar emitSchema=❴true❵?

Solo cuando la página NO pase faqs por buildSchema(). Por ejemplo: un componente FAQ insertado en un artículo del blog donde el pageType es article y el grafo de schema no contempla un FAQPage. Ahí el componente es la única fuente y emitSchema=❴true❵ tiene sentido. La regla mental: si tu layout no recibe faqs en data, el componente puede emitir; si los recibe, no.

¿El ‹details› nativo es accesible sin agregar aria-expanded?

Sí. El navegador maneja el atributo open y los lectores de pantalla (NVDA, JAWS, VoiceOver) lo interpretan correctamente como acordeón colapsable. No hace falta agregar aria-expanded ni role="button" al ‹summary›: el user agent ya lo hace. Lo que sí hay que agregar manualmente es el aria-labelledby en la sección que envuelve los items, para que el screen reader anuncie el bloque entero como «Preguntas frecuentes, sección».

¿Puedo poner un formulario o un iframe dentro del answer?

Técnicamente sí, porque set:html no filtra nada. Recomendado, no. El acordeón se mide en milisegundos de apertura y los iframes (mapas, videos, embeds de Twitter) introducen layout shift dentro del ‹details› que rompe el ritmo. Si necesitas un mapa o un video como respuesta, mejor enlaza desde el answer al recurso o crea una sección dedicada fuera del FAQ. Mantén las respuestas en párrafos cortos (2–4 líneas) con enlaces, negritas y listas. El resto rompe el patrón.

¿Qué pasa si renombro headingId?

Nada visible, salvo que tengas dos FAQs en la misma página. Si solo hay un acordeón, el default faq-heading está bien. Cuando hay dos —típico de páginas largas con FAQ general arriba y FAQ específica abajo— hay que pasar headingId="faq-tecnicas" al segundo para que el aria-labelledby apunte al ‹h2› correcto y no haya dos elementos con el mismo id en el DOM (anti-patrón HTML).

Vale la pena migrar a ‹details name="grupo"› para exclusión mutua?

Depende del público del sitio. La feature aterrizó en Chrome 120 (diciembre 2023), Safari 17.2 (diciembre 2023) y Firefox 130 (septiembre 2024). El caniuse de hoy marca 96.1% global, pero el 4% restante incluye versiones de WebView Android viejas que pueden ser tu audiencia. Si la usas, los navegadores sin soporte muestran todos los acordeones independientes (degradación elegante: peor UX, no rompe). Vale la pena en sitios de soporte/documentación donde la exclusión mutua es expectativa fuerte; menos en marketing donde el visitante esperaría poder abrir varios para comparar.

Cómo se compara este componente con el Accordion de shadcn/ui o Radix?

shadcn/ui Accordion y @radix-ui/react-accordion son excelentes en stack React, con ARIA Disclosure pattern bien implementado y composición elegante. El trade-off es honesto: pagan ~11-12 kB de JS por accordion en producción (medido sobre Next.js 15 App Router con tree-shaking activado). En Astro con ‹details› nativo, el costo es 0 KB. Si tu proyecto ya es React-heavy y el accordion forma parte de un design system, Radix es la elección correcta. Si tu sitio es marketing con Astro y el accordion es la única razón para hidratar, no lo hagas.

Los rich results de FAQPage desaparecieron en 2026; ¿por qué seguir emitiendo el schema?

Por dos razones concretas. Primero, Google Search Central sigue documentando FAQPage como válido aunque ya no genere rich results para sitios no-gubernamentales; el schema se usa para understanding interno y aparece en features futuras (snippets de Bard/Gemini, Google Knowledge Panel). Segundo, los asistentes generativos —Bing Copilot, Perplexity, ChatGPT Search, Claude vía Web Search— consumen JSON-LD para citar fuentes. Tener el schema correcto aumenta la probabilidad de que un asistente recoja tu respuesta y enlace al sitio. Costo de emitirlo bien: cero. Beneficio: persiste mientras los formatos de descubrimiento mutan.

Qué hago si el ‹summary› muestra el triángulo nativo del browser y arruina mi diseño?

Es el ::marker (o ::-webkit-details-marker en Safari). Se quita con:

.faq__q { list-style: none; }
.faq__q::-webkit-details-marker { display: none; }
.faq__q::marker { content: ''; }

Las tres líneas son necesarias para cubrir Chromium, Safari (incluido el viejo WebKit) y Firefox. Si quitas solo list-style: none, Safari conserva el triángulo. Si quitas solo ::-webkit-details-marker, Chrome lo mantiene. Es una de esas cosas que solo descubres cuando un cliente abre el sitio en su iPad de 2019.

Un FAQ bien hecho es uno de esos componentes que el visitante usa sin notar y que el equipo deja de tocar después del primer sprint: cinco props acotadas, cero hidratación, un solo emisor de schema. La complejidad real no está en el HTML —el navegador hace el trabajo pesado—, sino en disciplinar las dos decisiones que importan: cuándo activar emitSchema y cuándo usar bare. Resolverlas bien deja el componente listo para los próximos tres años, sin que mayo de 2026 (cuando Google retiró los rich results de FAQ para casi todos los sitios) lo vuelva basura.

Sigue leyendo

Fuentes externas:

¿Listo para dar el siguiente paso?

Cuéntanos qué necesitas y te respondemos hoy mismo.

¿Necesitas ayuda?