Faq Grouped
An FAQ split into titled, icon-tagged category groups of accordions.
import type { Block } from 'payload'import { faqFields } from '@/blocks/shared/faqFields'import { iconField } from '@/blocks/shared/faqIcons'export const FaqGrouped: Block = { slug: 'faqGrouped', interfaceName: 'FaqGroupedBlock', fields: [ // Shared FAQ core (eyebrow, title, description). This variant buckets // questions under titled, icon-tagged groups, so it defines a nested // groups array rather than the shared faqItemsField. ...faqFields, { name: 'groups', type: 'array', required: true, minRows: 1, admin: { initCollapsed: true, }, fields: [ iconField, { name: 'title', type: 'text', required: true, }, { name: 'items', type: 'array', required: true, minRows: 1, fields: [ { name: 'question', type: 'text', required: true, }, { name: 'answer', type: 'textarea', required: true, }, ], }, ], }, ], labels: { plural: 'FAQ Grouped Blocks', singular: 'FAQ Grouped', },}import React from 'react'import type { FaqGroupedBlock as FaqGroupedBlockData } from '@/payload-types'import { faqIcons } from '@/blocks/shared/faqIcons'import { Accordion, AccordionContent, AccordionItem, AccordionTrigger,} from '@/components/ui/accordion'import { Badge } from '@/components/ui/badge'import { cn } from '@/utilities/ui'type Props = FaqGroupedBlockData & { id?: string className?: string disableInnerContainer?: boolean}export const FaqGroupedBlock: React.FC<Props> = ({ className, description, disableInnerContainer, eyebrow, groups, id, title,}) => { return ( <section className={cn('container', className)} id={id ? `block-${id}` : undefined}> <div className="overflow-hidden rounded-frame border border-border/70 bg-card/35 px-6 py-10 sm:px-8 lg:px-12 lg:py-14"> <div className={cn('flex flex-col gap-12', { 'mx-auto max-w-3xl': !disableInnerContainer, })} > <div className="flex flex-col gap-4 text-center"> {eyebrow ? ( <Badge variant="outline" className="mx-auto w-fit rounded-full px-3 py-1 uppercase tracking-eyebrow"> {eyebrow} </Badge> ) : null} <h2 className="text-4xl font-medium tracking-display text-balance sm:text-5xl">{title}</h2> {description ? ( <p className="text-base leading-7 text-muted-foreground sm:text-lg">{description}</p> ) : null} </div> {groups && groups.length > 0 ? ( <div className="flex flex-col gap-10"> {groups.map((group, groupIndex) => { const Icon = group.icon ? faqIcons[group.icon] : null return ( <div className="flex flex-col gap-4" key={group.id ?? `${group.title}-${groupIndex}`}> <div className="flex items-center gap-3"> {Icon ? <Icon className="size-5 text-muted-foreground" /> : null} <h3 className="text-lg font-medium tracking-title">{group.title}</h3> </div> <Accordion type="single" collapsible className="w-full"> {group.items?.map((item, index) => ( <AccordionItem key={item.id ?? `${item.question}-${index}`} value={item.id ?? `group-${groupIndex}-item-${index}`} className="border-border/70" > <AccordionTrigger className="text-left text-base tracking-title hover:no-underline"> {item.question} </AccordionTrigger> <AccordionContent className="text-sm leading-7 text-muted-foreground"> {item.answer} </AccordionContent> </AccordionItem> ))} </Accordion> </div> ) })} </div> ) : null} </div> </div> </section> )}import type { Field } from 'payload'/** * Shared field core for the FAQ component family. * * Every FAQ variant (faq-accordion, faq-split, faq-card, faq-icons, * faq-grouped, faq-grid) spreads these section-heading fields first and then * appends its own variant-specific shape — the question/answer items, which * differ per layout (flat list, icon-tagged, or grouped). Editing the shared * eyebrow/title/description here updates every installed FAQ block at once, so * the family never drifts field-by-field across a repo. * * Installed once per repo at `src/blocks/shared/faqFields.ts`; re-running * `payload-components add faq-*` never overwrites a copy you have already edited. */export const faqFields: Field[] = [ { name: 'eyebrow', type: 'text', }, { name: 'title', type: 'text', required: true, }, { name: 'description', type: 'textarea', },]/** * Reusable question/answer array for the plain FAQ variants (faq-accordion, * faq-split, faq-card, faq-grid). The icon and grouped variants define their * own item shape, so they compose `faqFields` but not this field. */export const faqItemsField: Field = { name: 'items', type: 'array', required: true, minRows: 1, admin: { initCollapsed: true, }, fields: [ { name: 'question', type: 'text', required: true, }, { name: 'answer', type: 'textarea', required: true, }, ],}import type { Field } from 'payload'import type { LucideIcon } from 'lucide-react'import { Clock, CreditCard, Globe, HelpCircle, Package, Truck } from 'lucide-react'/** * Shared icon allowlist for the icon-bearing FAQ variants * (faq-icons, faq-grouped). * * Payload cannot store a React component, so each FAQ item (or group) picks an * icon by name from this fixed allowlist (`iconField`, a `select`) and the * frontend looks the name up in `faqIcons` at render. Keeping the options and * the component map in one file means the editor choices can never drift from * what actually renders — add an icon by adding it to both `faqIconOptions` * and `faqIcons` together. * * Installed once per repo at `src/blocks/shared/faqIcons.ts`; re-running * `payload-components add faq-*` never overwrites a copy you have already edited. */export const faqIconOptions = ['clock', 'credit-card', 'truck', 'globe', 'package', 'help-circle'] as constexport type FaqIconName = (typeof faqIconOptions)[number]export const iconField: Field = { name: 'icon', type: 'select', options: [ { label: 'Clock', value: 'clock' }, { label: 'Credit card', value: 'credit-card' }, { label: 'Truck', value: 'truck' }, { label: 'Globe', value: 'globe' }, { label: 'Package', value: 'package' }, { label: 'Help circle', value: 'help-circle' }, ],}export const faqIcons: Record<FaqIconName, LucideIcon> = { clock: Clock, 'credit-card': CreditCard, truck: Truck, globe: Globe, package: Package, 'help-circle': HelpCircle,}Installation
npx payload-components add faq-groupedCopy the files straight from the registry, then wire the Payload fragments by hand:
pnpm dlx shadcn@latest add https://www.payload-components.xyz/r/faq-grouped.jsonWhat it installs
Copies 4 source files into your project:
src/blocks/shared/faqFields.tssharedsrc/blocks/shared/faqIcons.tssrc/blocks/FaqGrouped/config.tssrc/blocks/FaqGrouped/Component.tsx
…and makes 4 edits to wire the block into your project:
| Registers the block | src/collections/Pages/index.ts |
| Maps the renderer | src/blocks/RenderBlocks.tsx |
| Regenerates types | src/payload-types.ts |
| Regenerates the admin import map | src/app/(payload)/admin/importMap.js |
faqFields.ts is the shared field core for this family — every variant composes it. Editing it updates each installed block at once, and re-running an install never overwrites a copy you have changed.Re-running the install converges: it detects existing wiring, skips it, and records install state in .payload-components/state.json.
Content model
The first three fields come from the shared faqFields base; groups is specific to this
variant. Each group's icon is a select over the faqIcons allowlist — Payload stores the
name and the frontend looks up the matching lucide mark at render.
Prop
Type
Each group in groups carries:
Prop
Type
Usage
FaqGrouped block to its layout.RenderBlocks on the frontend, fully typed — no extra wiring.Requirements
- Target
- payload-website-starter
- Payload
- v3
- Next.js
- 15 / 16
- shadcn UI
- accordion, badge
Your project must already expose components.json, src/payload.config.ts, src/blocks/RenderBlocks.tsx, src/collections/Pages/index.ts — the surfaces payload-components add patches. The CLI verifies this against the support matrix before touching anything.
In this family
faq-accordionfaq-splitfaq-cardfaq-iconsfaq-groupedcurrentfaq-grid