How to create an accessible accordion with details/summary and JS in WordPress

Contents

Introduction

This article is a complete, production-ready tutorial for building an accessible accordion in WordPress using the native HTML5 details/summary elements, plus progressive JavaScript enhancements. It covers semantic markup, accessibility rules and ARIA patterns you should be aware of, styling, keyboard interaction, optional single-open behavior, motion-safe animations, WordPress integration (how to paste markup into the block editor and how to enqueue assets in a theme/plugin), and testing/checklist items before publishing.

Why use details/summary for an accordion?

  • Semantic and built-in behavior: Browsers treat details/summary as a disclosure widget many keyboard and assistive behaviors are native.
  • Lightweight and future-proof: No large ARIA widget scaffolding required for basic disclosure UX.
  • Progressive enhancement: The markup works without JavaScript enhancements add better animation, ARIA synchronization, and optional single-open behavior.
  • Less code to maintain: Fewer DOM roles to manage, fewer focus traps and manual state toggling required for basic expand/collapse.

Accessibility considerations and principles

  • Do not place interactive controls inside a summary: Summary acts as the control for the disclosure and must not include other interactive elements (buttons, links, inputs). Put them inside the panel instead.
  • Use IDs and aria-controls/aria-labelledby: Provide a clear relationship between the summary and the panel for assistive tech. Keep IDs unique across a page.
  • Sync aria-expanded with details.open: Some assistive tech reads aria attributes better update aria-expanded on the summary to match the open state.
  • Keyboard support: Browsers typically provide Enter/Space toggling for summary, but you can add additional keys (Arrow Up/Down, Home/End) to navigate between summaries if you implement a composite accordion.
  • Respect prefers-reduced-motion: Turn off animations when users prefer reduced motion.
  • Focus order: Ensure the summary is focusable (it usually is). When closing a panel programmatically, return focus where appropriate.
  • Single-open vs multi-open: Decide whether the accordion allows multiple panels open. If using single-open, closing other panels when opening a new one must be implemented carefully and announced to assistive tech (by updating aria attributes).

Basic semantic markup (multi-open, minimal JS)

Start with this simple HTML structure. It is already usable without JavaScript in modern browsers. The example includes attributes to improve assistive tech mapping.

ltdetails class=wp-accordion__item id=accordion-1gt
  ltsummary id=accordion-1-heading aria-controls=accordion-1-panel aria-expanded=falsegt
    Section title 1
  lt/summarygt
  ltdiv id=accordion-1-panel role=region aria-labelledby=accordion-1-headinggt
    ltpgtPanel content for section 1. Put any HTML except interactive items in the summary here.lt/pgt
  lt/divgt
lt/detailsgt

ltdetails class=wp-accordion__item id=accordion-2gt
  ltsummary id=accordion-2-heading aria-controls=accordion-2-panel aria-expanded=falsegt
    Section title 2
  lt/summarygt
  ltdiv id=accordion-2-panel role=region aria-labelledby=accordion-2-headinggt
    ltpgtPanel content for section 2.lt/pgt
  lt/divgt
lt/detailsgt

Notes about the markup

  • The summary element is the toggle control. Do not embed clickable controls inside it.
  • The content panel is a generic container (div in the example) with role=region and aria-labelledby that points to the summarys ID. This makes the panel easier to navigate for screen reader users.
  • Setting aria-expanded on the summary lets assistive tech read the current state keep it synchronized with the details open attribute using JavaScript (example below).

Styling the accordion

Below is a practical CSS that hides the browser default marker, provides a custom indicator, sets accessible sizes and spacing, and includes a motion-safe transition. It also uses a simple utility class system so you can adapt it to your theme.

/ Base layout /
.wp-accordion__item {
  border: 1px solid #ddd
  border-radius: 6px
  margin: 0 0 1rem
  overflow: hidden / helps with animated max-height /
}

/ Reset summary default marker and spacing /
.wp-accordion__item summary {
  list-style: none
  cursor: pointer
  padding: 1rem
  display: flex
  align-items: center
  gap: 0.75rem
  background: #f7f7f7
  font-weight: 600
  outline: none
}

/ Remove default marker in WebKit and Firefox /
.wp-accordion__item summary::-webkit-details-marker { display: none }
.wp-accordion__item summary::marker { font-size: 0 }

/ Add custom chevron /
.wp-accordion__item summary::before {
  content: 
  display: inline-block
  width: 1rem
  height: 1rem
  transform: rotate(0deg)
  transition: transform 200ms cubic-bezier(.2,.8,.2,1)
  background-image: linear-gradient(45deg, transparent 45%, currentColor 45%), linear-gradient(-45deg, transparent 45%, currentColor 45%)
  background-repeat: no-repeat
  background-size: 50% 50%
  background-position: center
  opacity: 0.85
}

/ Rotate chevron when open /
.wp-accordion__item[open] summary::before {
  transform: rotate(90deg)
}

/ Panel styles /
.wp-accordion__item > div[role=region] {
  padding: 1rem
  background: #fff
  border-top: 1px solid #eee
  overflow: hidden
}

/ Accessibility: focus outline for keyboard users /
.wp-accordion__item summary:focus {
  box-shadow: 0 0 0 3px rgba(21,156,228,0.25)
  border-radius: 4px
}

/ Respect reduced motion /
@media (prefers-reduced-motion: reduce) {
  .wp-accordion__item summary::before,
  .wp-accordion__item[open] summary::before {
    transition: none
  }
}

JavaScript enhancements

The JavaScript below provides a set of progressive enhancements:

  1. Synchronizes aria-expanded on the summary with the open state of the details.
  2. Optional single-open mode (close other details when one opens).
  3. Accessible animated opening/closing using max-height calculations while respecting prefers-reduced-motion.
  4. Keyboard navigation: Arrow Up/Down/Home/End move focus between summary controls.
  5. Esc closes the currently open details and returns focus to its summary.
/
  Accessible accordion enhancement.
  Usage: include after DOM is ready. It progressively enhances native details/summary behavior.
  To enable singleOpen, set the option { singleOpen: true }.
/
(function () {
  use strict

  var supportsReducedMotion = window.matchMedia((prefers-reduced-motion: reduce)).matches

  function initAccordion(root, options) {
    options = options  {}
    var singleOpen = !!options.singleOpen

    var items = Array.prototype.slice.call(root.querySelectorAll(details.wp-accordion__item))
    if (!items.length) return

    items.forEach(function (details) {
      var summary = details.querySelector(summary)
      var panel = details.querySelector([role=region])

      // Ensure aria attributes exist
      if (summary  panel) {
        var sumId = summary.id  generateId(summary-)
        var panelId = panel.id  generateId(panel-)

        summary.id = sumId
        panel.id = panelId
        summary.setAttribute(aria-controls, panelId)
        summary.setAttribute(aria-expanded, details.hasAttribute(open) ? true : false)
        panel.setAttribute(aria-labelledby, sumId)

        // Keep aria-expanded in sync with the open attribute
        details.addEventListener(toggle, function () {
          var isOpen = details.hasAttribute(open)
          summary.setAttribute(aria-expanded, isOpen ? true : false)

          if (isOpen  singleOpen) {
            // Close other details within the same root
            items.forEach(function (other) {
              if (other !== details  other.hasAttribute(open)) {
                other.removeAttribute(open)
                var otherSummary = other.querySelector(summary)
                if (otherSummary) otherSummary.setAttribute(aria-expanded, false)
                // If you want the close to animate, ensure animation code is applied here too
              }
            })
          }
        })

        // Set up animated open/close if user doesnt prefer reduced motion
        if (!supportsReducedMotion) {
          setupAnimatedToggle(details, panel)
        }

        // Keyboard navigation between summaries
        summary.addEventListener(keydown, function (evt) {
          var key = evt.key
          var idx = items.indexOf(details)
          if (key === ArrowDown) {
            evt.preventDefault()
            focusNextSummary(items, idx)
          } else if (key === ArrowUp) {
            evt.preventDefault()
            focusPrevSummary(items, idx)
          } else if (key === Home) {
            evt.preventDefault()
            focusSummaryByIndex(items, 0)
          } else if (key === End) {
            evt.preventDefault()
            focusSummaryByIndex(items, items.length - 1)
          } else if (key === Escape  key === Esc) {
            // Close this details if open
            if (details.hasAttribute(open)) {
              details.removeAttribute(open)
              summary.setAttribute(aria-expanded, false)
              summary.focus()
            }
          }
        })
      }
    })
  }

  // Helpers for keyboard moves
  function focusNextSummary(items, idx) {
    var next = items[(idx   1) % items.length]
    if (next) {
      var s = next.querySelector(summary)
      if (s) s.focus()
    }
  }
  function focusPrevSummary(items, idx) {
    var prev = items[(idx - 1   items.length) % items.length]
    if (prev) {
      var s = prev.querySelector(summary)
      if (s) s.focus()
    }
  }
  function focusSummaryByIndex(items, idx) {
    var item = items[idx]
    if (item) {
      var s = item.querySelector(summary)
      if (s) s.focus()
    }
  }

  // Unique ID generator
  var idCounter = 0
  function generateId(prefix) {
    idCounter  = 1
    return prefix   idCounter
  }

  // Animate open/close using max-height trick (measures panel.scrollHeight)
  function setupAnimatedToggle(details, panel) {
    // Set initial styles
    panel.style.transition = max-height 300ms cubic-bezier(.2,.8,.2,1)
    panel.style.overflow = hidden
    // If closed initially, set max-height to 0
    if (!details.hasAttribute(open)) {
      panel.style.maxHeight = 0px
    } else {
      panel.style.maxHeight = panel.scrollHeight   px
    }

    details.addEventListener(toggle, function () {
      if (details.hasAttribute(open)) {
        // Opening: animate from 0 to scrollHeight
        panel.style.maxHeight = panel.scrollHeight   px
        // After transition, remove maxHeight so content can grow/shrink naturally
        window.setTimeout(function () {
          // Only clear if still open
          if (details.hasAttribute(open)) {
            panel.style.maxHeight = 
          }
        }, 310)
      } else {
        // Closing: set maxHeight to current height and then to 0
        var current = panel.scrollHeight
        panel.style.maxHeight = current   px
        // Force repaint so transition runs
        void panel.offsetHeight
        panel.style.maxHeight = 0px
      }
    })

    // If content changes dynamically while open, keep max-height cleared so it can expand
    var ro = new MutationObserver(function () {
      if (details.hasAttribute(open)) {
        panel.style.maxHeight = panel.scrollHeight   px
        window.setTimeout(function () {
          if (details.hasAttribute(open)) panel.style.maxHeight = 
        }, 310)
      }
    })
    ro.observe(panel, { childList: true, subtree: true, characterData: true })
  }

  // Auto-init on document ready for elements inside .wp-accordion containers
  function autoInit() {
    var roots = document.querySelectorAll(.wp-accordion)
    if (roots.length) {
      Array.prototype.forEach.call(roots, function (root) {
        initAccordion(root, { singleOpen: false })
      })
    } else {
      // If no container, init the entire document
      initAccordion(document, { singleOpen: false })
    }
  }

  if (document.readyState === loading) {
    document.addEventListener(DOMContentLoaded, autoInit)
  } else {
    autoInit()
  }

  // Expose a global method to initialize programmatically:
  window.wpAccessibleAccordion = {
    init: initAccordion
  }
})()

Example: Single-open accordion mode

To enable single-open behavior (only one panel open at a time), call the initializer with the singleOpen option. If you are using the auto-init above, you can instead manually initialize a container.

// Example when you want single-open and you have your markup inside a parent with class my-accordion
document.addEventListener(DOMContentLoaded, function () {
  var container = document.querySelector(.my-accordion)
  if (container) {
    wpAccessibleAccordion.init(container, { singleOpen: true })
  }
})

Copy/paste “single file” example (HTML inline JS CSS)

For fast prototyping inside a WordPress Custom HTML block, paste the HTML markup and then enqueue the following CSS and JS inline in the same block. For production, prefer separate files enqueued by WordPress to enable caching.

ltdiv class=wp-accordiongt
  ltdetails class=wp-accordion__item id=a1gt
    ltsummary id=a1-heading aria-controls=a1-panelgtFirst sectionlt/summarygt
    ltdiv id=a1-panel role=region aria-labelledby=a1-headinggt
      ltpgtContent for the first section.lt/pgt
    lt/divgt
  lt/detailsgt

  ltdetails class=wp-accordion__item id=a2gt
    ltsummary id=a2-heading aria-controls=a2-panelgtSecond sectionlt/summarygt
    ltdiv id=a2-panel role=region aria-labelledby=a2-headinggt
      ltpgtContent for the second section.lt/pgt
    lt/divgt
  lt/detailsgt
lt/divgt

ltstylegt
/ Insert the CSS from the styling example (paste the CSS block here) /
lt/stylegt

ltscriptgt
/ Insert the JavaScript enhancement here (paste the JS block here) /
lt/scriptgt

WordPress integration: how to add this into your theme or plugin

There are two recommended ways to add the accordion to WordPress: (A) add the HTML into a Custom HTML block or a block template and enqueue separate CSS and JS files (preferred for production), or (B) include the markup in a PHP template and enqueue files from functions.php.

Enqueue CSS and JS from functions.php (theme or plugin)

Below is a standard pattern to enqueue external CSS/JS files located in your theme or plugin. It uses file modification time for cache busting (recommended for development). Adjust paths and handles as needed.


Using the markup in PHP templates or block templates

Output the same HTML structure in your theme template (for example, archive or single templates). Ensure you escape any dynamic content properly (use for titles and for panel content if pulling from post fields).

Polyfills and browser support

  • Modern browser support: details/summary has good support in modern browsers (Chrome, Edge, Firefox, Safari). For very old browsers, a polyfill might be needed.
  • Polyfill: If you must support older browsers (eg. Internet Explorer), consider a lightweight polyfill such as the one on GitHub: https://github.com/javan/details-polyfill. Integrate it only when needed.
  • Testing: Test with screen readers (NVDA Firefox or VoiceOver Safari), keyboard only, tab/shift-tab sequences, and mobile zooming/voice control.

Testing checklist (before publishing)

  • Keyboard: Can you tab to every summary and toggle it with Space/Enter? Can you navigate between summaries with Arrow Up/Down/Home/End (if implemented)?
  • Screen reader: Confirm that a screen reader announces the summary and whether it is expanded (aria-expanded). Verify the panel is accessible and labeled by the summary.
  • Focus management: If you implement single-open behavior that closes a panel programmatically, ensure focus remains predictable and visible.
  • Reduced motion: Verify animations are disabled if the OS prefers reduced motion.
  • Responsive: Confirm the accordion works well on small screens and that panels do not overflow the viewport.
  • Validation: Ensure unique element IDs on the page duplicate IDs break aria relationships.

Advanced tips and patterns

  • Lazy-loading large panel contents: If a panel contains heavy content (images, embeds), consider loading that content only when the panel opens (use the toggle event to inject or replace placeholders).
  • Analytics: Track open/close events with a data attribute or dispatch a custom event when a details toggles: element.dispatchEvent(new CustomEvent(accordion:opened, { detail: {…} }))
  • Complex keyboard patterns: If you want the full ARIA Authoring Practices composite accordion (one-button-per-header with arrow navigation), consider using role=button inside headers instead of summary — but that loses native details behavior and requires reimplementing all toggle, keyboard and focus logic.
  • Server-side rendering: When generating server-side markup (PHP or blocks), set the initial open state using the open attribute on details, and ensure aria-expanded matches it.

Common pitfalls to avoid

  • Putting links, dropdowns, inputs or other interactive controls inside the summary element (breaks semantics).
  • Not keeping aria-expanded synchronized with the element state when you programmatically open/close a details.
  • Relying solely on CSS animations without considering prefers-reduced-motion.
  • Using duplicate IDs across the page.

Summary

Using details and summary gives you a semantic, accessible base for accordions. With a few JavaScript enhancements you can provide smooth animations, keyboard navigation improvements, aria synchronization, and optional single-open behavior. For production use in WordPress, enqueue optimized CSS/JS files from your theme or plugin and test thoroughly with keyboard and assistive technologies before publishing.

Useful references



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *