shadimage
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 · light

Registry 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>
  )
}

More from payload-components

Browse all components in this library →