Comparator Grid
A plan-column comparison card — pricing columns in one bordered card over a feature matrix, with a highlighted column and per-plan CTAs.
import type { Block } from 'payload'import { comparatorFields } from '@/blocks/shared/comparatorFields'import { linkGroup } from '@/fields/linkGroup'export const ComparatorGrid: Block = { slug: 'comparatorGrid', interfaceName: 'ComparatorGridBlock', fields: [ // Shared comparator header (title, description). Variant-specific fields // follow; edit the shared shape in @/blocks/shared/comparatorFields. ...comparatorFields, { name: 'plans', type: 'array', required: true, minRows: 2, maxRows: 4, admin: { initCollapsed: true, }, fields: [ { name: 'name', type: 'text', required: true, }, { name: 'price', type: 'text', }, { name: 'period', type: 'text', admin: { description: 'Shown next to the price, e.g. "/month".', }, }, { name: 'badge', type: 'text', admin: { description: 'Optional pill above the plan name, e.g. "Most popular".', }, }, { name: 'highlighted', type: 'checkbox', admin: { description: 'Tints this column to draw the eye to the recommended plan.', }, }, linkGroup({ overrides: { maxRows: 1, }, }), ], }, { name: 'features', type: 'array', required: true, minRows: 1, admin: { initCollapsed: true, }, fields: [ { name: 'feature', type: 'text', required: true, }, { name: 'values', type: 'array', admin: { description: 'One cell per plan, in the same order as Plans. Tick "included" for a checkmark, or set a label for a text value.', }, fields: [ { name: 'included', type: 'checkbox', }, { name: 'label', type: 'text', }, ], }, ], }, ], labels: { plural: 'Comparator Grid Blocks', singular: 'Comparator Grid', },}import React from 'react'import { Check, Minus } from 'lucide-react'import type { ComparatorGridBlock as ComparatorGridBlockData } from '@/payload-types'import { CMSLink } from '@/components/Link'import { Badge } from '@/components/ui/badge'import { Card } from '@/components/ui/card'import { cn } from '@/utilities/ui'type Props = ComparatorGridBlockData & { id?: string className?: string disableInnerContainer?: boolean}export const ComparatorGridBlock: React.FC<Props> = ({ className, description, disableInnerContainer, features, id, plans, title,}) => { const columns = `minmax(8rem, 1.4fr) repeat(${plans?.length ?? 0}, minmax(0, 1fr))` 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-10', { 'mx-auto max-w-4xl': !disableInnerContainer, })} > {title || description ? ( <div className="flex flex-col items-center gap-4 text-center"> {title ? ( <h2 className="text-4xl font-medium tracking-display text-balance sm:text-5xl">{title}</h2> ) : null} {description ? ( <p className="max-w-xl text-base leading-7 text-muted-foreground sm:text-lg">{description}</p> ) : null} </div> ) : null} {plans && plans.length > 0 ? ( <Card className="overflow-x-auto border-border/70 bg-background/60 p-0 shadow-none"> <div className="min-w-[36rem]"> <div className="grid items-end border-b border-border/70" style={{ gridTemplateColumns: columns }} > <div className="p-4" /> {plans.map((plan, planIndex) => ( <div key={plan.id ?? planIndex} className={cn('flex flex-col gap-2 p-4 text-center', { 'bg-primary/5': plan.highlighted, })} > {plan.badge ? ( <Badge variant="outline" className="mx-auto rounded-full px-2 py-0.5 text-xs uppercase tracking-eyebrow" > {plan.badge} </Badge> ) : null} <span className="text-base font-medium text-foreground">{plan.name}</span> <span className="flex items-baseline justify-center gap-1"> <span className="text-2xl font-medium tracking-title text-foreground">{plan.price}</span> {plan.period ? <span className="text-sm text-muted-foreground">{plan.period}</span> : null} </span> </div> ))} </div> {features?.map((row, rowIndex) => ( <div key={row.id ?? rowIndex} className="grid items-center border-b border-border/70 last:border-b-0" style={{ gridTemplateColumns: columns }} > <div className="p-4 text-sm text-muted-foreground">{row.feature}</div> {plans.map((plan, planIndex) => { const cell = row.values?.[planIndex] return ( <div key={plan.id ?? planIndex} className={cn('flex items-center justify-center p-4 text-sm text-foreground', { 'bg-primary/5': plan.highlighted, })} > {cell?.included ? ( <Check aria-label="Included" className="size-4 text-primary" role="img" /> ) : cell?.label ? ( <span>{cell.label}</span> ) : ( <Minus aria-label="Not included" className="size-4 text-muted-foreground" role="img" /> )} </div> ) })} </div> ))} <div className="grid border-t border-border/70" style={{ gridTemplateColumns: columns }}> <div className="p-4" /> {plans.map((plan, planIndex) => ( <div key={plan.id ?? planIndex} className={cn('p-4', { 'bg-primary/5': plan.highlighted, })} > {plan.links && plan.links.length > 0 ? ( <div className="flex flex-col gap-2"> {plan.links.map(({ link }, linkIndex) => ( <CMSLink key={linkIndex} appearance={link.appearance === 'outline' ? 'outline' : 'default'} {...link} /> ))} </div> ) : null} </div> ))} </div> </div> </Card> ) : null} </div> </div> </section> )}import type { Field } from 'payload'/** * Shared field core for the Comparator component family. * * Every comparator variant (comparator-table, comparator-grid, comparator-stack) * spreads this optional section header first and then appends its own plan/feature * shape — the feature matrix for the table and grid layouts, or the per-plan * checklists for the stacked layout. Editing the shared title/description here * updates every installed comparator block at once, so the family never drifts * field-by-field across a repo. * * Installed once per repo at `src/blocks/shared/comparatorFields.ts`; re-running * `payload-components add comparator-*` never overwrites a copy you have already edited. */export const comparatorFields: Field[] = [ { name: 'title', type: 'text', }, { name: 'description', type: 'textarea', },]Installation
npx payload-components add comparator-gridCopy the files straight from the registry, then wire the Payload fragments by hand:
pnpm dlx shadcn@latest add https://www.payload-components.xyz/r/comparator-grid.jsonWhat it installs
Copies 3 source files into your project:
src/blocks/shared/comparatorFields.tssharedsrc/blocks/ComparatorGrid/config.tssrc/blocks/ComparatorGrid/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 |
comparatorFields.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
title and description come from the shared Comparator family base
(src/blocks/shared/comparatorFields.ts); the priced plans columns and the features matrix are
specific to this variant. Each feature row holds one values cell per plan, in the same order as
plans — keep the two aligned.
Prop
Type
Each item in plans carries:
Prop
Type
Each item in features carries:
Prop
Type
Usage
ComparatorGrid 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
- badge, card
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
comparator-tablecomparator-gridcurrentcomparator-stack