Grouped FAQ
FAQ questions split into named topics, each with a heading and native disclosures, and a topic index that jumps to each group, above the groups or in a sticky side column.
cmp_grouped_faq_01 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_grouped_faq_01 version 1.0.0 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.0.0
- Digest
Full digest
sha256-ead479feb037ac96bfc01dbf900bfc1a7afd5f779a48a49868f03305d33ba68f
<script module lang="ts">
export interface FaqQuestion {
question: string;
/** Plain text answer. Line breaks are preserved, so a blank line starts a new paragraph. */
answer: string;
/** Render this answer open, in the server HTML too. */
open?: boolean;
}
export interface FaqGroup {
/** URL-safe and unique within the section; becomes the group's anchor after the id prefix. */
id: string;
title: string;
description?: string;
items: FaqQuestion[];
}
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Topic groups in display order. Groups with no questions are skipped. */
groups: FaqGroup[];
/** Section heading. */
title: string;
/** Short uppercase label above the title. */
eyebrow?: string;
/** One or two sentences under the title. */
description?: string;
/** Section heading level; group titles use the next level and questions the one after. */
headingLevel?: 2 | 3 | 4;
/** Render the topic index. Defaults to on when three or more groups have questions. */
showTopicNav?: boolean;
/** Topic index above the groups, or in a sticky column beside them from lg. */
topicNavPlacement?: 'top' | 'sidebar';
/** Accessible name and visible label of the topic index. */
topicNavLabel?: string;
/** Prefix for every id and anchor. Defaults to a per-instance id; set it for stable links. */
idPrefix?: string;
/** Content after the last group, such as a contact link. */
footer?: Snippet;
}
let {
groups,
title,
eyebrow,
description,
headingLevel = 2,
showTopicNav,
topicNavPlacement = 'sidebar',
topicNavLabel = 'FAQ topics',
idPrefix,
footer
}: Props = $props();
const uid = $props.id();
const prefix = $derived(idPrefix || uid);
const sectionHeading = $derived(`h${headingLevel}`);
const groupHeading = $derived(`h${headingLevel + 1}`);
const questionHeading = $derived(`h${headingLevel + 2}`);
/* An empty group would be a heading with nothing under it, so it is not rendered or linked. */
const visible = $derived(groups.filter((group) => group.items.length > 0));
const navShown = $derived(visible.length > 0 && (showTopicNav ?? visible.length >= 3));
const sidebar = $derived(navShown && topicNavPlacement === 'sidebar');
/* Group anchors are prefix-id; the component's own ids use a double hyphen so a group
called "title" or "nav" cannot collide with them. */
const anchor = (group: FaqGroup) => `${prefix}-${group.id}`;
/*
* In the sticky column, mark the topic being read: the last group whose top has passed
* 30% of the viewport, the first group while it is on screen but not yet that high, or at
* the foot of the page the last one on screen. Nothing once the section has scrolled past.
* Without JavaScript the index still works; it just marks nothing.
*/
let current = $state<string | null>(null);
$effect(() => {
if (!sidebar) {
current = null;
return;
}
const ids = visible.map(anchor);
let frame = 0;
const update = () => {
frame = 0;
const line = window.innerHeight * 0.3;
const atEnd =
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2;
let next: string | null = null;
let bottom = 0;
for (const [index, id] of ids.entries()) {
const box = document.getElementById(id)?.getBoundingClientRect();
if (!box) continue;
const onScreen = box.top < window.innerHeight;
if (box.top <= line || (atEnd && onScreen) || (index === 0 && onScreen)) {
next = id;
bottom = box.bottom;
}
}
current = next && bottom > 0 ? next : null;
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(update);
};
/* Opening or closing an answer moves later groups without a scroll event. */
const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule);
for (const id of ids) {
const group = document.getElementById(id);
if (group) resize?.observe(group);
}
update();
window.addEventListener('scroll', schedule, { passive: true });
window.addEventListener('resize', schedule);
return () => {
window.removeEventListener('scroll', schedule);
window.removeEventListener('resize', schedule);
resize?.disconnect();
cancelAnimationFrame(frame);
};
});
</script>
<section
class="grouped-faq px-4 py-16 sm:px-6 sm:py-24 lg:px-8 lg:py-32"
aria-labelledby="{prefix}--title"
>
<div class="mx-auto max-w-6xl">
<div class="max-w-2xl">
{#if eyebrow}
<p
class="grouped-faq__tracked mb-2 text-xs leading-none font-medium tracking-[0.06em] text-balance wrap-anywhere text-[var(--_muted)] uppercase"
>
{eyebrow}
</p>
{/if}
<svelte:element
this={sectionHeading}
id="{prefix}--title"
class="grouped-faq__tracked text-3xl leading-[1.15] font-semibold tracking-[-0.02em] text-balance wrap-anywhere text-[var(--_ink)] sm:text-4xl"
>
{title}
</svelte:element>
{#if description}
<p
class="mt-4 max-w-lg text-base leading-[1.6] text-pretty wrap-anywhere text-[var(--_muted)] sm:text-lg"
>
{description}
</p>
{/if}
</div>
{#if visible.length > 0}
<div
class={[
'mt-12 grid grid-cols-[minmax(0,1fr)] gap-12 lg:mt-16',
sidebar && 'lg:grid-cols-[minmax(0,1fr)_minmax(0,3fr)] lg:gap-16'
]}
>
{#if navShown}
<nav
aria-labelledby="{prefix}--nav"
class={sidebar ? 'lg:sticky lg:top-[var(--_scroll-offset)] lg:self-start' : undefined}
>
<p id="{prefix}--nav" class="text-sm leading-5 font-medium text-[var(--_muted)]">
{topicNavLabel}
</p>
<ul
class={[
'mt-2 grid gap-x-5',
visible.length === 1 ? 'grid-cols-1' : 'grid-cols-2',
visible.length >= 3 && 'sm:grid-cols-3',
sidebar ? 'lg:grid-cols-1' : visible.length >= 4 && 'lg:grid-cols-4'
]}
>
{#each visible as group (group.id)}
{@const id = anchor(group)}
<li class="flex">
<a
href="#{id}"
aria-current={current === id ? 'location' : undefined}
class={[
'grouped-faq__topic flex w-full items-baseline justify-between gap-5 border-t border-[var(--_hairline)] py-3 text-base leading-6 font-medium text-[var(--_ink)] hover:border-[var(--_accent)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] aria-[current=location]:border-[var(--_accent)]',
sidebar &&
'lg:border-s lg:border-t-0 lg:ps-4 lg:text-[var(--_muted)] lg:hover:text-[var(--_ink)] lg:aria-[current=location]:text-[var(--_ink)]'
]}
>
<span class="grouped-faq__press min-w-0 text-pretty wrap-anywhere"
>{group.title}</span
>
<span class="text-sm text-[var(--_muted)] tabular-nums" aria-hidden="true">
{group.items.length}
</span>
</a>
</li>
{/each}
</ul>
</nav>
{/if}
<div>
{#each visible as group, index (group.id)}
<div
id={anchor(group)}
class={[
'grid scroll-mt-[var(--_scroll-offset)] grid-cols-[minmax(0,1fr)] border-t border-[var(--_ink)]',
index > 0 && 'mt-12 lg:mt-16',
!sidebar && 'lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] lg:gap-16'
]}
>
<div class="pt-5">
<svelte:element
this={groupHeading}
class="grouped-faq__tracked text-2xl leading-[1.2] font-semibold tracking-[-0.02em] text-balance wrap-anywhere text-[var(--_ink)]"
>
{group.title}
</svelte:element>
{#if group.description}
<p
class="mt-2 max-w-sm text-base leading-[1.6] text-pretty wrap-anywhere text-[var(--_muted)]"
>
{group.description}
</p>
{/if}
</div>
<div
class={[
'mt-5 divide-y divide-[var(--_hairline)] border-y border-[var(--_hairline)]',
!sidebar && 'lg:mt-0 lg:self-start lg:border-t-0'
]}
>
{#each group.items as item, i (i)}
<details class="grouped-faq__item group/item" open={item.open || undefined}>
<summary
class="grouped-faq__summary group/summary flex cursor-pointer list-none items-start justify-between gap-5 rounded py-5 text-lg leading-[1.3] font-medium tracking-[-0.015em] text-[var(--_ink)] group-open/item:pb-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)]"
>
<svelte:element
this={questionHeading}
class="min-w-0 text-pretty wrap-anywhere"
>
{item.question}
</svelte:element>
<span
class="grouped-faq__glyph flex h-lh shrink-0 items-center group-active/summary:translate-y-px"
aria-hidden="true"
>
<svg
class="grouped-faq__chevron size-5 text-[var(--_muted)] group-open/item:text-[var(--_accent)] group-hover/summary:text-[var(--_ink)]"
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>
</summary>
<div class="pe-12 pb-5">
<p
class="max-w-lg text-base leading-[1.6] text-pretty wrap-anywhere whitespace-pre-line text-[var(--_muted)] rtl:max-w-md"
>
{item.answer}
</p>
</div>
</details>
{/each}
</div>
</div>
{/each}
{#if footer}
<div
class={[
'mt-12 max-w-lg text-base leading-[1.6] text-pretty text-[var(--_muted)]',
!sidebar && 'lg:ms-[calc((100%-4rem)/3+4rem)]'
]}
>
{@render footer()}
</div>
{/if}
</div>
</div>
{/if}
</div>
</section>
<style>
/* Public tokens: set --grouped-faq-* on this section or any ancestor to retone it. */
.grouped-faq {
--_accent: var(--grouped-faq-accent, #18181b);
--_on-accent: var(--grouped-faq-on-accent, #ffffff);
--_ink: var(--grouped-faq-ink, #18181b);
--_muted: var(--grouped-faq-muted, #52525b);
--_hairline: var(--grouped-faq-hairline, rgb(0 0 0 / 0.1));
/* Clears a sticky site header when a topic link jumps to its group. */
--_scroll-offset: var(--grouped-faq-scroll-offset, 6rem);
interpolate-size: allow-keywords;
}
/* Arabic and Hebrew are never letter-spaced; tracked text resets under right-to-left. */
.grouped-faq__tracked:dir(rtl),
.grouped-faq__summary:dir(rtl) {
letter-spacing: 0;
}
/* Safari still draws its own disclosure triangle unless the marker is removed. */
.grouped-faq__summary::-webkit-details-marker {
display: none;
}
.grouped-faq__topic {
transition:
color 150ms cubic-bezier(0.2, 0, 0, 1),
border-color 150ms cubic-bezier(0.2, 0, 0, 1);
}
/*
* The answer grows to its height where ::details-content and interpolate-size exist, and
* opens at once elsewhere. Closing uses the exit curve at 160 ms; opening the enter curve
* at 220 ms.
*/
.grouped-faq__item::details-content {
block-size: 0;
overflow-y: clip;
transition:
block-size 160ms cubic-bezier(0.4, 0, 1, 1),
content-visibility 160ms cubic-bezier(0.4, 0, 1, 1) allow-discrete;
}
.grouped-faq__item[open]::details-content {
block-size: auto;
transition:
block-size 220ms cubic-bezier(0.16, 1, 0.3, 1),
content-visibility 220ms cubic-bezier(0.16, 1, 0.3, 1) allow-discrete;
}
/* The press nudge uses the 80 ms press duration: the chevron of a question, the title of
a topic link. */
.grouped-faq__glyph,
.grouped-faq__press {
transition: translate 80ms cubic-bezier(0.2, 0, 0, 1);
}
.grouped-faq__topic:active .grouped-faq__press {
translate: 0 1px;
}
.grouped-faq__chevron {
transition:
transform 160ms cubic-bezier(0.4, 0, 1, 1),
color 150ms cubic-bezier(0.2, 0, 0, 1);
}
.grouped-faq__item[open] .grouped-faq__chevron {
transform: rotate(180deg);
transition:
transform 220ms cubic-bezier(0.16, 1, 0.3, 1),
color 150ms cubic-bezier(0.2, 0, 0, 1);
}
@media (prefers-reduced-motion: reduce) {
.grouped-faq__item::details-content,
.grouped-faq__item[open]::details-content {
transition: none;
}
.grouped-faq__glyph,
.grouped-faq__press,
.grouped-faq__topic:active .grouped-faq__press {
transition: none;
translate: none;
}
.grouped-faq__chevron,
.grouped-faq__item[open] .grouped-faq__chevron {
transition: color 150ms cubic-bezier(0.2, 0, 0, 1);
}
}
</style>
Usage#
On this pageSupply topic groups of questions and plain-text answers. Answers are native disclosures, so the section works without JavaScript; open state is not persisted, synced to the URL or reported. It does not search or filter questions, render rich-text answers or emit FAQPage structured data, and needs no packages beyond Svelte and Tailwind CSS.
- Suggested location
src/lib/components/grouped-faq-01
Limitations
- Answers are plain text with line breaks preserved; edit the source to render links, lists or snippets inside an answer.
- Groups with no questions are not rendered. When no group has questions, only the eyebrow, title and description render; show your own message or omit the section.
- The current-topic mark in the sticky index needs JavaScript; without it the index links still jump to their groups.
- Group anchors are prefixed with a per-instance id by default, which is stable for a given page but not across layouts; set
idPrefixfor links that must survive from other pages. - The answer grows open where the browser supports ::details-content and interpolate-size, and opens at once elsewhere.
Example
<script lang="ts">
import GroupedFaq, { type FaqGroup } from '$lib/components/grouped-faq-01/GroupedFaq.svelte';
const groups: FaqGroup[] = [
{
id: 'billing',
title: 'Plans and billing',
description: 'Seats, invoices and changing plan.',
items: [
{
question: 'How does per-seat billing work?',
answer: 'You pay for each member with edit access, billed monthly or yearly.'
}
]
},
{
id: 'trials',
title: 'Trials and switching',
items: [
{
question: 'Can we try Halcyon before we pay?',
answer: 'Every workspace starts with a 14-day trial of the Team plan.'
}
]
},
{
id: 'access',
title: 'Access and security',
items: [
{
question: 'Who can see a project?',
answer: 'Projects are private to the members you add.'
}
]
}
];
</script>
<GroupedFaq
eyebrow="Help centre"
title="Answers, sorted by what you came to ask"
description="Plans, trials, access and exports."
{groups}
idPrefix="faq"
>
{#snippet footer()}
Still stuck? <a href="/contact">Write to support</a>.
{/snippet}
</GroupedFaq>Using the grouped FAQ#
Copy GroupedFaq.svelte into src/lib/components/grouped-faq-01/. The quick-start example and props reference describe its inputs. Replace the Halcyon topics with your own.
Writing the groups#
Name topics the way visitors think about their problem ("Plans and billing", "Appointments", "Orders and delivery"), not the way your team is organised. Three to six groups of three to five questions each read best; a group of ten is two groups. The one-line group description sits under the topic title and says what belongs there.
Set open: true on a question to render its answer expanded, in the server HTML too; everything else starts collapsed.
Each group's id becomes its anchor, so keep it short, lowercase and URL-safe (billing, first-visit). Set idPrefix when other pages link in, so the anchor is stable: with idPrefix="faq" the billing group is #faq-billing. Without it the prefix is a per-instance id, which keeps two sections on one page apart.
The topic index#
The index appears once three or more groups have questions; set showTopicNav to override that. With topicNavPlacement="sidebar" (the default) it becomes a sticky column beside the groups from the lg breakpoint, and the topic being read is marked once the page has hydrated. Below lg, and with topicNavPlacement="top", it sits above the groups and wraps onto as many rows as it needs. With the index on top, each topic sits in a rail beside its questions at lg.
Sticky headers#
A jumped-to group lands --grouped-faq-scroll-offset below the top of the viewport (6rem by default), and the sticky index sits at the same offset. Set it to your header's height plus about 2rem:
.help-page {
--grouped-faq-scroll-offset: 7rem;
}Empty content#
Groups with no questions render nothing: no heading and no index link. If every group is empty, the section renders its eyebrow, title and description only, so either pass a description that says where to get help or leave the section out.
Retoning#
The section has no background of its own. On a tinted or dark page, set the variables on the section or any ancestor:
.support-page {
--grouped-faq-ink: #fafafa;
--grouped-faq-muted: #a1a1aa;
--grouped-faq-hairline: rgb(255 255 255 / 0.1);
--grouped-faq-accent: #93c5fd;
}Structured data#
The component does not emit FAQPage JSON-LD. If you want it, build it from the same groups array in your page's <svelte:head>.
Right to left#
Spacing, alignment and the index rules use logical properties, so under dir="rtl" topics start on the right and the chevron moves to the left with no changes. Letter-spacing resets to zero in right-to-left text.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
groups | FaqGroup[] | Yes | None | Topic groups in display order: { id: string; title: string; description?: string; items: { question: string; answer: string; open?: boolean }[] }. id must be URL-safe and unique in the section; groups with no items are skipped; open renders that answer expanded. |
title | string | Yes | None | Section heading text. |
eyebrow | string | No | None | Short label above the title, set small, uppercase and muted. |
description | string | No | None | Introductory paragraph under the heading. |
headingLevel | 2 | 3 | 4 | No | 2 | Level of the section heading; group titles use the next level and questions the level after that. |
showTopicNav | boolean | No | None | Render the topic index. When omitted it shows once three or more groups have questions. |
topicNavPlacement | 'top' | 'sidebar' | No | 'sidebar' | sidebar puts the index in a sticky column beside the groups from lg and above them below lg; top keeps it above the groups at every width. |
topicNavLabel | string | No | 'FAQ topics' | Visible label and accessible name of the topic index. |
idPrefix | string | No | None | Prefix for every id and anchor, so #faq-billing links to the billing group. Defaults to a per-instance id from $props.id(). |
footer | Snippet | No | None | Content after the last group, such as a link to contact support. |
Customization#
On this pageChange content through props and retone the section through CSS variables: accent (index hover, current topic, open chevron and focus ring), ink, muted, hairline, and scroll-offset for the sticky header height. Layout, widths and spacing are Tailwind classes in the source.
- Content: pass groups in display order, each with a URL-safe id, a title, an optional one-line description and its questions. Keep a group to five or six questions; split it before it grows longer.
- Headings: set
headingLevelso the section continues the page outline; group titles and questions step down one level each. - Topic index: it appears automatically from three groups. Set
showTopicNavto force it on or off,topicNavPlacementto choose top or sidebar, andtopicNavLabelto rename it (for example "On this page"). - Links from other pages: set
idPrefix(for example "faq") so each group has a stable anchor such as #faq-billing. - Sticky header:
--grouped-faq-scroll-offset(default 6rem) is where a jumped-to group lands and where the sticky index sits. Set it to your header height plus about 2rem. - Accent:
--grouped-faq-accentcolours the index rule on hover, the current topic, the open chevron and the focus ring. The default is near-black, so the section is monochrome until you choose a colour; keep it at 3:1 against the page. - Text:
--grouped-faq-inksets the title, topic rules, group titles and questions;--grouped-faq-mutedthe description, answers, counts and index label. Keep muted at 4.5:1 against the background. - On-accent:
--grouped-faq-on-accentis part of the standard token set but nothing in this section is filled with the accent, so it has no effect until you add a filled element such as a contact button in the footer. - Dividers:
--grouped-faq-hairlinedraws the rules between questions and index entries; each topic opens with a one-pixel rule in the ink colour. - Worked retone for a dark page: give the section a dark background with a class, then set
--grouped-faq-ink:#fafafa;--grouped-faq-muted:#a1a1aa;--grouped-faq-hairline: rgb(255 255 255 / 0.1);--grouped-faq-accent:#93c5fd. - Layout: with the index on top, each topic sits in a one-third rail beside its questions from lg; change
lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] on the group to rebalance it. The sidebar split islg:grid-cols-[minmax(0,1fr)_minmax(0,3fr)]. - Rich answers: change the answer type and replace the paragraph inside each details element with your own markup.
Public CSS variables
| Variable | Token |
|---|---|
--grouped-faq-accent | accent |
--grouped-faq-on-accent | onAccent |
--grouped-faq-ink | ink |
--grouped-faq-muted | muted |
--grouped-faq-hairline | hairline |
--grouped-faq-scroll-offset | scrollOffset |
Accessibility#
On this page- Each answer is a native details element whose summary is the keyboard target: Tab reaches it, Enter and Space toggle it, and browsers expose its expanded state. Answers open independently and work without JavaScript.
- The outline steps section, topic, question: the section heading uses
headingLevel, group titles the next level and each question a heading one level lower inside its summary. Some screen readers announce the summary as a button and do not list that inner heading; the group headings are always listed. - The topic index is a nav named by its visible label (
topicNavLabel), so it is distinct from the site navigation. Its links are plain in-page anchors, and in the sticky column the topic being read carriesaria-current="location" once JavaScript runs. - Group anchors carry scroll-margin-top from
--grouped-faq-scroll-offsetso a sticky header does not cover the heading; set it to your header height. - IDs are derived from
$props.id()oridPrefix, so two sections on one page never collide, even with the same group ids, as long as eachidPrefixis unique. The section's own ids use a double hyphen (prefix--title, prefix--nav), so no group id can collide with them. - The chevron and the question counts in the index are decorative and hidden from assistive technology.
- Focus is shown with a two-pixel ring in the accent colour; keep
--grouped-faq-accentat 3:1 against the page. Under reduced motion answers open without animating and the chevron flips without rotating.
Known limitations
- Opening a question does not update the URL, so a link cannot open a particular answer.
Release details#
On this page- Integration
- Local interaction
- Works without client-side JavaScript
- 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.0.0 (Published) Current release · 30 September 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.