Help 1: help center home
A help center home: a hero search that suggests articles as you type with highlighted matches, topic cards that light up under the pointer, popular articles, and ways to reach a person with who is online.
The normal view. Pick a state to see what the block shows while it loads, is empty or fails.
// Ballmac UI: Help 1. https://ui.ballmac.com/blocks/help-1
"use client"
import * as React from "react"
import { ArrowRight, BookOpen, Code2, CreditCard, FileSearch, LifeBuoy, MessageCircle, Plug, Rocket, Search, ShieldCheck, Users, X } from "lucide-react"
import { BlurFade, BlurFadeGroup } from "@/components/ballmac/blur-fade"
import { Button } from "@/components/ballmac/button"
import { CoverArt } from "@/components/ballmac/cover-art"
import { DotPattern } from "@/components/ballmac/dot-pattern"
import { Kbd } from "@/components/ballmac/kbd"
import { MatchHighlight } from "@/components/ballmac/match-highlight"
import { Skeleton } from "@/components/ballmac/skeleton"
import { SpotlightCard } from "@/components/ballmac/spotlight-card"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { cn } from "@/lib/utils"
type Help1Category = {
id: string
title: string
description: string
icon?: React.ReactNode
/** How many articles it holds. */
count: number
href?: string
}
type Help1Article = {
id: string
title: string
/** One line shown under the title in search. Also searched. */
summary?: string
categoryId: string
/** Reading time in minutes. */
minutes?: number
href?: string
/** Listed under Popular articles. */
popular?: boolean
}
type Help1Channel = {
id: string
label: string
description: string
icon?: React.ReactNode
/** A green dot and "Online" when true. */
online?: boolean
/** The button's text ("Start a chat", "Send an email"). */
action: string
href?: string
}
type Help1Props = Omit<React.ComponentProps<"section">, "title"> &
StateProps & {
title?: string
description?: string
categories?: Help1Category[]
articles?: Help1Article[]
/** Searches shown under the box as shortcuts. */
suggestions?: string[]
/** Ways to reach a person. */
channels?: Help1Channel[]
/** Called when an article is chosen from search or the Popular list. Without it, articles with an href are followed. */
onOpenArticle?: (article: Help1Article) => void
onOpenCategory?: (category: Help1Category) => void
onContact?: (channel: Help1Channel) => void
/** Called with each search, once typing has paused, for analytics or your own results. */
onSearch?: (query: string) => void
/** Text in the search box at first, with its suggestions open. */
defaultQuery?: string
}
const sampleCategories: Help1Category[] = [
{ id: "start", title: "Getting started", description: "Set up your workspace, invite your team and import your data.", icon: <Rocket />, count: 14 },
{ id: "billing", title: "Billing and plans", description: "Invoices, seats, payment methods and changing your plan.", icon: <CreditCard />, count: 11 },
{ id: "security", title: "Security and privacy", description: "Two-step sign-in, single sign-on, data export and deletion.", icon: <ShieldCheck />, count: 9 },
{ id: "integrations", title: "Integrations", description: "Connect chat, calendars, code hosting and your own tools.", icon: <Plug />, count: 18 },
{ id: "teams", title: "Teams and roles", description: "Permissions, groups and what each role can see and change.", icon: <Users />, count: 8 },
{ id: "api", title: "API and webhooks", description: "Keys, rate limits, events and examples in four languages.", icon: <Code2 />, count: 22 },
]
const sampleArticles: Help1Article[] = [
{ id: "a1", title: "Reset your password", summary: "Get back into your account in two minutes.", categoryId: "security", minutes: 2, popular: true },
{ id: "a2", title: "Download invoices and receipts", summary: "Find every invoice as a PDF, with your company details.", categoryId: "billing", minutes: 3, popular: true },
{ id: "a3", title: "Create and rotate an API key", summary: "Keys are shown once. Here is how to replace one safely.", categoryId: "api", minutes: 4, popular: true },
{ id: "a4", title: "Invite teammates and set their role", summary: "Add people by email or by link, and choose what they can do.", categoryId: "teams", minutes: 3, popular: true },
{ id: "a5", title: "Turn on two-step sign-in", summary: "Use an authenticator app or a passkey to protect your account.", categoryId: "security", minutes: 3, popular: true },
{ id: "a6", title: "Connect your chat workspace", summary: "Post updates to a channel and reply to alerts from chat.", categoryId: "integrations", minutes: 5, popular: true },
{ id: "a7", title: "Change or cancel your plan", summary: "What happens to your seats and data when you change plans.", categoryId: "billing", minutes: 4 },
{ id: "a8", title: "Import data from a spreadsheet", summary: "Upload a CSV and map its columns in under five minutes.", categoryId: "start", minutes: 6 },
{ id: "a9", title: "Webhook events and retries", summary: "Every event we send, its payload and how failed deliveries retry.", categoryId: "api", minutes: 7 },
{ id: "a10", title: "Export or delete your data", summary: "Download everything, or permanently remove your workspace.", categoryId: "security", minutes: 4 },
]
const sampleSuggestions = ["Reset password", "Invoices", "API keys", "Two-step sign-in"]
const sampleChannels: Help1Channel[] = [
{ id: "chat", label: "Live chat", description: "Replies in about 2 minutes", icon: <MessageCircle />, online: true, action: "Start a chat" },
{ id: "email", label: "Email", description: "Replies within one working day", icon: <LifeBuoy />, action: "Send an email" },
{ id: "docs", label: "Community", description: "Ask other people who use it", icon: <BookOpen />, action: "Open the forum" },
]
const tones = ["bg-chart-1/20", "bg-chart-2/20", "bg-chart-3/25", "bg-chart-4/20", "bg-chart-5/20", "bg-chart-1/20"]
function HelpSkeleton() {
return (
<div className="grid gap-10" aria-hidden="true">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-40 rounded-2xl" />
))}
</div>
<div className="grid gap-3 sm:grid-cols-2">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-14 rounded-xl" />
))}
</div>
</div>
)
}
/** A link when there is an address, otherwise a button, so one row works for both. */
function Action({ href, onClick, className, children, ...rest }: { href?: string; onClick?: () => void; className?: string; children: React.ReactNode } & Omit<React.ComponentProps<"button">, "onClick">) {
if (href)
return (
<a href={href} onClick={onClick} className={className}>
{children}
</a>
)
return (
<button type="button" onClick={onClick} className={className} {...rest}>
{children}
</button>
)
}
/**
* A help center home: a hero with a search that suggests articles as you type (highlighted matches, arrow keys, press / to focus),
* categories that light up under the pointer, the most read articles, and ways to reach a person with who is online.
*/
function Help1({
title = "How can we help?",
description = "Search the guides, or browse by topic.",
categories = sampleCategories,
articles = sampleArticles,
suggestions = sampleSuggestions,
channels = sampleChannels,
onOpenArticle,
onOpenCategory,
onContact,
onSearch,
defaultQuery = "",
state = "ready",
onRetry,
emptyState,
errorState,
className,
...props
}: Help1Props) {
const id = React.useId()
const inputRef = React.useRef<HTMLInputElement>(null)
const [query, setQuery] = React.useState(defaultQuery)
const [open, setOpen] = React.useState(defaultQuery !== "")
const [active, setActive] = React.useState(0)
const ready = state === "ready" || state === "empty"
const noGuides = state === "empty" || (state === "ready" && !categories.length)
const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
const results = tokens.length
? articles
.map((a) => {
const haystack = `${a.title} ${a.summary ?? ""}`.toLowerCase()
const inTitle = a.title.toLowerCase()
return { a, hit: tokens.every((t) => haystack.includes(t)), score: tokens.filter((t) => inTitle.includes(t)).length }
})
.filter((r) => r.hit)
.sort((x, y) => y.score - x.score)
.map((r) => r.a)
.slice(0, 6)
: []
const showList = open && ready && tokens.length > 0
const categoryName = (cid: string) => categories.find((c) => c.id === cid)?.title
React.useEffect(() => setActive(0), [query])
React.useEffect(() => {
if (!onSearch || !tokens.length) return
const t = setTimeout(() => onSearch(query.trim()), 600)
return () => clearTimeout(t)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
// "/" jumps to the search from anywhere on the page, as it does on most documentation sites
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return
const el = e.target as HTMLElement | null
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable)) return
e.preventDefault()
inputRef.current?.focus()
}
document.addEventListener("keydown", onKey)
return () => document.removeEventListener("keydown", onKey)
}, [])
function choose(a: Help1Article) {
setOpen(false)
if (onOpenArticle) onOpenArticle(a)
else if (a.href) window.location.assign(a.href)
}
const popular = articles.filter((a) => a.popular).slice(0, 6)
const content = (
<div className="grid gap-14">
<section aria-labelledby={`${id}-cats`}>
<h2 id={`${id}-cats`} className="text-xl font-semibold tracking-tight">
Browse by topic
</h2>
<StateView
state={noGuides ? "empty" : "ready"}
bare
empty={{ title: emptyState?.title ?? "No guides yet", description: emptyState?.description ?? "Help articles will be listed here.", icon: emptyState?.icon ?? <FileSearch />, action: emptyState?.action }}
className="mt-5"
>
<BlurFadeGroup stagger={0.06} className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 [&>*]:h-full [&>*>*]:h-full" item={{ direction: "up", offset: 12, blur: 6 }}>
{categories.map((c, i) => (
<SpotlightCard key={c.id} size={300} className="focus-within:ring-ring/50 group/card flex flex-col gap-4 rounded-2xl p-6 transition-shadow duration-200 focus-within:ring-[3px] hover:shadow-md">
<span aria-hidden="true" className={cn("text-foreground flex size-11 items-center justify-center rounded-xl border [&_svg]:size-5", tones[i % tones.length])}>
{c.icon ?? <BookOpen />}
</span>
<div className="flex-1">
<h3 className="text-base font-semibold tracking-tight">
<Action href={c.href} onClick={() => onOpenCategory?.(c)} className="outline-none after:absolute after:inset-0 after:content-['']">
{c.title}
</Action>
</h3>
<p className="text-muted-foreground mt-1.5 text-sm text-pretty">{c.description}</p>
</div>
<p className="text-muted-foreground flex items-center justify-between text-xs tabular-nums">
<span>{c.count === 1 ? "1 article" : `${c.count} articles`}</span>
<ArrowRight aria-hidden="true" className="text-foreground size-4 -translate-x-1 opacity-0 transition-all duration-200 group-hover/card:translate-x-0 group-hover/card:opacity-100 group-focus-within/card:translate-x-0 group-focus-within/card:opacity-100 motion-reduce:transition-none rtl:translate-x-1 rtl:rotate-180 rtl:group-hover/card:translate-x-0 rtl:group-focus-within/card:translate-x-0" />
</p>
</SpotlightCard>
))}
</BlurFadeGroup>
</StateView>
</section>
{popular.length ? (
<section aria-labelledby={`${id}-popular`}>
<h2 id={`${id}-popular`} className="text-xl font-semibold tracking-tight">
Popular articles
</h2>
<ol className="mt-5 grid gap-x-10 sm:grid-cols-2">
{popular.map((a, i) => (
<li key={a.id} className="border-b">
<Action href={a.href} onClick={() => choose(a)} className="group/row hover:bg-accent/50 focus-visible:ring-ring/50 -mx-3 flex w-[calc(100%+1.5rem)] items-center gap-4 rounded-lg px-3 py-4 text-start outline-none focus-visible:ring-[3px]">
<span aria-hidden="true" className="text-muted-foreground w-5 shrink-0 font-mono text-xs tabular-nums">
{String(i + 1).padStart(2, "0")}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-pretty">{a.title}</span>
<span className="text-muted-foreground mt-0.5 block text-xs">
{categoryName(a.categoryId)}
{a.minutes ? ` · ${a.minutes} min read` : ""}
</span>
</span>
<ArrowRight aria-hidden="true" className="text-muted-foreground group-hover/row:text-foreground size-4 shrink-0 transition-transform duration-200 group-hover/row:translate-x-0.5 motion-reduce:transition-none motion-reduce:group-hover/row:translate-x-0 rtl:rotate-180 rtl:group-hover/row:-translate-x-0.5" />
</Action>
</li>
))}
</ol>
</section>
) : null}
</div>
)
return (
<section data-slot="help-1" data-state={state} aria-label="Help center" className={cn("mx-auto w-full max-w-6xl px-4 py-8 sm:px-6", className)} {...frameProps(props)}>
{/* the suggestions open over the sections below, so the hero is not clipped: only its decoration is */}
<div className="bg-card relative isolate z-10 mb-14 rounded-3xl border px-5 py-14 text-center sm:px-10 sm:py-20">
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 overflow-hidden rounded-3xl">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_55%_60%_at_50%_30%,color-mix(in_oklch,var(--chart-1)_14%,transparent),transparent)]" />
<DotPattern width={16} height={16} radius={1} radialMask />
</div>
<BlurFade direction="up" offset={10} duration={0.5}>
<h1 className="mx-auto max-w-2xl text-4xl font-semibold tracking-[-0.04em] text-balance sm:text-5xl">{title}</h1>
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-base text-pretty">{description}</p>
</BlurFade>
<BlurFade direction="up" offset={10} duration={0.5} delay={0.1}>
<div className="relative mx-auto mt-8 max-w-2xl text-start">
<div className="bg-background focus-within:border-ring focus-within:ring-ring/40 flex h-14 items-center gap-3 rounded-2xl border px-4 shadow-[0_10px_30px_-14px_rgb(0_0_0/0.3)] transition-shadow focus-within:ring-[3px]">
<Search aria-hidden="true" className="text-muted-foreground size-5 shrink-0" />
<input
ref={inputRef}
role="combobox"
aria-label="Search the help center"
aria-expanded={showList}
aria-controls={`${id}-results`}
aria-activedescendant={showList && results[active] ? `${id}-r-${results[active]!.id}` : undefined}
aria-autocomplete="list"
aria-describedby={`${id}-hint`}
autoComplete="off"
spellCheck={false}
disabled={!ready}
value={query}
placeholder="Search for answers"
onChange={(e) => {
setQuery(e.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onKeyDown={(e) => {
if (e.key === "Escape") {
if (open && query) setOpen(false)
else setQuery("")
return
}
if (!showList || !results.length) return
if (e.key === "ArrowDown") {
e.preventDefault()
setActive((a) => (a + 1) % results.length)
} else if (e.key === "ArrowUp") {
e.preventDefault()
setActive((a) => (a - 1 + results.length) % results.length)
} else if (e.key === "Enter" && results[active]) {
e.preventDefault()
choose(results[active]!)
}
}}
className="placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-base outline-none disabled:opacity-50"
/>
{query ? (
<button
type="button"
aria-label="Clear search"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setQuery("")
inputRef.current?.focus()
}}
className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/50 flex size-7 items-center justify-center rounded-md outline-none focus-visible:ring-[3px]"
>
<X aria-hidden="true" className="size-4" />
</button>
) : (
<Kbd className="hidden sm:inline-flex" aria-hidden="true">
/
</Kbd>
)}
</div>
<p id={`${id}-hint`} className="sr-only">
Type to see matching articles. Up and down arrows choose one, Enter opens it. Press slash anywhere on the page to jump here.
</p>
{showList ? (
<div className="bg-popover absolute inset-x-0 top-full z-20 mt-2 overflow-hidden rounded-2xl border p-2 shadow-xl">
{results.length ? (
<ul id={`${id}-results`} role="listbox" aria-label="Matching articles">
{results.map((a, i) => (
<li
key={a.id}
id={`${id}-r-${a.id}`}
role="option"
aria-selected={i === active}
onMouseDown={(e) => e.preventDefault()}
onMouseMove={() => setActive(i)}
onClick={() => choose(a)}
className={cn("flex cursor-default items-center gap-3 rounded-xl px-3 py-2.5", i === active && "bg-accent")}
>
<FileSearch aria-hidden="true" className="text-muted-foreground size-4 shrink-0" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
<MatchHighlight text={a.title} query={tokens} />
</span>
{a.summary ? (
<span className="text-muted-foreground block truncate text-xs">
<MatchHighlight text={a.summary} query={tokens} />
</span>
) : null}
</span>
<span className="text-muted-foreground hidden shrink-0 text-xs sm:block">{categoryName(a.categoryId)}</span>
</li>
))}
</ul>
) : (
<div id={`${id}-results`} className="px-4 py-8 text-center">
<p className="text-sm font-medium">No articles match “{query.trim()}”</p>
<p className="text-muted-foreground mt-1 text-xs text-pretty">Check the spelling, try fewer words, or ask us directly.</p>
{channels[0] ? (
<Button variant="outline" size="sm" className="mt-4" onMouseDown={(e) => e.preventDefault()} onClick={() => onContact?.(channels[0]!)}>
{channels[0].action}
</Button>
) : null}
</div>
)}
</div>
) : null}
<p aria-live="polite" className="sr-only">
{showList ? (results.length === 1 ? "1 article found" : `${results.length} articles found`) : ""}
</p>
</div>
{suggestions.length && ready && !showList ? (
<p className="mt-5 flex flex-wrap items-center justify-center gap-2 text-sm">
<span className="text-muted-foreground">Popular:</span>
{suggestions.map((s) => (
<button
key={s}
type="button"
onClick={() => {
setQuery(s)
setOpen(true)
inputRef.current?.focus()
}}
className="hover:bg-accent focus-visible:ring-ring/50 rounded-full border px-3 py-1 text-[13px] outline-none focus-visible:ring-[3px]"
>
{s}
</button>
))}
</p>
) : null}
</BlurFade>
</div>
<StateView
state={state === "loading" || state === "error" ? state : "ready"}
onRetry={onRetry}
label="Loading the help center"
loading={<HelpSkeleton />}
error={{ title: errorState?.title ?? "The help center could not be loaded", description: errorState?.description ?? "Check your connection and try again. You can still reach us below.", ...errorState }}
>
{content}
</StateView>
<div className="mt-14">
{channels.length ? (
<section aria-labelledby={`${id}-contact`} className="bg-card relative isolate overflow-hidden rounded-3xl border">
<CoverArt seed="support" variant="mesh" className="absolute inset-0 -z-10 opacity-60" />
<div className="from-card via-card/85 absolute inset-0 -z-10 bg-gradient-to-r to-transparent" aria-hidden="true" />
<div className="grid gap-8 p-6 sm:p-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)] lg:items-center">
<div>
<h2 id={`${id}-contact`} className="text-2xl font-semibold tracking-[-0.03em] text-balance">
Still stuck? Talk to a person.
</h2>
<p className="text-muted-foreground mt-2 max-w-sm text-sm text-pretty">Our team reads every message. Tell us what you tried and we will take it from there.</p>
</div>
<ul className="grid gap-3 sm:grid-cols-3">
{channels.map((c) => (
<li key={c.id} className="bg-background/80 flex flex-col gap-3 rounded-2xl border p-4 backdrop-blur-sm">
<span aria-hidden="true" className="bg-muted flex size-9 items-center justify-center rounded-lg [&_svg]:size-4">
{c.icon}
</span>
<div className="flex-1">
<p className="text-sm font-semibold">{c.label}</p>
<p className="text-muted-foreground mt-0.5 flex items-center gap-1.5 text-xs">
{c.online ? <span aria-hidden="true" className="bg-chart-2 size-1.5 shrink-0 rounded-full" /> : null}
{c.online ? <><span className="sr-only">Online.</span>{" "}</> : null}
{c.description}
</p>
</div>
{c.href ? (
<a href={c.href} onClick={() => onContact?.(c)} className="focus-visible:ring-ring/50 hover:bg-accent inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-3 text-[13px] font-medium outline-none focus-visible:ring-[3px]">
{c.action}
</a>
) : (
<Button variant="outline" size="sm" onClick={() => onContact?.(c)}>
{c.action}
</Button>
)}
</li>
))}
</ul>
</div>
</section>
) : null}
</div>
</section>
)
}
export { Help1, type Help1Props, type Help1Category, type Help1Article, type Help1Channel }Installation
$ pnpm dlx shadcn@latest add @ballmac/help-1Install the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/blur-fade @ballmac/button @ballmac/cover-art @ballmac/dot-pattern @ballmac/kbd @ballmac/match-highlight @ballmac/skeleton @ballmac/spotlight-card @ballmac/state-viewCopy the source into your project.
components/ballmac/blocks/help-1/help-1.tsx// Ballmac UI: Help 1. https://ui.ballmac.com/blocks/help-1 "use client" import * as React from "react" import { ArrowRight, BookOpen, Code2, CreditCard, FileSearch, LifeBuoy, MessageCircle, Plug, Rocket, Search, ShieldCheck, Users, X } from "lucide-react" import { BlurFade, BlurFadeGroup } from "@/components/ballmac/blur-fade" import { Button } from "@/components/ballmac/button" import { CoverArt } from "@/components/ballmac/cover-art" import { DotPattern } from "@/components/ballmac/dot-pattern" import { Kbd } from "@/components/ballmac/kbd" import { MatchHighlight } from "@/components/ballmac/match-highlight" import { Skeleton } from "@/components/ballmac/skeleton" import { SpotlightCard } from "@/components/ballmac/spotlight-card" import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view" import { cn } from "@/lib/utils" type Help1Category = { id: string title: string description: string icon?: React.ReactNode /** How many articles it holds. */ count: number href?: string } type Help1Article = { id: string title: string /** One line shown under the title in search. Also searched. */ summary?: string categoryId: string /** Reading time in minutes. */ minutes?: number href?: string /** Listed under Popular articles. */ popular?: boolean } type Help1Channel = { id: string label: string description: string icon?: React.ReactNode /** A green dot and "Online" when true. */ online?: boolean /** The button's text ("Start a chat", "Send an email"). */ action: string href?: string } type Help1Props = Omit<React.ComponentProps<"section">, "title"> & StateProps & { title?: string description?: string categories?: Help1Category[] articles?: Help1Article[] /** Searches shown under the box as shortcuts. */ suggestions?: string[] /** Ways to reach a person. */ channels?: Help1Channel[] /** Called when an article is chosen from search or the Popular list. Without it, articles with an href are followed. */ onOpenArticle?: (article: Help1Article) => void onOpenCategory?: (category: Help1Category) => void onContact?: (channel: Help1Channel) => void /** Called with each search, once typing has paused, for analytics or your own results. */ onSearch?: (query: string) => void /** Text in the search box at first, with its suggestions open. */ defaultQuery?: string } const sampleCategories: Help1Category[] = [ { id: "start", title: "Getting started", description: "Set up your workspace, invite your team and import your data.", icon: <Rocket />, count: 14 }, { id: "billing", title: "Billing and plans", description: "Invoices, seats, payment methods and changing your plan.", icon: <CreditCard />, count: 11 }, { id: "security", title: "Security and privacy", description: "Two-step sign-in, single sign-on, data export and deletion.", icon: <ShieldCheck />, count: 9 }, { id: "integrations", title: "Integrations", description: "Connect chat, calendars, code hosting and your own tools.", icon: <Plug />, count: 18 }, { id: "teams", title: "Teams and roles", description: "Permissions, groups and what each role can see and change.", icon: <Users />, count: 8 }, { id: "api", title: "API and webhooks", description: "Keys, rate limits, events and examples in four languages.", icon: <Code2 />, count: 22 }, ] const sampleArticles: Help1Article[] = [ { id: "a1", title: "Reset your password", summary: "Get back into your account in two minutes.", categoryId: "security", minutes: 2, popular: true }, { id: "a2", title: "Download invoices and receipts", summary: "Find every invoice as a PDF, with your company details.", categoryId: "billing", minutes: 3, popular: true }, { id: "a3", title: "Create and rotate an API key", summary: "Keys are shown once. Here is how to replace one safely.", categoryId: "api", minutes: 4, popular: true }, { id: "a4", title: "Invite teammates and set their role", summary: "Add people by email or by link, and choose what they can do.", categoryId: "teams", minutes: 3, popular: true }, { id: "a5", title: "Turn on two-step sign-in", summary: "Use an authenticator app or a passkey to protect your account.", categoryId: "security", minutes: 3, popular: true }, { id: "a6", title: "Connect your chat workspace", summary: "Post updates to a channel and reply to alerts from chat.", categoryId: "integrations", minutes: 5, popular: true }, { id: "a7", title: "Change or cancel your plan", summary: "What happens to your seats and data when you change plans.", categoryId: "billing", minutes: 4 }, { id: "a8", title: "Import data from a spreadsheet", summary: "Upload a CSV and map its columns in under five minutes.", categoryId: "start", minutes: 6 }, { id: "a9", title: "Webhook events and retries", summary: "Every event we send, its payload and how failed deliveries retry.", categoryId: "api", minutes: 7 }, { id: "a10", title: "Export or delete your data", summary: "Download everything, or permanently remove your workspace.", categoryId: "security", minutes: 4 }, ] const sampleSuggestions = ["Reset password", "Invoices", "API keys", "Two-step sign-in"] const sampleChannels: Help1Channel[] = [ { id: "chat", label: "Live chat", description: "Replies in about 2 minutes", icon: <MessageCircle />, online: true, action: "Start a chat" }, { id: "email", label: "Email", description: "Replies within one working day", icon: <LifeBuoy />, action: "Send an email" }, { id: "docs", label: "Community", description: "Ask other people who use it", icon: <BookOpen />, action: "Open the forum" }, ] const tones = ["bg-chart-1/20", "bg-chart-2/20", "bg-chart-3/25", "bg-chart-4/20", "bg-chart-5/20", "bg-chart-1/20"] function HelpSkeleton() { return ( <div className="grid gap-10" aria-hidden="true"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> {[0, 1, 2, 3, 4, 5].map((i) => ( <Skeleton key={i} className="h-40 rounded-2xl" /> ))} </div> <div className="grid gap-3 sm:grid-cols-2"> {[0, 1, 2, 3].map((i) => ( <Skeleton key={i} className="h-14 rounded-xl" /> ))} </div> </div> ) } /** A link when there is an address, otherwise a button, so one row works for both. */ function Action({ href, onClick, className, children, ...rest }: { href?: string; onClick?: () => void; className?: string; children: React.ReactNode } & Omit<React.ComponentProps<"button">, "onClick">) { if (href) return ( <a href={href} onClick={onClick} className={className}> {children} </a> ) return ( <button type="button" onClick={onClick} className={className} {...rest}> {children} </button> ) } /** * A help center home: a hero with a search that suggests articles as you type (highlighted matches, arrow keys, press / to focus), * categories that light up under the pointer, the most read articles, and ways to reach a person with who is online. */ function Help1({ title = "How can we help?", description = "Search the guides, or browse by topic.", categories = sampleCategories, articles = sampleArticles, suggestions = sampleSuggestions, channels = sampleChannels, onOpenArticle, onOpenCategory, onContact, onSearch, defaultQuery = "", state = "ready", onRetry, emptyState, errorState, className, ...props }: Help1Props) { const id = React.useId() const inputRef = React.useRef<HTMLInputElement>(null) const [query, setQuery] = React.useState(defaultQuery) const [open, setOpen] = React.useState(defaultQuery !== "") const [active, setActive] = React.useState(0) const ready = state === "ready" || state === "empty" const noGuides = state === "empty" || (state === "ready" && !categories.length) const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean) const results = tokens.length ? articles .map((a) => { const haystack = `${a.title} ${a.summary ?? ""}`.toLowerCase() const inTitle = a.title.toLowerCase() return { a, hit: tokens.every((t) => haystack.includes(t)), score: tokens.filter((t) => inTitle.includes(t)).length } }) .filter((r) => r.hit) .sort((x, y) => y.score - x.score) .map((r) => r.a) .slice(0, 6) : [] const showList = open && ready && tokens.length > 0 const categoryName = (cid: string) => categories.find((c) => c.id === cid)?.title React.useEffect(() => setActive(0), [query]) React.useEffect(() => { if (!onSearch || !tokens.length) return const t = setTimeout(() => onSearch(query.trim()), 600) return () => clearTimeout(t) // eslint-disable-next-line react-hooks/exhaustive-deps }, [query]) // "/" jumps to the search from anywhere on the page, as it does on most documentation sites React.useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return const el = e.target as HTMLElement | null if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable)) return e.preventDefault() inputRef.current?.focus() } document.addEventListener("keydown", onKey) return () => document.removeEventListener("keydown", onKey) }, []) function choose(a: Help1Article) { setOpen(false) if (onOpenArticle) onOpenArticle(a) else if (a.href) window.location.assign(a.href) } const popular = articles.filter((a) => a.popular).slice(0, 6) const content = ( <div className="grid gap-14"> <section aria-labelledby={`${id}-cats`}> <h2 id={`${id}-cats`} className="text-xl font-semibold tracking-tight"> Browse by topic </h2> <StateView state={noGuides ? "empty" : "ready"} bare empty={{ title: emptyState?.title ?? "No guides yet", description: emptyState?.description ?? "Help articles will be listed here.", icon: emptyState?.icon ?? <FileSearch />, action: emptyState?.action }} className="mt-5" > <BlurFadeGroup stagger={0.06} className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 [&>*]:h-full [&>*>*]:h-full" item={{ direction: "up", offset: 12, blur: 6 }}> {categories.map((c, i) => ( <SpotlightCard key={c.id} size={300} className="focus-within:ring-ring/50 group/card flex flex-col gap-4 rounded-2xl p-6 transition-shadow duration-200 focus-within:ring-[3px] hover:shadow-md"> <span aria-hidden="true" className={cn("text-foreground flex size-11 items-center justify-center rounded-xl border [&_svg]:size-5", tones[i % tones.length])}> {c.icon ?? <BookOpen />} </span> <div className="flex-1"> <h3 className="text-base font-semibold tracking-tight"> <Action href={c.href} onClick={() => onOpenCategory?.(c)} className="outline-none after:absolute after:inset-0 after:content-['']"> {c.title} </Action> </h3> <p className="text-muted-foreground mt-1.5 text-sm text-pretty">{c.description}</p> </div> <p className="text-muted-foreground flex items-center justify-between text-xs tabular-nums"> <span>{c.count === 1 ? "1 article" : `${c.count} articles`}</span> <ArrowRight aria-hidden="true" className="text-foreground size-4 -translate-x-1 opacity-0 transition-all duration-200 group-hover/card:translate-x-0 group-hover/card:opacity-100 group-focus-within/card:translate-x-0 group-focus-within/card:opacity-100 motion-reduce:transition-none rtl:translate-x-1 rtl:rotate-180 rtl:group-hover/card:translate-x-0 rtl:group-focus-within/card:translate-x-0" /> </p> </SpotlightCard> ))} </BlurFadeGroup> </StateView> </section> {popular.length ? ( <section aria-labelledby={`${id}-popular`}> <h2 id={`${id}-popular`} className="text-xl font-semibold tracking-tight"> Popular articles </h2> <ol className="mt-5 grid gap-x-10 sm:grid-cols-2"> {popular.map((a, i) => ( <li key={a.id} className="border-b"> <Action href={a.href} onClick={() => choose(a)} className="group/row hover:bg-accent/50 focus-visible:ring-ring/50 -mx-3 flex w-[calc(100%+1.5rem)] items-center gap-4 rounded-lg px-3 py-4 text-start outline-none focus-visible:ring-[3px]"> <span aria-hidden="true" className="text-muted-foreground w-5 shrink-0 font-mono text-xs tabular-nums"> {String(i + 1).padStart(2, "0")} </span> <span className="min-w-0 flex-1"> <span className="block text-sm font-medium text-pretty">{a.title}</span> <span className="text-muted-foreground mt-0.5 block text-xs"> {categoryName(a.categoryId)} {a.minutes ? ` · ${a.minutes} min read` : ""} </span> </span> <ArrowRight aria-hidden="true" className="text-muted-foreground group-hover/row:text-foreground size-4 shrink-0 transition-transform duration-200 group-hover/row:translate-x-0.5 motion-reduce:transition-none motion-reduce:group-hover/row:translate-x-0 rtl:rotate-180 rtl:group-hover/row:-translate-x-0.5" /> </Action> </li> ))} </ol> </section> ) : null} </div> ) return ( <section data-slot="help-1" data-state={state} aria-label="Help center" className={cn("mx-auto w-full max-w-6xl px-4 py-8 sm:px-6", className)} {...frameProps(props)}> {/* the suggestions open over the sections below, so the hero is not clipped: only its decoration is */} <div className="bg-card relative isolate z-10 mb-14 rounded-3xl border px-5 py-14 text-center sm:px-10 sm:py-20"> <div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 overflow-hidden rounded-3xl"> <div className="absolute inset-0 bg-[radial-gradient(ellipse_55%_60%_at_50%_30%,color-mix(in_oklch,var(--chart-1)_14%,transparent),transparent)]" /> <DotPattern width={16} height={16} radius={1} radialMask /> </div> <BlurFade direction="up" offset={10} duration={0.5}> <h1 className="mx-auto max-w-2xl text-4xl font-semibold tracking-[-0.04em] text-balance sm:text-5xl">{title}</h1> <p className="text-muted-foreground mx-auto mt-3 max-w-md text-base text-pretty">{description}</p> </BlurFade> <BlurFade direction="up" offset={10} duration={0.5} delay={0.1}> <div className="relative mx-auto mt-8 max-w-2xl text-start"> <div className="bg-background focus-within:border-ring focus-within:ring-ring/40 flex h-14 items-center gap-3 rounded-2xl border px-4 shadow-[0_10px_30px_-14px_rgb(0_0_0/0.3)] transition-shadow focus-within:ring-[3px]"> <Search aria-hidden="true" className="text-muted-foreground size-5 shrink-0" /> <input ref={inputRef} role="combobox" aria-label="Search the help center" aria-expanded={showList} aria-controls={`${id}-results`} aria-activedescendant={showList && results[active] ? `${id}-r-${results[active]!.id}` : undefined} aria-autocomplete="list" aria-describedby={`${id}-hint`} autoComplete="off" spellCheck={false} disabled={!ready} value={query} placeholder="Search for answers" onChange={(e) => { setQuery(e.target.value) setOpen(true) }} onFocus={() => setOpen(true)} onBlur={() => setOpen(false)} onKeyDown={(e) => { if (e.key === "Escape") { if (open && query) setOpen(false) else setQuery("") return } if (!showList || !results.length) return if (e.key === "ArrowDown") { e.preventDefault() setActive((a) => (a + 1) % results.length) } else if (e.key === "ArrowUp") { e.preventDefault() setActive((a) => (a - 1 + results.length) % results.length) } else if (e.key === "Enter" && results[active]) { e.preventDefault() choose(results[active]!) } }} className="placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-base outline-none disabled:opacity-50" /> {query ? ( <button type="button" aria-label="Clear search" onMouseDown={(e) => e.preventDefault()} onClick={() => { setQuery("") inputRef.current?.focus() }} className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/50 flex size-7 items-center justify-center rounded-md outline-none focus-visible:ring-[3px]" > <X aria-hidden="true" className="size-4" /> </button> ) : ( <Kbd className="hidden sm:inline-flex" aria-hidden="true"> / </Kbd> )} </div> <p id={`${id}-hint`} className="sr-only"> Type to see matching articles. Up and down arrows choose one, Enter opens it. Press slash anywhere on the page to jump here. </p> {showList ? ( <div className="bg-popover absolute inset-x-0 top-full z-20 mt-2 overflow-hidden rounded-2xl border p-2 shadow-xl"> {results.length ? ( <ul id={`${id}-results`} role="listbox" aria-label="Matching articles"> {results.map((a, i) => ( <li key={a.id} id={`${id}-r-${a.id}`} role="option" aria-selected={i === active} onMouseDown={(e) => e.preventDefault()} onMouseMove={() => setActive(i)} onClick={() => choose(a)} className={cn("flex cursor-default items-center gap-3 rounded-xl px-3 py-2.5", i === active && "bg-accent")} > <FileSearch aria-hidden="true" className="text-muted-foreground size-4 shrink-0" /> <span className="min-w-0 flex-1"> <span className="block truncate text-sm font-medium"> <MatchHighlight text={a.title} query={tokens} /> </span> {a.summary ? ( <span className="text-muted-foreground block truncate text-xs"> <MatchHighlight text={a.summary} query={tokens} /> </span> ) : null} </span> <span className="text-muted-foreground hidden shrink-0 text-xs sm:block">{categoryName(a.categoryId)}</span> </li> ))} </ul> ) : ( <div id={`${id}-results`} className="px-4 py-8 text-center"> <p className="text-sm font-medium">No articles match “{query.trim()}”</p> <p className="text-muted-foreground mt-1 text-xs text-pretty">Check the spelling, try fewer words, or ask us directly.</p> {channels[0] ? ( <Button variant="outline" size="sm" className="mt-4" onMouseDown={(e) => e.preventDefault()} onClick={() => onContact?.(channels[0]!)}> {channels[0].action} </Button> ) : null} </div> )} </div> ) : null} <p aria-live="polite" className="sr-only"> {showList ? (results.length === 1 ? "1 article found" : `${results.length} articles found`) : ""} </p> </div> {suggestions.length && ready && !showList ? ( <p className="mt-5 flex flex-wrap items-center justify-center gap-2 text-sm"> <span className="text-muted-foreground">Popular:</span> {suggestions.map((s) => ( <button key={s} type="button" onClick={() => { setQuery(s) setOpen(true) inputRef.current?.focus() }} className="hover:bg-accent focus-visible:ring-ring/50 rounded-full border px-3 py-1 text-[13px] outline-none focus-visible:ring-[3px]" > {s} </button> ))} </p> ) : null} </BlurFade> </div> <StateView state={state === "loading" || state === "error" ? state : "ready"} onRetry={onRetry} label="Loading the help center" loading={<HelpSkeleton />} error={{ title: errorState?.title ?? "The help center could not be loaded", description: errorState?.description ?? "Check your connection and try again. You can still reach us below.", ...errorState }} > {content} </StateView> <div className="mt-14"> {channels.length ? ( <section aria-labelledby={`${id}-contact`} className="bg-card relative isolate overflow-hidden rounded-3xl border"> <CoverArt seed="support" variant="mesh" className="absolute inset-0 -z-10 opacity-60" /> <div className="from-card via-card/85 absolute inset-0 -z-10 bg-gradient-to-r to-transparent" aria-hidden="true" /> <div className="grid gap-8 p-6 sm:p-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)] lg:items-center"> <div> <h2 id={`${id}-contact`} className="text-2xl font-semibold tracking-[-0.03em] text-balance"> Still stuck? Talk to a person. </h2> <p className="text-muted-foreground mt-2 max-w-sm text-sm text-pretty">Our team reads every message. Tell us what you tried and we will take it from there.</p> </div> <ul className="grid gap-3 sm:grid-cols-3"> {channels.map((c) => ( <li key={c.id} className="bg-background/80 flex flex-col gap-3 rounded-2xl border p-4 backdrop-blur-sm"> <span aria-hidden="true" className="bg-muted flex size-9 items-center justify-center rounded-lg [&_svg]:size-4"> {c.icon} </span> <div className="flex-1"> <p className="text-sm font-semibold">{c.label}</p> <p className="text-muted-foreground mt-0.5 flex items-center gap-1.5 text-xs"> {c.online ? <span aria-hidden="true" className="bg-chart-2 size-1.5 shrink-0 rounded-full" /> : null} {c.online ? <><span className="sr-only">Online.</span>{" "}</> : null} {c.description} </p> </div> {c.href ? ( <a href={c.href} onClick={() => onContact?.(c)} className="focus-visible:ring-ring/50 hover:bg-accent inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-3 text-[13px] font-medium outline-none focus-visible:ring-[3px]"> {c.action} </a> ) : ( <Button variant="outline" size="sm" onClick={() => onContact?.(c)}> {c.action} </Button> )} </li> ))} </ul> </div> </section> ) : null} </div> </section> ) } export { Help1, type Help1Props, type Help1Category, type Help1Article, type Help1Channel }Update the import paths to match your project setup.
Usage
"use client"
import { Help1 } from "@/components/ballmac/blocks/help-1/help-1"
export default function Help1Demo() {
return <Help1 onOpenArticle={() => {}} onOpenCategory={() => {}} onContact={() => {}} />
}States
Pass state and the block does the rest: skeletons that match its layout, a clear empty message, an error you can retry, and announcements for screen readers.
Loading
Skeleton topic cards and article rows under the real heading; search is disabled.
state="loading"No guides yet
Says guides will be listed here; emptyState sets the copy. The contact strip stays.
state="empty" (or categories={[]})Could not load
An alert with Try again (onRetry). The hero and the contact strip stay, so people can still reach you.
state="error"
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 6 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
Every endless animation stops when the visitor asks for less motion.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (2% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
68 tab stops, each showing a visible change when it takes focus. 3 documented keys.
Passed: Loading, empty and error
Ships loading, empty, error states, each with an example.
Passed: Automated tests
Covered by help-1.test.tsx.
For information: Footprint
7.1 KB of source (gzip) in 1 file. Installs lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Dependencies
- npm
- lucide-react
Use with AI
A help center home. <Help1 categories={[{ id, title, description, icon, count, href }]} articles={[{ id, title, summary, categoryId, minutes, href, popular }]} suggestions={['Reset password']} channels={[{ id, label, description, icon, online, action, href }]} onOpenArticle onOpenCategory onContact onSearch />. Search runs in the browser over title and summary; pass onSearch to log queries or fetch your own results. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Help 1: help center home (@ballmac/help-1) to this project with the shadcn MCP, then use it where it fits.
Use it for
- The front page of a help center or knowledge base
- Self-serve support before people contact you
Not for
- A short list of questions (use faq-1)
- A documentation site's navigation (use sidebar and command)
- A contact form (use contact-1)
Registry JSON: https://ui.ballmac.com/r/help-1.json
Free to use in personal and commercial projects.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.