Open notifio.app/help in a browser, do not click anything, and ask the page what it contains:
> [...document.querySelectorAll('button[aria-expanded]')].map(b => b.getAttribute('aria-expanded'))
["false", "false", "false", "false", "false", "false"]
> document.body.innerText.includes("Notifio runs in the background")
false
Six questions, six closed, and the text of the first answer is not on the page. Click the first question and it is:
> document.body.innerText.includes("Notifio runs in the background")
true
Now do the same on notifio.app/alerts/kamernet, which renders its four questions a completely different way:
> const d = [...document.querySelectorAll('details')]
> d.length, d.some(x => x.open)
4, false
> d[0].innerText.length // what a human can see
50
> d[0].textContent.length > 80 // what is actually in the DOM
true
Four closed questions, and every answer present in the document anyway. Same site, same visual design, two different mechanisms, and only one of them was a decision rather than an accident of which file got written first.
The hand-rolled one
function FaqItem({ q, a }: { q: string; a: string }) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-[#1e1e1e] pb-6">
<button
onClick={() => setOpen((o) => !o)}
className="flex w-full items-start justify-between gap-4 text-left"
aria-expanded={open}
>
<span className="text-[14px] font-semibold text-[#e5e5e5]">{q}</span>
<span
className="mt-0.5 shrink-0 text-[#525252] transition-transform duration-200"
style={{ transform: open ? "rotate(45deg)" : "rotate(0deg)" }}
aria-hidden
>
+
</span>
</button>
{open && (
<p className="mt-3 text-[14px] text-[#a3a3a3] leading-relaxed">{a}</p>
)}
</div>
);
}
{open && <p>} is the line that removes the answer from the document. Not hidden, not display: none, not a collapsed height. The paragraph does not exist until the state flips.
The native one, which is the rest of the site
{site.faqs.map((item) => (
<details key={item.q} className="group py-5">
<summary className="flex cursor-pointer items-center justify-between gap-4 ... marker:content-none list-none">
{item.q}
<svg className="h-4 w-4 flex-shrink-0 text-[#525252] transition-transform group-open:rotate-45" ...>
<path d="M12 5v14M5 12h14" />
</svg>
</summary>
<p className="mt-3 text-[13px] leading-relaxed text-[#737373]">{item.a}</p>
</details>
))}
Same plus sign, same 45 degree rotation when open, except the rotation is group-open:rotate-45 driven by the element's own open state instead of a React style object, and there is no useState, no click handler and no client component boundary. The alert pages, the comparison pages, the guides, the audience pages and the home page all use this.
Twenty-nine pages, if you count them: fifteen per-portal alert pages, five comparison pages, five guides, three audience pages, plus the home page. One page, the help page, does it with React state.
What unmounting actually costs
Three things, in increasing order of how much I care.
Find in page stops working. Somebody who presses Cmd+F on the help page and types "60 seconds" will not match the answer that contains it, because that text is not in the document at all. With <details> the text is in the document, which is the precondition for a browser being able to reveal a closed section for a find. A support page is exactly the kind of page people search rather than read, so this is the cost that bites.
No transition is possible. You cannot animate the height of an element that does not exist yet. The plus sign rotates smoothly and the answer appears instantaneously, which is a small visual inconsistency that only exists because of the mechanism.
It needs to be a client component. The accordion is "use client" purely because of six booleans, which drags the FAQ data into the client bundle on a page that would otherwise be entirely static markup.
The accessibility part is better than I expected
aria-expanded is on the button and flips correctly, which is the attribute that matters most: a screen reader announces the question as a collapsed or expanded button rather than as unlabelled text.
There is no aria-controls, which the ARIA authoring practices suggest pairing with it. In practice its support has always been patchy, and because the answer is the immediately following sibling, the reading order is already correct. If I were wiring this up from scratch today I would still add the id and the attribute, because it costs one line.
The plus sign carries aria-hidden, so the button's accessible name is the question text and nothing else. Without that, the name would read as the question followed by a stray "plus", which is the kind of detail nobody notices until they hear it.
The native <details> version gets all of this for free, plus keyboard behaviour nobody had to write.
Both versions ship the answers to crawlers regardless
The help page's structured data is generated on the server from the same array the accordion renders:
const faqSchema = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: FAQ_ITEMS.map(({ q, a }) => ({
"@type": "Question",
name: q,
acceptedAnswer: { "@type": "Answer", text: a },
})),
};
Which produces the slightly odd situation we started with. On first paint, the help page's visible DOM contains zero answers and its JSON-LD contains all six:
> [...document.querySelectorAll('script[type="application/ld+json"]')].map(s => JSON.parse(s.textContent)["@type"])
["Organization", "FAQPage"]
The alert pages do the same thing through a graph node, alongside their breadcrumbs and the application markup:
> // on /alerts/kamernet
["BreadcrumbList", "SoftwareApplication", "FAQPage"]
One array, two consumers, no second copy of the text. The thing I would never do is write the answers out twice, once in the markup and once in the schema, which turns every copy edit into two edits and one of them eventually gets skipped. We already have the near miss on record: three of our six FAQ answers disagreed with the code at one point, and the array shipping them to Google is what made that worth fixing quickly.
And the rich result it was written for no longer exists
While checking the hidden-content rules for this post, I went to Google's FAQPage documentation. It redirects:
https://developers.google.com/search/docs/appearance/structured-data/faqpage
→ https://developers.google.com/search/updates#removing-faq-rich-result
The entry it lands on says: "Removed documentation for the FAQ rich result feature. Why: The FAQ rich result feature is no longer shown in Google Search results, as announced in the changelog entry in May 2026." The search gallery, which used to list every supported structured data type, has no FAQ entry left in it at all.
So the markup above buys us nothing in Google Search today. We are keeping it anyway, for two reasons that are both worth being explicit about rather than pretending the rich result is still coming back. The first is that it costs nothing: the array already exists, the serialisation is four lines, and the output is around a kilobyte of JSON on pages that are static anyway. The second is that Google Search is not the only thing reading pages now, and a question-and-answer pair expressed as data rather than as styling is the most machine-legible form the same content can take.
What I would not do is start writing FAQ sections for the markup's sake. Ours exist because people actually ask those six things, which is also why the answers are allowed to be dull.
What I would change
Delete the hand-rolled accordion and render the help page's FAQ with <details>, like the other twenty-nine pages. It removes a client component, restores find-in-page on the one page where find-in-page matters most, and makes the whole site's FAQ one mechanism instead of two.
The reason it has not happened yet is the honest one: the page works, nobody has complained, and the thing I know about the help FAQ is that its answers drifting away from the code is a much more expensive bug than its answers being unfindable by Cmd+F. Fixing the mechanism is a nice afternoon. Keeping the six answers true is the actual job.
Top comments (0)