Como crear un acordeón accesible con details/summary y JS en WordPress

Contents

Introducción

Un acordeón es un patrón de interfaz muy común para ocultar y mostrar contenido. Utilizar las etiquetas semánticas ltdetailsgt y ltsummarygt proporciona una base accesible y con comportamiento nativo en navegadores modernos. Sin embargo, para ofrecer una experiencia completa (animaciones suaves, soporte para lectores de pantalla más antiguos, control por teclado avanzado y respeto a preferencias de movimiento) conviene mejorar el marcado con CSS y un poco de JavaScript progresivo.

Por qué usar details/summary

  • Semántica: el navegador ya entiende que hay una sección expandible.
  • Accesibilidad: muchos lectores de pantalla reconocen el widget nativo.
  • Progresivo: si JavaScript falla, la funcionalidad básica sigue existiendo.
  • Sencillo: menos atributos ARIA manuales que con elementos totalmente personalizados.

Consideraciones de accesibilidad

  1. Asegurar que el ltsummarygt sea alcanzable y reconocible mediante teclado (lo es por defecto, pero personalizaciones pueden romperlo).
  2. Añadir roles/atributos cuando sea necesario para compatibilidad con ciertos lectores de pantalla: aria-controls en el summary y role=region en el panel pueden ayudar.
  3. Proveer foco visible y estados de contraste adecuados.
  4. Respetar prefers-reduced-motion evitando animaciones si el usuario lo solicita.
  5. Permitir navegación por flechas entre summarios si existen múltiples items (mejora de UX similar a los acordeones de WAI-ARIA).

Marcado HTML recomendado

Usa una estructura clara: cada item del acordeón será un ltdetailsgt con un ltsummarygt y un contenedor para el contenido. Añadimos atributos ARIA para mejorar la interoperabilidad.

lt!-- Estructura base de un item del acordeón --gt
ltdetails class=accordion-item id=acc-1gt
  ltsummary class=accordion-summary id=acc-1-summary aria-controls=acc-1-panelgt
    Título del panel 1
  lt/summarygt
  ltdiv id=acc-1-panel class=accordion-panel role=region aria-labelledby=acc-1-summarygt
    ltpgtContenido del panel 1. Aquí puede ir HTML rico: imágenes, listas, formularios...lt/pgt
  lt/divgt
lt/detailsgt
lt!-- Repetir por cada panel --gt

Notas sobre el marcado

  • El atributo aria-controls en el summary apunta al id del panel, facilitando a tecnologías de asistencia saber qué controla.
  • El panel usa role=region y aria-labelledby para enlazar con el summary y mejorar la navegación.
  • No elimines el comportamiento nativo del summary si aplicas estilos, mantén la capacidad de recibir foco.

Estilos CSS: apariencia y accesibilidad

A continuación un ejemplo de estilos que incluyen icono personalizado, foco visible y adaptación a quotprefers-reduced-motionquot.

/ Estilos básicos del acordeón /
.accordion-item {
  border-bottom: 1px solid #e2e2e2
}

/ Summary: apariencia y cursor /
.accordion-summary {
  list-style: none / limpia marcador por seguridad /
  cursor: pointer
  padding: 1rem
  display: flex
  align-items: center
  justify-content: space-between
  font-weight: 600
  background: #fff
  border: none
}

/ Icono personalizado: rotar cuando está abierto /
.accordion-summary::after {
  content: ▸ / triángulo simple /
  transform: rotate(0deg)
  transition: transform 180ms ease
  margin-left: 1rem
}

/ Cuando details está [open] gira el icono /
.accordion-item[open] .accordion-summary::after {
  transform: rotate(90deg)
}

/ Panel — dejar overflow oculto para animaciones JS /
.accordion-panel {
  padding: 0 1rem 1rem 1rem
}

/ Foco visible accesible /
.accordion-summary:focus {
  outline: 3px solid #6ca0ff
  outline-offset: 2px
}

/ Respetar preferencia de movimiento /
@media (prefers-reduced-motion: reduce) {
  .accordion-summary::after {
    transition: none
  }
}

JavaScript progresivo para animación y mejoras de accesibilidad

El siguiente script no reemplaza al comportamiento nativo lo complementa: añade animación de altura suave (sin animar a/from auto), sincroniza atributos aria-expanded, gestiona navegación por teclado entre summarios y respeta prefers-reduced-motion. Se presenta como ejemplo que puedes adaptar a tu tema de WordPress (por ejemplo, enqueue en un archivo JS).

/ Accordeón accesible con details/summary - mejora JS /
(function () {
  const prefersReducedMotion = window.matchMedia((prefers-reduced-motion: reduce)).matches

  // Selecciona todos los accordions de la página
  const items = Array.from(document.querySelectorAll(.accordion-item))

  if (!items.length) return

  // Inicializa aria-expanded y panel estilo
  items.forEach(item =gt {
    const summary = item.querySelector(.accordion-summary)
    const panel = item.querySelector(.accordion-panel)

    // Asegurar ids únicos
    if (!summary.id) summary.id = summary-{Math.random().toString(36).substr(2, 9)}
    if (!panel.id) panel.id = panel-{Math.random().toString(36).substr(2, 9)}

    summary.setAttribute(aria-controls, panel.id)
    panel.setAttribute(aria-labelledby, summary.id)
    panel.style.overflow = hidden

    // Set aria-expanded según el estado inicial
    summary.setAttribute(aria-expanded, item.hasAttribute(open) ? true : false)

    // Si el item está cerrado dejamos panel height 0
    if (!item.hasAttribute(open)) {
      panel.style.maxHeight = 0px
    } else {
      panel.style.maxHeight = panel.scrollHeight   px
    }

    // Cuando el usuario hace click en summary: animamos la apertura/cierre si no se reduce movimiento
    summary.addEventListener(click, (e) =gt {
      // Evitar doble ejecución en algunos navegadores: el click sucede antes del toggle nativo
      // Usamos setTimeout para dejar que el estado open se actualice y luego animamos
      window.setTimeout(() =gt toggleAnimation(item, panel, summary), 0)
    })

    // Keyboard: manejar Enter/Space para compatibilidad y navegación por flechas
    summary.addEventListener(keydown, (e) =gt {
      const key = e.key
      if (key ===    key === Spacebar  key === Enter) {
        // Let native toggle run, aria se actualizará en click handler setTimeout
        return
      }
      // Flechas para moverse al summary anterior/siguiente
      if (key === ArrowDown  key === ArrowRight) {
        e.preventDefault()
        focusNextSummary(items, summary)
      } else if (key === ArrowUp  key === ArrowLeft) {
        e.preventDefault()
        focusPrevSummary(items, summary)
      } else if (key === Home) {
        e.preventDefault()
        focusSummaryByIndex(items, 0)
      } else if (key === End) {
        e.preventDefault()
        focusSummaryByIndex(items, items.length - 1)
      }
    })
  })

  function toggleAnimation(item, panel, summary) {
    const isOpen = item.hasAttribute(open)
    // Actualizar aria-expanded
    summary.setAttribute(aria-expanded, isOpen ? true : false)

    if (prefersReducedMotion) {
      // Si el usuario prefiere poca animación: simplemente ajustar maxHeight sin transición
      panel.style.transition = none
      panel.style.maxHeight = isOpen ? panel.scrollHeight   px : 0px
      // Forzar reflow para aplicar cambio si necesario
      panel.offsetHeight
      panel.style.transition = 
      return
    }

    // Animación basada en medir scrollHeight
    if (isOpen) {
      // Abrir: animar max-height desde 0 a scrollHeight
      panel.style.transition = max-height 300ms ease
      panel.style.maxHeight = panel.scrollHeight   px
      // Limpiar inline style tras finalizar para permitir el redimensionamiento natural
      panel.addEventListener(transitionend, function handler() {
        // al estar abierto, dejamos maxHeight en none para que el contenido pueda crecer naturalmente
        panel.style.maxHeight = none
        panel.removeEventListener(transitionend, handler)
      })
    } else {
      // Cerrar: establecer maxHeight = scrollHeight (estado visible), forzar reflow, luego animar a 0
      panel.style.transition = max-height 300ms ease
      // Si maxHeight era none, primero fijarlo al scrollHeight
      if (panel.style.maxHeight === none  panel.style.maxHeight === ) {
        panel.style.maxHeight = panel.scrollHeight   px
        // Forzar reflow
        panel.offsetHeight
      }
      // Animar a 0
      window.requestAnimationFrame(() =gt {
        panel.style.maxHeight = 0px
      })
    }
  }

  // Helpers para navegación por teclado
  function focusNextSummary(items, currentSummary) {
    const summaries = items.map(i =gt i.querySelector(.accordion-summary))
    const idx = summaries.indexOf(currentSummary)
    const next = summaries[(idx   1) % summaries.length]
    next.focus()
  }
  function focusPrevSummary(items, currentSummary) {
    const summaries = items.map(i =gt i.querySelector(.accordion-summary))
    const idx = summaries.indexOf(currentSummary)
    const prev = summaries[(idx - 1   summaries.length) % summaries.length]
    prev.focus()
  }
  function focusSummaryByIndex(items, index) {
    const summaries = items.map(i =gt i.querySelector(.accordion-summary))
    if (summaries[index]) summaries[index].focus()
  }

  // Opcional: abrir un panel si la URL contiene su id como fragmento (deep-linking)
  (function handleFragmentOpen(){
    const hash = decodeURIComponent(location.hash.replace(#,))
    if (!hash) return
    const target = document.getElementById(hash)
    if (!target) return
    const item = target.closest(.accordion-item)
    if (item  !item.hasAttribute(open)) {
      // Abrir el item para mostrar el target
      item.setAttribute(open, )
      // Actualizar aria en caso de que el script ya haya inicializado
      const summary = item.querySelector(.accordion-summary)
      if (summary) summary.setAttribute(aria-expanded, true)
      // Ajustar panel height inmediatamente
      const panel = item.querySelector(.accordion-panel)
      if (panel) {
        panel.style.maxHeight = panel.scrollHeight   px
        // Deshacer el maxHeight despues de una animación corta
        setTimeout(() =gt { panel.style.maxHeight = none }, 350)
      }
      // Llevar foco al elemento objetivo para accesibilidad
      const focusTarget = item.querySelector(#   CSS.escape(hash))
      if (focusTarget) focusTarget.focus()
    }
  })()

})()

Compatibilidad y consideraciones para WordPress

  • Encola el script usando wp_enqueue_script con dependencia de jquery si tu tema lo requiere, o preferentemente sin jQuery para mantenerlo ligero.
  • Los fragmentos de código de arriba no deben imprimirse directamente en el HTML sin escapado si los colocas en PHP en WordPress usa funciones de salida seguras como wp_add_inline_script o archivos .js separados.
  • Si necesitas soportar navegadores muy antiguos que no reconocen ltdetailsgt (ej. IE11), considera una pequeña polyfill o bien reemplazar con un widget aria completo solo donde sea necesario.
  • Prueba con lectores de pantalla populares (NVDA, VoiceOver, JAWS) y navegadores móviles para asegurar experiencia consistente.

Buenas prácticas y optimizaciones

  1. Mantenlo progresivo: el contenido debe ser legible y navegable incluso sin JavaScript.
  2. No anules comportamiento nativo salvo que sea necesario añade mejoras, no sustituciones innecesarias.
  3. Respeta la preferencia de movimiento para usuarios con sensibilidad a las animaciones.
  4. Proporciona foco visible y establece contraste adecuado para accesibilidad visual.
  5. Prueba el control por teclado: Tab, Enter, Space, Arrow keys, Home/End.

Ejemplo completo (HTML CSS JS reunidos)

Este fragmento combina los ejemplos anteriores. Inserta el CSS en tu hoja de estilos y el JS en un archivo encolado en WordPress.

lt!-- HTML acordeón --gt
ltdetails class=accordion-itemgt
  ltsummary class=accordion-summarygtPanel Alt/summarygt
  ltdiv class=accordion-panelgt
    ltpgtContenido A.lt/pgt
  lt/divgt
lt/detailsgt

ltdetails class=accordion-itemgt
  ltsummary class=accordion-summarygtPanel Blt/summarygt
  ltdiv class=accordion-panelgt
    ltpgtContenido B.lt/pgt
  lt/divgt
lt/detailsgt
lt!-- Añadir CSS/JS tal y como se muestra en ejemplos separados --gt

Conclusión

Utilizar ltdetailsgt y ltsummarygt como base para un acordeón en WordPress aporta semántica y accesibilidad por defecto. Con unas pequeñas mejoras en CSS y JavaScript obtendrás animaciones suaves, mejor soporte para tecnologías de asistencia y una experiencia teclado-friendly. Integra el JS como mejora progresiva y evita sobrescribir comportamientos nativos salvo que sea imprescindible.

Recursos útiles



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *