FAQ Page
A polished help center at /faq — a searchable hero, browse-by-topic cards, a filterable Q&A accordion with helpful-voting, and a sticky support aside, all driven by i18n keys.
A polished help-center at /faq: a tinted hero band with live search, browse-by-topic category
cards, a filterable single-open Q&A accordion with "was this helpful?" voting, and a sticky aside
(popular questions + support CTA). All questions and answers are i18n keys — the content model
stores no literal copy.
Overview
The page opens with a hero band — a light primary/info-tinted gradient card (dark text, not a
saturated CTA band) textured with a masked DotGrid and two parallaxing soft glows — carrying
an eyebrow chip, the headline, and a large pill search input. Below it, five category cards
(License & usage / Customization & theming / Tech stack / Updates & versioning / Support) act as
filter toggles, each showing its icon and a live "N articles" count.
The main area is a two-column grid: the Q&A accordion (single-open, controlled, first question
open by default; each answer ends in a HelpfulVote thumbs row) and a sticky aside with a
"Popular questions" list (click → clears filters, opens that item, and smooth-scrolls it to center)
and a support CTA card (Email / Live chat / Contact buttons + a typical-reply-time note).
Search matches against the translated question and answer text, so filtering works in the
active language. Search and category compose; a ghost Clear filters button appears only while
filtering, and zero matches render a token-drawn FaqEmpty illustration. All state lives in the
useFaq view-model hook (the feature view-model convention); the page component is render-only
wiring. Votes are local component state — nothing persists.
Architecture & files
| File | Responsibility |
|---|---|
src/pages/FaqPage.tsx | The page: PageHeader, the hero band (search), the category-card grid, the accordion + aside layout, and the support CTA. Owns only presentation helpers (selectCategory, scroll-to-list); everything stateful comes from useFaq. |
src/components/faq/useFaq.ts | The view-model: search query, category filter (with the FAQ_ALL sentinel), the controlled single-open accordion value, local votes (+ thanks toast), the popular list, filtered results, isFiltering, clear, and jumpTo. |
src/components/faq/HelpfulVote.tsx | "Was this helpful?" thumbs up/down row appended to each answer. Controlled by the parent; shows the thanks string once voted. |
src/components/faq/FaqEmpty.tsx | "No matching questions" empty state — an inline SVG drawn with raw token vars so it re-skins/dark-modes automatically. |
src/components/faq/index.ts | Barrel: FaqEmpty, HelpfulVote (+ Vote type), useFaq, FAQ_ALL. |
src/data/faq.ts | The content model: FaqCategory / FaqItem types, FAQ_CATEGORIES (5), and FAQ_ITEMS (19) — i18n keys only, no literal copy. |
src/locales/en/faq.json | All page chrome and every question/answer (q*/a* keys) in the faq namespace. |
src/routes.tsx | The route entry: {path: '/faq', element: <FaqPage />} (standard shell page, not full-bleed). |
src/data/menu.ts | The sidebar leaf: Pages → FAQ Page (nav:faq). |
Data model
export interface FaqCategory {
id: string
/** i18n key (faq ns) for the category label. */
labelKey: string
icon: LucideIcon
}
export interface FaqItem {
id: string
categoryId: string
/** i18n keys (faq ns) for the question / answer. */
qKey: string
aKey: string
/** Surfaced in the "Popular questions" strip. */
popular?: boolean
}Like data/pricing.ts, the model stores keys, not copy — qKey/aKey resolve at render via
the faq namespace, so the entire help center translates by editing locale JSON. Five items are
flagged popular in the shipped seed and feed the aside strip.
No persistence
Unlike the scrumboard/calendar/contacts apps, the FAQ keeps no localStorage state: filters,
the open item, and votes reset on reload. There is deliberately no STORAGE_KEY and nothing to
register in src/lib/appStorage.ts.
Usage
The page is already wired — navigate to /faq (Pages → FAQ Page in the sidebar). It's a
standard shell page (renders <PageHeader title={t('nav:faq')} />, so the breadcrumb auto-derives
and the tab title is set automatically), registered in src/routes.tsx:
{path: '/faq', element: <FaqPage />},To reuse just the state logic (e.g. an embedded FAQ widget), call the hook:
import {useFaq, FAQ_ALL} from '@/components/faq'
function MiniFaq() {
const faq = useFaq()
// faq.filtered is ready to render; faq.setQuery / faq.setCategory drive it
}API / Props
useFaq()
Returns the FAQ view-model:
| Key | Type | Description |
|---|---|---|
query / setQuery | string / (v) => void | The search text. Matching is case-insensitive against the translated question and answer. |
category / setCategory | string / (v) => void | The active category id, or the FAQ_ALL sentinel ('all'). |
open / setOpen | string / (v) => void | The single open accordion item id (controlled). Defaults to the first seed question. |
votes | Record<string, Vote> | Per-question local votes ('up' | 'down'). |
vote | (id, dir) => void | Record a vote and fire the faq:helpfulThanks success toast. |
filtered | FaqItem[] | Items passing the category + search filters. |
popular | FaqItem[] | Items flagged popular in the seed. |
isFiltering | boolean | True when a query is set or a category is active — drives the Clear-filters button. |
clear | () => void | Reset query + category. |
jumpTo | (item: FaqItem) => void | Reset filters, open the item, and smooth-scroll it to center (double-rAF so the list re-renders first). |
HelpfulVoteProps (HelpfulVote.tsx)
| Prop | Type | Description |
|---|---|---|
vote? | 'up' | 'down' | The current vote for this question (from the page's vote map), if any. |
onVote | (dir: Vote) => void | Vote callback. Buttons carry aria-pressed; the selected one tints success (up) / danger (down). |
FaqEmpty (FaqEmpty.tsx)
{title: string; hint: string} — pass already-translated strings (faq:emptyTitle /
faq:emptyHint on the page).
Accordion wiring
The list is the standard Accordion primitive in
controlled single-open mode — value={faq.open} with
onValueChange={(open) => faq.setOpen(open[0] ?? '')} (the callback always receives the next
open-item array). Each AccordionItem gets its category's icon and a title <span> whose id is
faq-item-<id> with a scroll-mt-24 class — that id + scroll margin is what jumpTo targets under
the sticky header.
Configuration & customization
Add or edit a question
Add a FaqItem to FAQ_ITEMS in src/data/faq.ts and its two keys to the locale files:
// src/data/faq.ts
{id: 'stackDeploy', categoryId: 'stack', qKey: 'faq:qStackDeploy', aKey: 'faq:aStackDeploy'},// src/locales/en/faq.json
{
"qStackDeploy": "How do I deploy the build?",
"aStackDeploy": "Run npm run build and serve the dist/ folder from any static host."
}Category counts, search, and the accordion pick it up automatically. Flag it popular: true to
surface it in the aside strip.
Add a category
Add a FaqCategory (unique id, a faq: label key, a lucide icon) to FAQ_CATEGORIES plus the
label in the locale files. The card grid is lg:grid-cols-5 — adjust the grid classes in
FaqPage.tsx if you go beyond five. Keep at least ~3 questions per category: the "N articles"
count renders a plural label (faq:articles) unconditionally.
Change the support contacts
The CTA card's email is the module-level SUPPORT_EMAIL constant at the top of FaqPage.tsx
(a demo contact — swap for your own; the button opens mailto:). Live chat navigates to
/apps/chat and Contact to /apps/contacts; repoint those navigate calls at your channels.
Hero band
The hero is a FadeIn card with bg-gradient-to-br from-primary/10 via-surface to-info/10, a
<DotGrid masked> texture (faded toward the edges), and a <Parallax distance={36}> layer of two
blurred token-tinted glows. It's the light variant deliberately — dark foreground text on a tinted
surface rather than a saturated primary CTA band. To restyle it, adjust the token-opacity utilities;
don't introduce hex.
Examples
Deep-link into a question from anywhere
import {useFaq} from '@/components/faq'
import {FAQ_ITEMS} from '@/data/faq'
const faq = useFaq()
const target = FAQ_ITEMS.find((i) => i.id === 'customTheme')!
faq.jumpTo(target) // clears filters, opens it, scrolls it to centerFilter to one category programmatically
faq.setCategory('license') // only License & usage questions
faq.setQuery('extended') // …matching "extended" in the active languageBest practices
- Keys, not copy. Every question/answer is a
faq:key resolved at render — add content by editingdata/faq.ts+ the locale JSON, never by inlining strings in components. - Keep logic in
useFaq. The page stays render-only per the feature-view-model convention; extend filtering/voting in the hook. - Search the translation, not the key. The filter compares
t(item.qKey)/t(item.aKey)— keep it that way so search works in every language. - Tokens for every tint. Category cards, vote states (
success/dangertints), the hero, and the empty-state SVG are all semantic-token based. - Persist votes deliberately. If you make votes survive reloads, add the new
localStoragekey toAPP_LOCAL_KEYSinsrc/lib/appStorage.tsso "Reset to defaults" clears it.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| A popular-question click doesn't scroll to the item | jumpTo waits two animation frames before scrollIntoView (the list must re-render unfiltered first) and targets #faq-item-<id> with scroll-mt-24. Keep both if you restructure. |
| Clicking an open question's category card shows everything | Expected toggle: selectCategory flips an already-active category back to FAQ_ALL. |
| Search misses a question you can see | The match runs on the translated text in the current language — check the active locale's faq.json, not the English source. |
| Votes disappear after reload | By design — votes are local useState, no persistence. |
| Two questions open at once | The accordion is type="single" and controlled via faq.open; keep the value/onValueChange pair rather than switching to uncontrolled defaultValue. |
| "N articles" reads wrong for a 1-question category | The label is unconditionally plural (faq:articles); all shipped categories have ≥3 questions. Add a plural-aware key if you need singleton categories. |
FAQ
Is the FAQ content translated? The chrome and all Q&A ship in the faq namespace with en+ja
parity; other languages fill via npm run i18n:translate (DeepL). Answers referencing file paths
(e.g. src/styles/index.css) keep those literal.
Do votes go anywhere? No — they're presentational local state plus a thanks toast. Wire
useFaq's vote() to your API (src/lib/api.ts) to record real feedback.
Can I make questions rich text? The answer renders as a plain <p>{t(item.aKey)}</p>. For
markup, render a <Trans> component or a custom body per item instead of the flat aKey string.
Why does one question start open? useFaq seeds open with the first seed item
(licenseWhich) so the page never looks collapsed-empty on first paint.
Where is the empty state from? FaqEmpty — an inline SVG using raw token vars
(var(--surface), var(--border), var(--muted-foreground)), the same technique as the
contacts/scrumboard/chat illustrations.
Notes for designers & content editors
- All copy lives in
src/locales/<lng>/faq.json— hero, buttons, categories, and every Q&A. The shipped answers are real template FAQ content (licensing, theming, stack, updates, support); rewrite them for your product without touching code. - Category icons are lucide icons chosen in
src/data/faq.ts— swap per category there. - The
popularflags curate the aside strip; keep it to a handful so the sticky card stays scannable. - Colors are tokens throughout — the hero tint, active card state, vote tints, and the empty-state SVG all re-skin and dark-mode automatically.
- The support email (
SUPPORT_EMAILinFaqPage.tsx) is a demo address — replace before ship.
Related
Error Pages
The 404 side panel links here as a popular destination.
Overlays & Disclosure
The Accordion / AccordionItem primitive the Q&A list uses.
Core & Feedback
Button, Input, and Toast.
Animation & Effects
FadeIn, Stagger, Reveal, Parallax, DotGrid.
Pricing
The sibling keys-only content model (data/pricing.ts).
Was this page helpful?
