FAQ accordion
Questions and plain-text answers in a hairline-divided accordion, with the section heading in a left rail on wide screens. Visitors open the answers they need with disclosure buttons.
cmp_faq_accordion_001 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_faq_accordion_001 version 1.1.3 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Neutral
- Version
- 1.1.3
- Digest
Full digest
sha256-a3abdcfabe9332fef45c5879c12dfc8f40ee77ba2d413f39ec34a51d77323353
<script module lang="ts">
export interface FaqItem {
question: string;
/** Plain text answer. Line breaks are preserved. */
answer: string;
}
</script>
<script lang="ts">
interface Props {
items: FaqItem[];
title: string;
/** Short uppercase label above the title. */
eyebrow?: string;
description?: string;
headingLevel?: 2 | 3 | 4 | 5;
multiple?: boolean;
defaultOpen?: number[];
}
let {
items,
title,
eyebrow,
description,
headingLevel = 2,
multiple = false,
defaultOpen = []
}: Props = $props();
const uid = $props.id();
const sectionHeading = $derived(`h${headingLevel}`);
const itemHeading = $derived(`h${headingLevel + 1}`);
/*
* Find-in-page can reveal a closed answer before this component hydrates, when no
* beforematch handler exists yet: the browser removes hidden and that event is gone.
* Hydration would hide the panel again, so read the server markup first. On the server
* and on a fresh mount there is nothing to read, and this returns nothing.
*/
function revealedBeforeHydration(): number[] {
if (typeof document === 'undefined') return [];
return items.flatMap((_, index) => {
const panel = document.getElementById(`${uid}-panel-${index}`);
return panel && !panel.hasAttribute('hidden') && !defaultOpen.includes(index) ? [index] : [];
});
}
/** Valid, unique indexes only; single mode keeps one, preferring a revealed answer. */
function initialOpen(): number[] {
const valid = defaultOpen.filter((i) => Number.isInteger(i) && i >= 0 && i < items.length);
const revealed = revealedBeforeHydration();
return multiple ? [...new Set([...valid, ...revealed])] : [...revealed, ...valid].slice(0, 1);
}
let open = $state<number[]>(initialOpen());
const triggers: HTMLButtonElement[] = [];
function setOpen(index: number, expanded: boolean) {
if (expanded === open.includes(index)) return;
if (!expanded) open = open.filter((i) => i !== index);
else open = multiple ? [...open, index] : [index];
}
/** Up, Down, Home and End move between questions, per the APG accordion pattern. */
function onTriggerKeydown(event: KeyboardEvent, index: number) {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
return;
const last = items.length - 1;
let next: number;
if (event.key === 'ArrowDown') next = index === last ? 0 : index + 1;
else if (event.key === 'ArrowUp') next = index === 0 ? last : index - 1;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = last;
else return;
event.preventDefault();
triggers[next]?.focus();
}
</script>
<section
class="faq-accordion px-4 py-16 sm:px-6 sm:py-24 lg:px-8 lg:py-32"
aria-labelledby="{uid}-title"
>
<div
class="mx-auto grid max-w-6xl grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] lg:gap-16"
>
<!-- With no questions the introduction is the whole section, so it spans both columns. -->
<div class={items.length === 0 ? 'lg:col-span-2' : undefined}>
{#if eyebrow}
<p
class="mb-2 text-xs leading-none font-medium tracking-[0.06em] text-balance wrap-anywhere text-(--_muted) uppercase rtl:tracking-normal"
>
{eyebrow}
</p>
{/if}
<svelte:element
this={sectionHeading}
id="{uid}-title"
class="text-3xl leading-[1.15] font-semibold tracking-[-0.02em] text-balance wrap-anywhere text-(--_ink) rtl:tracking-normal"
>
{title}
</svelte:element>
{#if description}
<p class="mt-4 max-w-md text-base leading-[1.6] text-pretty wrap-anywhere text-(--_muted)">
{description}
</p>
{/if}
</div>
{#if items.length > 0}
<div class="divide-y divide-(--_hairline) border-y border-(--_hairline)">
{#each items as item, index (index)}
{@const expanded = open.includes(index)}
<div>
<svelte:element this={itemHeading} class="m-0">
<button
bind:this={triggers[index]}
type="button"
id="{uid}-trigger-{index}"
aria-expanded={expanded}
aria-controls="{uid}-panel-{index}"
onclick={() => setOpen(index, !expanded)}
onkeydown={(event) => onTriggerKeydown(event, index)}
class={[
'faq-accordion__trigger group flex w-full items-start justify-between gap-6 rounded pt-5 text-start text-lg leading-[1.3] font-medium tracking-[-0.015em] text-(--_ink) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent) rtl:tracking-normal',
expanded ? 'pb-2' : 'pb-5'
]}
>
<span class="min-w-0 wrap-anywhere">{item.question}</span>
<span
class="faq-accordion__glyph flex h-[1.3em] shrink-0 items-center transition-[translate] duration-80 ease-(--_ease) group-active:translate-y-px motion-reduce:transition-none motion-reduce:group-active:translate-y-0"
aria-hidden="true"
>
<svg
class="faq-accordion__chevron size-5 text-(--_muted)"
viewBox="0 0 20 20"
fill="none"
>
<path
d="M5 7.5l5 5 5-5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</button>
</svelte:element>
<!-- until-found keeps closed answers searchable with find-in-page; browsers
without it treat the value as plain hidden. -->
<div
id="{uid}-panel-{index}"
hidden={expanded ? undefined : 'until-found'}
onbeforematch={() => setOpen(index, true)}
>
<div class="pe-11 pb-5">
<p
class="max-w-md text-base leading-[1.6] text-pretty wrap-anywhere whitespace-pre-line text-(--_muted) rtl:max-w-sm"
>
{item.answer}
</p>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</section>
<style>
.faq-accordion {
--_accent: var(--faq-accordion-accent, #09090b);
--_ink: var(--faq-accordion-ink, #09090b);
--_muted: var(--faq-accordion-muted, #52525b);
--_hairline: var(--faq-accordion-hairline, rgb(0 0 0 / 0.1));
/* The state-change curve, read by the glyph's press utilities and the chevron below. */
--_ease: cubic-bezier(0.2, 0, 0, 1);
}
/*
* The chevron's states are CSS because each transitions on its own clock: closing uses the
* exit curve at 160 ms, opening the enter curve at 220 ms, colour 150 ms either way. Its hover
* and open colours live here with those transitions; open wins over hover.
*/
.faq-accordion__chevron {
transition:
transform 160ms cubic-bezier(0.4, 0, 1, 1),
color 150ms var(--_ease);
}
@media (hover: hover) {
.faq-accordion__trigger:hover .faq-accordion__chevron {
color: var(--_ink);
}
}
.faq-accordion__trigger[aria-expanded='true'] .faq-accordion__chevron {
color: var(--_accent);
transform: rotate(180deg);
transition:
transform 220ms cubic-bezier(0.16, 1, 0.3, 1),
color 150ms var(--_ease);
}
@media (prefers-reduced-motion: reduce) {
.faq-accordion__chevron,
.faq-accordion__trigger[aria-expanded='true'] .faq-accordion__chevron {
transition: color 150ms var(--_ease);
}
}
</style>
Usage#
On this pageSupply questions and plain-text answers. Open/closed state is local to each instance and is not persisted, synced to the URL or reported through callbacks. It renders no search, categories or contact form, needs no additional runtime packages beyond Svelte and Tailwind CSS, and has no backend.
- Suggested location
src/lib/components/faq-accordion-01
Limitations
- Answers are plain text; edit the source to render rich content or snippets.
- Clicks and keys open answers only after hydration.
defaultOpenanswers (the first only, unless multiple) are visible in server HTML. - Closed answers use hidden="until-found": supporting browsers reveal them through find-in-page even before hydration, and the question is marked expanded once hydrated; other browsers keep them hidden.
defaultOpenis read once at creation; changing multiple later does not collapse answers already open, and open state is tracked by item position. Remount (e.g. with{#key}) after changing multiple or reordering items.- An empty items array renders the heading and description without a list; show your own message or omit the section.
Example
<script lang="ts">
import FaqAccordion, { type FaqItem } from '$lib/components/faq-accordion-01/FaqAccordion.svelte';
const items: FaqItem[] = [
{
question: 'Can we try Halcyon before we pay?',
answer: 'Every workspace starts with a 14-day trial of the Team plan, and no card is needed to begin.'
},
{
question: 'How do we get our data out?',
answer: 'Admins can export a workspace as JSON or CSV from Settings, Export at any time.'
}
];
</script>
<FaqAccordion
eyebrow="Help centre"
title="Questions teams ask before moving to Halcyon"
description="Billing, trials, imports and access, answered by the people who run support."
{items}
defaultOpen={[0]}
/>The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Using the accordion#
Copy FaqAccordion.svelte into src/lib/components/faq-accordion-01/. The quick-start example and props reference describe its inputs. Replace the Halcyon questions and answers with your own product's.
Writing the section#
The title reads best as the question visitors bring to the page ("Questions teams ask before moving to Halcyon") rather than a label like "Frequently asked questions". Use description to say who answers and where an unanswered question should go. Keep answers to two or three sentences; they are capped at about 65 characters a line so they stay readable in the wide column.
Managing open answers#
Clicking a question, or pressing Enter or Space on its button, toggles the answer. With multiple off, opening another answer closes the previous one. Up and Down move between questions, and Home and End jump to the first and last.
defaultOpen is read once when the component is created. Changing multiple later does not collapse answers already open. State is tracked by index: remount the component with a Svelte {#key} block after reordering items or changing the interaction mode.
Before hydration#
Answers listed in defaultOpen (only the first one when multiple is off) are in the server-rendered HTML and visible straight away. Opening or closing an answer by click or keyboard waits for hydration. Closed answers are rendered with hidden="until-found": in browsers that support it, find-in-page can reveal a matching answer even before hydration, and once the component is hydrated it marks that question open. Other browsers keep a closed answer hidden.
Retoning#
The section has no background of its own, so it takes the page's. On a tinted or dark page, set the four variables on the section or any ancestor:
.pricing-page {
--faq-accordion-ink: #fafafa;
--faq-accordion-muted: #a1a1aa;
--faq-accordion-hairline: rgb(255 255 255 / 0.1);
--faq-accordion-accent: #93c5fd;
}The accent colours the focus ring and the chevron of an open question, so keep it at 3:1 against the background. Muted carries the answers and needs 4.5:1.
Right to left#
Spacing and alignment use logical properties, so under dir="rtl" the questions start on the right and the chevron moves to the left with no changes.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
items | FaqItem[] | Yes | None | Questions and plain-text answers ({ question: string; answer: string }) in display order. |
title | string | Yes | None | Section heading text. |
eyebrow | string | No | None | Optional short label above the title, set small, uppercase and muted. |
description | string | No | None | Optional introductory paragraph under the heading. |
headingLevel | 2 | 3 | 4 | 5 | No | 2 | Level of the section heading; each question is wrapped in a heading one level lower. |
multiple | boolean | No | false | When false, opening one answer closes the others. When true, answers open independently. |
defaultOpen | number[] | No | [] | Zero-based indexes of answers expanded on first render. Out-of-range and duplicate indexes are ignored; only the first valid index is used when multiple is false. |
Customization#
On this pageChange content through props and retone the section through four CSS variables: accent (which the focus ring follows), ink, muted and hairline. Spacing, widths and the split are Tailwind classes in the source.
- Content: pass items as
{ question, answer }; line breaks in answers are preserved. Write the title as the question your visitors are actually asking, and use description to say where unanswered questions go. - Headings: set
headingLevelso the section fits the page outline; questions use the next level down. - Behaviour: set multiple to allow several open answers; use
defaultOpento expand answers on load. - Accent:
--faq-accordion-accentcolours the focus ring and the chevron of an open question. The default is near-black, so the section renders monochrome until you choose a colour; keep it at 3:1 against your page background. - Text:
--faq-accordion-inksets the title and questions,--faq-accordion-mutedthe eyebrow, description, answers and resting chevron. Keep muted at 4.5:1 against the background. - Dividers:
--faq-accordion-hairlinedraws the rules between questions. It defaults to 10% black so it sits on any light surface; on a dark page use about 10% white. - Worked retone for a dark page: give the section a dark background with a class, then set
--faq-accordion-ink:#fafafa;--faq-accordion-muted:#a1a1aa;--faq-accordion-hairline: rgb(255 255 255 / 0.1);--faq-accordion-accent:#93c5fd. - Layout: from lg: the heading sits in a one-third rail beside the list; below that it sits above. Change
lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] on the inner wrapper, or remove it to keep the heading above at every width. The content column ismax-w-6xland answers are capped atmax-w-md(max-w-smunder RTL), about 65 characters a line. - Rich answers: change the answer type and replace the paragraph in the panel with your own markup or a snippet prop.
Public CSS variables
| Variable | Token |
|---|---|
--faq-accordion-accent | accent |
--faq-accordion-ink | ink |
--faq-accordion-muted | muted |
--faq-accordion-hairline | hairline |
Accessibility#
On this page- Follows the WAI-ARIA APG accordion pattern: each question is a native button inside a heading, with
aria-expandedandaria-controlspointing at its answer panel. Panels carry no region role, which the APG advises against for longer lists. - Buttons are operable with Enter and Space and appear in normal Tab order. Up and Down arrows move focus between questions and wrap at the ends; Home and End move to the first and last question. Keys held with Ctrl, Alt, Meta or Shift are left to the browser.
- Closed panels use hidden="until-found", so their content is out of the accessibility tree; find-in-page can reveal an answer, and once hydrated the component then marks that question as expanded.
- IDs are derived from
$props.id(), so several accordions on one page never collide and server and client markup match. - The chevron is decorative and hidden from assistive technology; state is conveyed by
aria-expanded. - Focus is shown with a two-pixel ring in the accent colour. If you change
--faq-accordion-accent, keep it at 3:1 against the page background. When reduced motion is requested the chevron flips without animating; only its colour transitions. - Choose
headingLevelso the section heading continues the page's outline.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.1.3 (Published) Current release · 16 September 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.