Help Center
A support landing block whose search ranks titles, keywords and summaries, whose topic cards report honest counts, and whose zero result offers a topic or a human instead of a dead end.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/help-center.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "HelpCenter" block with zod and
lucide-react, plus one sibling component that highlights query matches inside a
string (structural marks, never innerHTML); cn() (clsx + tailwind-merge) for
classes, no other dependency. This is the page a company points help.<domain>
at: one search field, topic cards, the articles people actually read, and a way
to reach a human. Its only job is to make sure nobody leaves with nothing.
Contract
- A zod schema (`helpCenterSchema` in a sibling contract file) is the single
source of truth and props are z.infer of it — never a parallel interface:
{ status; title; tagline?; asOf; categories[]; articles[]; popular[];
contact?; errorMessage? }.
- status is "loading" | "empty" | "error" | "ready" — the block's own render
state, unrelated to whether a search matched anything.
- HelpArticle = { id; title; summary?; categoryId; href; updatedAt: string|null;
readingMinutes?; keywords?: string[] }. `keywords` is searched and NEVER
rendered: it carries the words people type but authors never write ("declined",
"429", "money back"). `href` is the consumer's and is rendered as a real <a>,
never a dead "#". updatedAt null means the source never recorded one.
- HelpCategory = { id; name; description?; articleCount: number|null }.
articleCount is what the BACKEND holds; null means "count the ones that
arrived". A reported number wins over the loaded count on purpose — this block
ships a slice and the topic page behind the card holds the rest.
- HelpContact = { heading; body?; actionLabel; href: string|null;
responseTime? }. href wins and renders an <a>; null falls back to the onContact
handler and renders a <button>; with neither, render NO control at all — a
dead "Contact us" button is worse than no button.
- popular is an array of ids into articles[], MOST POPULAR FIRST. That order is
the host's ranking and is never re-sorted. Duplicates collapse; ids that
resolve to nothing are counted and reported under the list.
- asOf is an ISO instant and is the ONLY clock. Never call Date.now(): every
"updated 3 days ago" derives from it, so the same payload renders the same
block and SSR matches hydration.
- Component props = the schema type plus: locale? ("en-US"), timeZone? ("UTC"),
defaultQuery? (""), defaultCategoryId? (null), searchPlaceholder?
("Search help articles"), searchHotkey? ("/", null disables), resultLimit? (6,
clamped 1-50), skeletonCards? (6, clamped 1-12), onSearchSettled?,
onArticleSelect?, onContact?, onRetry?. forwardRef<HTMLElement>, extends
Omit<React.HTMLAttributes<HTMLElement>, "title">, rest spread on the root
<section>. defaultQuery / defaultCategoryId are uncontrolled initial values.
- Export the search itself: helpQueryTokens(query) and
rankHelpArticles({ articles, categories, query, categoryId, popular }), plus
resolvePopularArticles(articles, popular) -> { articles, missing } and
categoryArticleCount(category, articles). A command palette, a 404 page or a
server route must be able to rank with the same rules instead of growing a
second opinion about which article answers "refund".
Behavior — four branches, not one plus three afterthoughts
- The hero (title, tagline, search field) renders in ALL FOUR branches. The
field is readOnly outside "ready" — never `disabled`, because the browser
blurs a disabled control to <body> and the caret must survive a branch flip.
- loading: skeletonCards topic cards plus four skeleton rows, aria-hidden, with
aria-busy on the root.
- empty: one panel saying nothing is published yet.
- error: a destructive-bordered panel printing errorMessage, falling back to a
sentence that admits the search has nothing to look through. The retry button
exists only when onRetry is passed.
- ready: topic cards, the list, the contact strip.
- status "ready" with zero articles renders the EMPTY branch: a search field
over nothing is a promise the block cannot keep.
- The contact strip renders in ready, empty AND error — the moment the feed is
down is the moment somebody needs a human most.
Behavior — the search
- Tokens: lowercase the query, split on whitespace, drop duplicates. Empty means
BROWSE, never "match nothing".
- An article survives when EVERY token matches SOMETHING (AND across tokens, OR
across fields), which is what makes a second word narrow the list instead of
widening it.
- Field weights decide the score: title 6 (+2 when the token opens the title),
keyword 4, summary 2, topic name 1. So "billing" surfaces "Update your billing
details" ahead of the twelve articles that merely live under Billing.
- Ties break by the host's popular rank, then by updatedAt descending (an
undated article sinks below the dated ones rather than claiming to be either
the newest or the oldest), then by id — deterministic to the last comparison,
so two articles saved in the same second cannot swap between SSR and
hydration.
- A topic card is a toggle (aria-pressed), not a link: pressing it filters,
pressing it again releases. The card stays mounted, so focus never moves.
- Highlighting is structural — text nodes plus <mark> — never
dangerouslySetInnerHTML, and the query is escaped before it reaches a RegExp:
typing "(429)" must highlight those characters, not throw.
- The list caps at resultLimit with a "Show N more articles" disclosure
(aria-expanded + aria-controls). Any change to the query or the topic collapses
it again, because a new list must not grow the page under the person typing.
- With no query and no topic the list is the POPULAR one, numbered in the host's
order. Otherwise it is the ranked results.
Behavior — the zero states (this is what makes it a help centre)
- Two different empties, two different sentences: "nothing matches <query>" is
not "nothing is published under <topic> yet". Say which one happened.
- Both offer a way onward — the escape hatch ("Clear search" / "Show all
articles") AND the contact action, so a miss routes to a human instead of
ending the journey.
- The escape hatch unmounts itself with the panel it lives in, so the handler
hands focus back to the search field on purpose. Focus must never land on
<body>.
Behavior — degenerate data (branches, not crashes)
- A categoryId matching no category prints the raw id as the article's topic and
the article stays searchable under that literal text: a broken association is
information, not a reason to hide somebody's article.
- articleCount below zero clamps to zero; a count of 0 reads "No articles yet",
and selecting that topic lands on the "nothing published here yet" branch.
- updatedAt that will not parse is printed VERBATIM; null prints nothing. A
timestamp in the future (a skewed publishing box) reads "today", never
"in -2 days".
- readingMinutes of 0, a negative or a non-finite number prints nothing rather
than promising a "0 min read".
- Relative under 30 days ("today", "yesterday", "N days ago"), absolute beyond
it — "47 days ago" is arithmetic, not information.
- An unknown IANA zone or a malformed locale makes Intl.DateTimeFormat throw a
RangeError at construction; catch it and retry without timeZone, then with
"en-US", so one bad config string cannot blank the page.
Behavior — keyboard, ARIA and narration
- Root is <section aria-labelledby> pointing at the h2 title, aria-busy while
loading. The field sits in a <form role="search"> whose onSubmit is
preventDefault (Enter must not reload the page), with an sr-only <label> and a
visible hint wired through aria-describedby.
- searchHotkey (default "/") focuses and selects the field from anywhere: one
document keydown listener that ignores modifier chords and any event whose
target is an input, textarea, select or contenteditable — never steal a
keystroke from something the user is already typing into. Remove the listener
on unmount and whenever the key or the branch changes.
- Escape inside the field clears it and calls stopPropagation, but ONLY when it
actually cleared something: an empty field leaves the key to a surrounding
dialog.
- Tab order is a plain list of controls (field, clear, each topic card, each
row, the disclosure, the contact action) — no roving tabindex, because this is
not a composite widget.
- One persistent live region, <span role="status" aria-atomic class="sr-only">,
fed from two places: the branch changing phase (announced immediately, with
the first paint as a silent baseline), and the result count after a 320ms
debounce. Keep the sentence and the count in a REF, not in the debounce
effect's deps — putting them there re-runs the effect on every keystroke and
the cleanup cancels the pending timer before it can fire.
- onSearchSettled fires on that same debounce with { query, categoryId,
resultCount }, which is where a server search or an analytics event belongs.
- Retry is one-shot per burst: the guard is a ref read AND written synchronously
inside the handler, because a state flag is only visible after a re-render and
the second click of a double click lands before that. Paint the pressed state
from separate state, re-arm on a 1200ms timer, and use aria-disabled plus a
handler guard while locked — never the native disabled attribute.
- Cleanup: clear the debounce timer and the retry timer on unmount, and detach
the hotkey listener on unmount and on dependency change.
Rendering & styling
- Semantic tokens only, no hex / rgb / oklch anywhere: bg-card + border +
rounded-xl cards, bg-muted/30 hero, bg-muted/40 contact strip, bg-primary/5 +
border-primary for the pressed topic card, text-muted-foreground for secondary
text, text-destructive + border-destructive/40 for the error panel, bg-primary
+ text-primary-foreground for the contact action, ring-ring focus-visible
rings with ring-offset-background on every control including the rows.
- The <mark> element's user-agent yellow ignores the colour scheme, so the
highlighter must override it with tokens (bg-primary/30 + text-foreground).
- Topic cards are a grid (1 / 2 / 3 columns); the rows are a divide-y list. Long
titles, topic names and raw ids get wrap-anywhere so a 58-character hostname
wraps instead of overflowing.
- Reduced motion: motion-reduce:animate-none on the skeleton pulse and the retry
spinner, motion-reduce:transition-none on the row hover, the card hover and the
chevron rotation. Nothing functional depends on any of it.
- The row's trailing arrow appears on group-hover AND group-focus-within, so it
is not a mouse-only affordance.
Customization levers
- Sub-blocks: pass categories: [] and the topic grid disappears; pass popular:
[] and the browse list becomes the "nothing ranked yet" panel; omit `contact`
and the block never offers a human (the zero states then only offer the escape
hatch). Drop the hero copy by passing an empty tagline.
- Ranking: FIELD_SCORE { title 6, keyword 4, summary 2, category 1 } and
TITLE_PREFIX_BONUS (2) are the entire relevance policy — raise the keyword
weight for a support desk whose articles are titled badly, drop the category
weight to 0 if topic names should never match.
- Density: resultLimit (6) is how many rows show before the disclosure; set it
above your article count for a flat list. skeletonCards (6) should match the
number of topics you expect.
- Freshness: RELATIVE_DAYS (30) is where "N days ago" turns into a date, and
locale / timeZone drive the absolute format.
- Narration: SETTLE_MS (320) is the debounce for both the live region and
onSearchSettled — raise it for a server-side search, lower it for a local one.
- Shortcut: searchHotkey takes any single key ("k" for a ⌘K-adjacent feel) or
null to leave the document's keystrokes alone.
- Palette: the block is monochrome by default (primary + muted + destructive).
Giving each topic card its own accent is one map from category id to
var(--chart-1..5) applied to the card border and icon.
- Actions: onArticleSelect receives the click event, so a client router can
preventDefault() and push instead of reloading; wire onContact to your ticket
dialog, or set contact.href to a mailto:/support URL and skip the handler.Concepts
- A miss is a route, not a dead end — zero results is the state this block exists for. It never ends in an empty list: the panel names what happened, offers the escape hatch, and offers a human, which is the difference between a help centre and a list with a filter on top.
- Two distinct zero states — "nothing matches your words" and "this topic is empty" are different facts and get different sentences and different escape hatches. Collapsing them into one "No results" is how people conclude the answer does not exist.
- Deliberate focus successor — every control that removes itself (the clear-search X, "Clear filters", "Show all articles") hands focus back to the search field inside the handler. A topic card, by contrast, only flips
aria-pressedand stays mounted, so pressing it never moves focus at all. - Reported counts beat loaded counts — a topic card prints the number the backend claims, not the number that happened to ship in this payload;
nullis the explicit way to say "count what arrived". A landing block is a slice of the library and must not understate it. - AND across tokens, OR across fields — every word you type has to match somewhere (title, keyword, summary or topic name), so a second word narrows instead of widening, and the field it matched in decides the rank. Keywords are searched and never rendered: that is how "429" and "money back" find articles nobody titled that way.
- Phase plus debounce, one live region — the region speaks when the branch changes phase, and again 320ms after the query settles with the result count. The first paint is a silent baseline, so a screen reader is not read the whole block on load and not read a number for every keystroke.
Invite Team
A four-state invite panel: a paste-a-list email field with per-address reasons, a role per invite, a seat meter that blocks over-allocation, undoable revoke and a copyable join link.
Referral Program
A four-state referral panel: the personal invite link with copy and templated share targets, tier progress counted from the referral list itself, every referred person with their stage and reward, and the program terms behind a disclosure.