FAQ 3

Searchable FAQ with category tabs, a live-filtered accordion, an empty state for missed queries and a support CTA strip.

Preview

Installation

npx shadcn@latest add https://hirael.com/r/faq-03.json

Code

components/blocks/faq-03.tsx
"use client";

import * as React from "react";
import { ArrowUpRight, LifeBuoy, Mail, Search, SearchX } from "lucide-react";

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/registry/hirael/ui/accordion";
import { Button } from "@/registry/hirael/ui/button";
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/registry/hirael/ui/empty";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/registry/hirael/ui/input-group";
import { Tabs, TabsList, TabsTrigger } from "@/registry/hirael/ui/tabs";

type Category = "getting-started" | "billing" | "licensing";

const CATEGORIES: { value: Category | "all"; label: string }[] = [
  { value: "all", label: "All" },
  { value: "getting-started", label: "Getting started" },
  { value: "billing", label: "Billing" },
  { value: "licensing", label: "Licensing" },
];

const CATEGORY_LABELS: Record<Category, string> = {
  "getting-started": "Getting started",
  billing: "Billing",
  licensing: "Licensing",
};

const FAQS: readonly { q: string; a: string; category: Category }[] = [
  {
    q: "How do I install my first block?",
    a: "Run the shadcn CLI with the block's registry URL; the source lands in components/blocks/ and is yours to edit. No package to add, nothing to configure.",
    category: "getting-started",
  },
  {
    q: "Do blocks work without the rest of Hirael?",
    a: "Yes. Each block declares its own dependencies, and the CLI resolves only what that block needs. You can install a single FAQ section into an existing app.",
    category: "getting-started",
  },
  {
    q: "Which frameworks are supported?",
    a: "Anywhere React runs: Next.js App or Pages Router, Remix, Vite. Blocks avoid framework-specific APIs unless the block page says otherwise.",
    category: "getting-started",
  },
  {
    q: "Is there a paid tier?",
    a: "Everything currently published is free. If a pro tier ships later, existing blocks stay free and installed copies are unaffected.",
    category: "billing",
  },
  {
    q: "Do you offer team invoicing?",
    a: "There's nothing to invoice today; installs are free and unmetered. For procurement paperwork, contact us and we'll sort something out.",
    category: "billing",
  },
  {
    q: "Can I use blocks in client work?",
    a: "Yes. Copies installed into a client project belong to that codebase. There's no per-seat or per-project license to track.",
    category: "licensing",
  },
  {
    q: "Can I republish blocks as my own library?",
    a: "Shipping products with blocks inside is encouraged; repackaging the registry itself as a competing collection isn't. When in doubt, ask.",
    category: "licensing",
  },
  {
    q: "Do I need to credit Hirael?",
    a: "No attribution required. A mention is appreciated but never a condition of use.",
    category: "licensing",
  },
];

export default function Faq03() {
  const [query, setQuery] = React.useState("");
  const [category, setCategory] = React.useState<Category | "all">("all");

  const normalized = query.trim().toLowerCase();
  const visible = FAQS.filter((f) => {
    if (category !== "all" && f.category !== category) return false;
    if (!normalized) return true;
    return (
      f.q.toLowerCase().includes(normalized) ||
      f.a.toLowerCase().includes(normalized)
    );
  });

  const clearFilters = () => {
    setQuery("");
    setCategory("all");
  };

  return (
    <section className="bg-background py-20 md:py-28">
      <div className="mx-auto flex w-full max-w-3xl flex-col gap-10 px-6 md:px-10">
        <div className="flex flex-col items-center gap-4 text-center">
          <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-foreground">
            help center
          </span>
          <h2 className="max-w-2xl font-serif text-4xl font-medium leading-[1.04] tracking-tight sm:text-5xl md:text-6xl">
            Find the answer before you file the issue.
          </h2>
          <p className="max-w-xl text-sm text-muted-foreground">
            Search the questions we hear most, or narrow by topic. Anything
            unanswered lands in the inbox below.
          </p>
        </div>

        <div className="flex flex-col items-center gap-4">
          <InputGroup className="w-full max-w-md">
            <InputGroupAddon>
              <Search className="size-4" />
            </InputGroupAddon>
            <InputGroupInput
              type="search"
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              placeholder="Search questions…"
              aria-label="Search questions"
            />
          </InputGroup>
          <Tabs
            value={category}
            onValueChange={(v) => setCategory(v as Category | "all")}
            className="w-fit"
          >
            <TabsList>
              {CATEGORIES.map((c) => (
                <TabsTrigger key={c.value} value={c.value}>
                  {c.label}
                </TabsTrigger>
              ))}
            </TabsList>
          </Tabs>
        </div>

        {visible.length > 0 ? (
          <Accordion
            type="single"
            collapsible
            className="border-y border-border"
          >
            {visible.map((f) => (
              <AccordionItem key={f.q} value={f.q} className="px-1">
                <AccordionTrigger>
                  <span className="flex flex-1 items-baseline justify-between gap-4">
                    <span>{f.q}</span>
                    <span className="shrink-0 font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
                      {CATEGORY_LABELS[f.category]}
                    </span>
                  </span>
                </AccordionTrigger>
                <AccordionContent>{f.a}</AccordionContent>
              </AccordionItem>
            ))}
          </Accordion>
        ) : (
          <Empty>
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <SearchX />
              </EmptyMedia>
              <EmptyTitle>No matching questions</EmptyTitle>
              <EmptyDescription>
                Nothing matches &ldquo;{query.trim()}&rdquo;
                {category !== "all"
                  ? ` in ${CATEGORY_LABELS[category as Category]}`
                  : ""}
                . Try different keywords or clear the filters.
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <Button variant="outline" size="sm" onClick={clearFilters}>
                Clear filters
              </Button>
            </EmptyContent>
          </Empty>
        )}

        <div className="flex flex-col items-start justify-between gap-4 rounded-md border border-border bg-card/40 p-5 sm:flex-row sm:items-center sm:p-6">
          <div className="flex items-start gap-3">
            <span className="inline-flex size-9 shrink-0 items-center justify-center rounded-sm border border-border bg-background">
              <LifeBuoy className="size-4" />
            </span>
            <div className="flex flex-col gap-0.5">
              <span className="text-sm font-medium">Still stuck?</span>
              <span className="text-xs text-muted-foreground">
                We answer most questions within a day.
              </span>
            </div>
          </div>
          <div className="flex flex-wrap items-center gap-2">
            <Button variant="outline" size="sm" asChild>
              <a href="mailto:support@hirael.com">
                <Mail className="size-3.5" />
                Email support
              </a>
            </Button>
            <Button size="sm" asChild>
              <a href="#">
                Open an issue
                <ArrowUpRight className="size-3.5" />
              </a>
            </Button>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

shadcn registry

accordionbuttonemptyinput-grouptabs

npm

@radix-ui/react-accordionlucide-react