An accessible, animated FAQ accordion built for both visitors and search engines. It handles the interactive question/answer UI on the page while quietly generating the structured data search engines look for, so you’re not maintaining two separate systems for the same content.
Use it for support pages, product FAQs, pricing page objections, or anywhere you’ve got a list of questions worth answering upfront. It works equally well with hand-entered content or FAQs pulled dynamically from a CPT, so it fits whether you’re building a one-off page or a repeatable pattern across dozens of posts.
Drop it into any page and it automatically combines every FAQ instance into a single FAQPage JSON-LD schema, so you get rich snippet eligibility in search results without touching a separate SEO plugin field.
Using the Component
Add the component wherever you want a FAQ block, then choose a data source, and optionally give it a title. Each instance you add is completely independent, so a general FAQ section and a product-specific one can sit on the same page with totally different settings, one open by default, one closed, one restricted to one-at-a-time, one allowing multiple open, without stepping on each other.
Props
| Prop | Type | Values / Notes |
|---|---|---|
source |
Select | "static" / "loop" |
faqs |
Repeater | Each item has question and answer subfield |
faqsLoop |
Loop | Dynamic data loop; items must expose faq_data.question / faq_data.answer via the normalization filter |
title.hasTitle |
Boolean | Whether to show the title above the accordion. |
title.text |
String | The eyebrow title text, only shown if hasTitle is true. |
title.element |
Select | h2 / h3 |
settings.openFirstItem |
Boolean | First item starts open, no animation |
settings.oneAtATime |
Boolean | Opening one item closes others in that instance |
classes |
Class | Custom classes added to the component’s root element |
settings.openFirstItem and settings.oneAtATime are read fresh every time the component initializes, so if you’re testing different configurations in the Etch editor, toggling either prop updates the live behavior right away without needing a page reload.
Choosing a Source
props.source controls where the FAQ items come from, either "static" or "loop". Both render identically on the frontend and feed into the same schema output, the only difference is where the question and answer text comes from.
Static uses props.faqs, a repeater of { question, answer } items you enter directly on the component. question is plain text, answer is a text field. Rich text formatting on the Answer field requires the Etch Enhancer plugin, without it the field behaves as plain text.
Loop uses props.faqsLoop, a dynamic data loop. Each item is expected to expose item.faq_data.question and item.faq_data.answer, a normalized shape rather than whatever field names your actual post type uses. Since your FAQ fields are probably named something else depending on your post type or field group, you’ll need a small PHP filter to normalize the field names before Etch sees them. This keeps the component itself source-agnostic instead of hardcoding it to one specific post type.
Connecting a dynamic data source
Since the loop mode always reads from item.faq_data.question and item.faq_data.answer, and your FAQ fields are probably named something else depending on your post type or field group, you’ll need a small PHP filter to normalize the field names before Etch sees them. This keeps the component itself source-agnostic instead of hardcoding it to one specific post type.
The example below is just that, an example. Your exact setup will depend on how your source data is structured (ACF fields, a different plugin, custom meta, and so on). The main thing that matters is that however you pull your data, you normalize it into this shape:
$data['faq_data'] = [
'question' => get_field($question_field, $post_id) ?: '',
'answer' => get_field($answer_field, $post_id) ?: '',
];
Our example below covers two common setups. The faq post type uses ACF fields, faq_question and faq_answer. The help_article post type instead uses the post’s own title and content, no ACF fields needed, since question pulls from get_the_title() and answer pulls from get_the_content(). Add this to your theme or a code snippets plugin, and swap in the post types and field keys that match your own setup, adding another row to the map for each additional post type you want the loop to support:
add_filter('etch/dynamic_data/post', function ($data, $post_id) {
$post_type = get_post_type($post_id);
// This example using an "FAQ" post type using dedicated ACF fields for the Q & A
if ($post_type === 'faq') {
if (function_exists('get_field')) {
$data['faq_data'] = [
'question' => get_field('faq_question', $post_id) ?: '',
'answer' => get_field('faq_answer', $post_id) ?: '',
];
}
}
// This example for a "help_article" post type doesn't use ACF at all.
// The question comes from the post title, answer comes from the post content.
if ($post_type === 'help_article') {
$data['faq_data'] = [
'question' => get_the_title($post_id) ?: '',
'answer' => get_the_content(null, false, $post_id) ?: '',
];
}
return $data;
}, 10, 2);
Once that’s in place, point props.faqsLoop at your post type’s loop and the component will pick up faq_data.question and faq_data.answer automatically. If you want to support another post type later, just add another if block for that post type rather than editing the component itself, which keeps it flexible instead of locking it to one source.
SEO/AEO Schema
FAQ schema had a rocky few years. Google scaled back FAQ rich results in 2023 and fully retired the visible dropdown from search in 2026, so the SERP payoff that made FAQ markup popular has mostly disappeared. But the underlying data isn’t useless. Google still parses FAQ structured data to understand pages, just without showing the rich result, and with AI answer engines increasingly pulling from clearly structured Q&A content, well-marked-up FAQ sections matter again even without the visual snippet.
This component handles that structured data for you automatically. Drop it into any page and it combines every FAQ instance into a single FAQPage JSON-LD schema (no separate SEO plugin field needed). Each instance stays independent for keyboard nav and open/close state, but all instances on a page still merge into that one combined schema, built from the data-faq-question / data-faq-answer attributes on each item. It’s only injected on the live frontend, never in the Etch or Gutenberg editorFAQ schema had a rocky few years. Google scaled back FAQ rich results in 2023 and fully retired the visible dropdown from search in 2026, so the SERP payoff that made FAQ markup popular has mostly disappeared. But the underlying data isn’t useless. Google still parses FAQ structured data to understand pages, just without showing the rich result, and with AI answer engines increasingly pulling from clearly structured Q&A content, well-marked-up FAQ sections matter again even without the visual snippet.
This component handles that structured data for you automatically. Drop it into any page and it combines every FAQ instance into a single FAQPage JSON-LD schema, no separate SEO plugin field needed. Each instance stays independent for keyboard nav and open/close state, but all instances on a page still merge into that one combined schema, built from the data-faq-question / data-faq-answer attributes on each item. It’s only injected on the live frontend, never in the Etch or Gutenberg editor.
How to Customize
Passing arguments to your loop
When using the Loop source option, you can pass arguments (like item count or category) into your loop by editing the loop line in the component.
1. Define arguments in your loop
Your loop needs to accept the arguments you want to pass. Give an argument a default with ?? so the component still works when that value is left blank:
$query_args = [
'post_type' => 'faq',
'posts_per_page' => $count ?? -1,
'post_status' => 'publish',
'tax_query' => [
[
'taxonomy' => 'faq_category',
'field' => 'term_id',
'terms' => $term_id,
],
],
];
2. Add props for each argument
Open the FAQ component in the Component Editor and add a Text prop for each argument. For example:
| Label | Key |
|---|---|
| Item Count | itemCount |
| FAQ Category | questionCategory |
3. Pass the props into the loop
Find the loop line inside the Loop Source block:
{#loop props.faqsLoop as item}
Add your arguments:
{#loop props.faqsLoop($count: props.itemCount.toInt(), $term_id: props.questionCategory.toInt()) as item}
.toInt() converts the Text prop value to a number, which most query arguments expect.
4. Set the values
Select your loop in the Loop prop as normal, then fill in the new props on the component instance.
Filtering by category
The $term_id argument has no sensible “show everything” default, so if your loop includes the tax_query above, set a category on every instance. If you also need an unfiltered FAQ list, create a second loop without the tax_query and select that one instead.
Styling
SN FAQs shares its styling with SN Accordion and SN Advanced Accordion. Each part carries two classes: a shared sn-accordion* class that holds all the CSS, and its own sn-faq* class, which has no styles and exists for the component’s script and for scoping.
- To change every accordion on the site, edit the shared
sn-accordion*rule. - To change only the FAQs, scope your rule under
.sn-faq(e.g..sn-faq .sn-accordion__answer-inner { … }) rather than editing the shared rule.
| Element | Shared styling class | FAQ class |
|---|---|---|
| The outer container | .sn-accordion-wrapper |
.sn-faq-wrapper |
| The optional title above the accordion | .sn-accordion__heading |
.sn-faq__heading |
| The accordion itself | .sn-accordion |
.sn-faq |
The <div> wrapping all items |
.sn-accordion__list |
.sn-faq__list |
| An individual question/answer pair | .sn-accordion__item |
.sn-faq__item |
The heading wrapping the trigger button (h3 by default; set it under Settings → Item Heading Level) |
.sn-accordion__question |
.sn-faq__question |
| The clickable button | .sn-accordion__trigger |
.sn-faq__trigger |
| The visible question text inside the trigger | .sn-accordion__question-text |
.sn-faq__question-text |
| The circular icon wrapper next to the question text | .sn-accordion__icon-circle |
.sn-faq__icon-circle |
| The vertical line of the plus icon, which collapses into a minus on open | .sn-accordion__icon-vertical |
.sn-faq__icon-vertical |
The answer panel (<div role="region">, labelled by its question) |
.sn-accordion__answer |
.sn-faq__answer |
| The inner wrapper of the answer content | .sn-accordion__answer-inner |
.sn-faq__answer-inner |
Classes prop: any classes added here are appended to the .sn-faq element, so you can add your own instance-specific classes without editing the component.
Fonts: set font changes on .sn-accordion__question-text, not on the trigger. The site’s global button rule overrides font-family on plain buttons, so font styles on the trigger won’t stick.
States: the trigger is a real <button>, and the panel’s closed state uses the hidden attribute rather than just CSS, so you don’t need to write your own show/hide logic. Style the states you care about (hover, focus, aria-expanded="true" on the trigger, and so on) directly off these classes.
Schema: the FAQPage schema is built from the data-faq-question and data-faq-answer attributes on each item, not from the visible markup. Restyling or changing the heading level won’t affect it.
Accessibility
The accordion is built with accessibility first:
- Real interactive elements: each trigger is a native
<button>, not a styled<div>, so it’s keyboard-focusable and works with assistive tech out of the box. - State communicated via ARIA: triggers use
aria-expandedto reflect open/closed state, andaria-controlsto associate each trigger with its corresponding answer panel. - True hide/show, not just visual: closed answer panels use the
hiddenattribute rather than relying on CSS alone, so screen readers won’t announce content that’s visually collapsed. - Motion sensitivity respected: if a user has
prefers-reduced-motionenabled, or their browser doesn’t supportelement.animate, the open/close animation is skipped and the panel shows or hides instantly instead.
Keyboard Navigation
Each instance supports full keyboard navigation on its own, so users can move through triggers with the arrow keys, jump to the first or last item, and the behavior stays scoped to whichever FAQ block they’re currently in.
| Key | Action |
|---|---|
↓ |
Focus next trigger |
↑ |
Focus previous trigger |
Home |
Focus first trigger |
End |
Focus last trigger |
Debugging
If something’s not showing up as expected, open your browser console and look for messages prefixed with FAQ:.
| Message | Meaning |
|---|---|
No [data-faq-component] elements found on this page. |
The wrapper markup isn’t present, check the HTML actually rendered. |
No .sn-faq__trigger elements found. |
An instance exists but has no items, check props.faqs or props.faqsLoop isn’t empty. |
Could not find answer element with id "..." |
A trigger’s aria-controls doesn’t match any .sn-faq__answer id, usually a slug mismatch. |
Skipping item with missing question or answer data. |
That item won’t appear in the schema, check data-faq-question / data-faq-answer are populated. |
No valid FAQ items to build schema from. |
None of the items had usable data, schema was not injected. |
To verify schema on the live site, open DevTools → Elements → search <head> for <script id="sn-faq-schema-ld-json"> and confirm the JSON matches your visible FAQ content.