payload-components
blockcomparator-gridsource: ok · hit
Comparator Grid
Plan-column comparison Payload block: pricing columns in a bordered card over a feature matrix.
npx shadcn add "https://www.payload-components.xyz/r/comparator-grid.json"free
Where it goes
pricingfeature-grid
Use cases
saas-landing
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
badgecard
Files3 · source cached 10/9/2026, 8:11:14 AM
payload-components/source/blocks/shared/comparatorFields.ts
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',
custom: { payloadComponents: { localization: 'localized' } },
},
{
name: 'description',
type: 'textarea',
custom: { payloadComponents: { localization: 'localized' } },
},
]
payload-components/source/blocks/ComparatorGrid/config.ts
import type { Block } from 'payload'
import { comparatorFields } from '@/blocks/shared/comparatorFields'
import { linkGroup } from '@/fields/linkGroup'
export const ComparatorGrid: Block = {
slug: 'comparatorGrid',
// Existing apps must migrate stored data before adopting this identifier:
// https://www.payload-components.xyz/docs/registry#installed-source-and-migrations
dbName: 'pc_com_gri',
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',
custom: { payloadComponents: { localization: 'localized' } },
required: true,
},
{
name: 'price',
type: 'text',
custom: { payloadComponents: { localization: 'global' } },
},
{
name: 'period',
type: 'text',
custom: { payloadComponents: { localization: 'localized' } },
admin: {
description: 'Shown next to the price, e.g. "/month".',
},
},
{
name: 'badge',
type: 'text',
custom: { payloadComponents: { localization: 'localized' } },
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',
custom: { payloadComponents: { localization: 'localized' } },
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',
custom: { payloadComponents: { localization: 'localized' } },
},
],
},
],
},
],
labels: {
plural: 'Comparator Grid Blocks',
singular: 'Comparator Grid',
},
}
payload-components/source/blocks/ComparatorGrid/Component.tsx
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'
// Layout adapted from tailark/blocks (MIT) — re-implemented as a Payload block.
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>
)
}