Custom Accordion FAQ Shortcode Plugin

Lorem Ipsum is simply dummy text of the printing and typesetting industry.

It has roots in a piece of classical Latin literature from 45 BC.

It is a long established fact that a reader will be distracted by readable content.

  • This is a list item
  • This is also a list item
  • And this

There are many variations of passages of Lorem Ipsum available.

Learn more

How it works

The basic logic

How it’s built

The accordion is split between PHP (shortcodes + data handling) and JavaScript (progressive enhancement + accessibility wiring).

1. register the shortcodes

On init, we tell WordPress about our shortcodes. The parent faq_accordion acts as the container, while each faq_item represents a single Q&A pair.

PHP
// register shortcodes for [faq_accordion] and [faq_item] on WordPress initiation
add_action('init', function () {
	add_shortcode('faq_accordion', 'faq_accordion_render_shortcode');
	add_shortcode('faq_item', 'faq_item_render_shortcode'); // [faq_item q="..."]Answer[/faq_item]
});
2. PARSE IN PHP

Each faq_item passes its question q and answer (the content between the tags) up to the parent faq_accordion. In the child’s callback we collect that data and store it in an array:

PHP
function faq_item_render_shortcode($atts, $content = null)
{
	$atts = shortcode_atts(['q' => ''], $atts, 'faq_item');

	$q = (string) ($atts['q'] ?? '');
	$a = (string) ($content ?? '');

	// hand this item up to the parent
	faq_items_push($q, $a);
  //...// 
}

The parent shortcode removes unwanted <p> / <br> tags, runs do_shortcode() to render children, and passes the parsed items to JS via wp_localize_script():

PHP
wp_localize_script('faq-accordion', 'FAQAccordionData', [
    'items'    => $items,
    'selector' => '.faq-accordion',
    'atts'     => $atts,
]);

This way the PHP array is available in JS without inline scripts.

3. RENDER MARKUP

PHP outputs a simple base structure:

HTML
<div class="faq-accordion__item">
		<h3 class="faq-accordion__heading">
			<button class="faq-accordion__button">
				<span class="faq-accordion__icon"></span>
				<span class="faq-accordion__question"><?php echo $q; ?></span>
			</button>
		</h3>
		<section class="faq-accordion__panel" role="region">
			<div class="faq-accordion__answer">
				<p><?php echo $a; ?></p>
			</div>
		</section>
	</div>

This is semantic, accessible, and works as plain HTML.

4. ENHANCE WITH JAVASCRIPT

When the script runs, it first pulls in the localized PHP array (FAQAccordionData). If the accordion root element doesn’t exist, the script stops immediately:

JavaScript
	// Define the array item data pulled from PHP
		const data = window.FAQAccordionData || {
			items: [],
			selector: ".faq-accordion",
		};
		const root = document.querySelector(data.selector);
		// Ensure root exists, if not, end JS.
		if (!root) return;

Next, we create an array of .faq-accordion__item elements and a debugging cross-check that the number of PHP items matches the DOM items.

JavaScript
// Define the array from PHP
		const items = Array.from(root.querySelectorAll(".faq-accordion__item"));
		// Check to make sure JS / PHP array lengths match up
		if (data.items && data.items.length && data.items.length !== items.length) {
			console.warn("FAQ count mismatch", {
				php: data.items.length,
				dom: items.length,
			});

For each accordion item, JavaScript generates unique IDs for the button (qID) and panel (aID). These IDs are wired up with:

  • aria-controls (button → panel)
  • aria-labelledby (panel → button)
  • aria-expanded and hidden (to manage open/close state)

This ensures accessibility and proper screen reader support.

Optional functionality is controlled via shortcode classes (passed from PHP). These are tokenised and referenced later in the toggle logic.

Finally, the event listener handles open/close. If close-on-open is active, it closes all other panels before opening the current one.

JavaScript
// Setup additional class behaviours

		const tokens = (data.atts.class || "").split(/\s+/).filter(Boolean);
		const firstOpen = tokens.includes("first-item-open");
		const closeOnOpen = tokens.includes("close-on-open");
		
		
	// Reference closeOnOpen on event listener for accordion items:
	
	// Toggle behavior for accordion items
		root.addEventListener("click", function (e) {
			const button = e.target.closest(".faq-accordion__button");
			if (!button || !root.contains(button)) return;

			const panelEl = document.getElementById(
				button.getAttribute("aria-controls")
			);
			const expanded = button.getAttribute("aria-expanded") === "true";

			// If it contains the class 'close-on-open' hide the panel after clicking on another item
			if (closeOnOpen && !expanded) {
				items.forEach((el) => {
					const otherBtn = el.querySelector(".faq-accordion__button");
					const otherPanel = el.querySelector(".faq-accordion__panel");
					if (otherBtn && otherPanel && otherBtn !== button) {
						otherBtn.setAttribute("aria-expanded", "false");
						otherBtn.classList.remove("is-open");
						otherPanel.hidden = true;
					}
				});
			}
			button.setAttribute("aria-expanded", String(!expanded));
			button.classList.toggle("is-open", !expanded);
			if (panelEl) panelEl.hidden = expanded;
		});
5. STYLE WITH CSS

The accordion toggle icon is built entirely with CSS, using a button that contains a .faq-accordion__icon span. Two pseudo-elements (:before and :after) form the horizontal and vertical lines of a plus symbol. When the accordion is opened, the horizontal line fades out and the vertical rotates into a minus, giving a smooth transition between the two states without needing extra markup or images.