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:
- Synchronizes aria-expanded on the summary with the open state of the details.
- Optional single-open mode (close other details when one opens).
- Accessible animated opening/closing using max-height calculations while respecting prefers-reduced-motion.
- Keyboard navigation: Arrow Up/Down/Home/End move focus between summary controls.
- 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 🙂 |