Pricing Split
A two-plan split layout — a compact entry plan beside an expanded featured plan with its full feature list.
import type { Block } from 'payload'import { planFields, pricingFields } from '@/blocks/shared/pricingFields'export const PricingSplit: Block = { slug: 'pricingSplit', interfaceName: 'PricingSplitBlock', fields: [ // Shared pricing heading (eyebrow, title, description). Variant-specific // fields follow; edit the shared shape in @/blocks/shared/pricingFields. ...pricingFields, { name: 'plans', type: 'array', required: true, // Exactly two plans: the entry plan on the left, the featured plan // (mark one `featured`) expanded across the right panel. minRows: 2, maxRows: 2, admin: { initCollapsed: true, }, fields: planFields, }, ], labels: { plural: 'Pricing Split Blocks', singular: 'Pricing Split', },}import { Check } from 'lucide-react'import React from 'react'import type { PricingSplitBlock as PricingSplitBlockData } from '@/payload-types'import { CMSLink } from '@/components/Link'import { Badge } from '@/components/ui/badge'import { cn } from '@/utilities/ui'type Props = PricingSplitBlockData & { id?: string className?: string disableInnerContainer?: boolean}export const PricingSplitBlock: React.FC<Props> = ({ className, description, disableInnerContainer, eyebrow, id, plans, title,}) => { // The featured plan takes the expanded right panel; the other becomes the // entry plan on the left. Falls back to source order when none is marked. const highlight = plans?.find((plan) => plan.featured) ?? plans?.[1] const entry = plans?.find((plan) => plan !== highlight) ?? plans?.[0] 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-5xl': !disableInnerContainer, })} > <div className="mx-auto flex max-w-2xl 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> {entry && highlight ? ( <div className="grid gap-6 md:grid-cols-5 md:gap-0"> <div className="flex flex-col justify-between gap-8 rounded-frame border border-border/70 p-6 md:col-span-2 md:my-2 md:rounded-r-none md:border-r-0 lg:p-10"> <div className="flex flex-col gap-4"> <div className="flex flex-col gap-1"> <h3 className="font-medium tracking-title">{entry.name}</h3> <div className="my-3 flex items-baseline gap-1"> <span className="text-2xl font-semibold">{entry.price}</span> {entry.period ? ( <span className="text-sm text-muted-foreground">{entry.period}</span> ) : null} </div> {entry.description ? ( <p className="text-sm text-muted-foreground">{entry.description}</p> ) : null} </div> {entry.links && entry.links.length > 0 ? ( <div className="flex flex-col gap-2"> {entry.links.map(({ link }, linkIndex) => ( <CMSLink key={linkIndex} className="w-full" {...link} appearance="outline" /> ))} </div> ) : null} <hr className="border-dashed" /> {entry.features && entry.features.length > 0 ? ( <ul className="flex flex-col gap-3 text-sm"> {entry.features.map((item, featureIndex) => ( <li key={item.id ?? `${item.feature}-${featureIndex}`} className="flex items-center gap-2" > <Check aria-hidden="true" className="size-3" /> {item.feature} </li> ))} </ul> ) : null} </div> </div> <div className="rounded-frame border border-border/70 bg-background/60 p-6 shadow-none md:col-span-3 lg:p-10"> <div className="grid gap-6 sm:grid-cols-2"> <div className="flex flex-col gap-4"> <div className="flex flex-col gap-1"> <div className="flex items-center gap-2"> <h3 className="font-medium tracking-title">{highlight.name}</h3> {highlight.featured ? ( <Badge className="rounded-full bg-brand px-3 py-1 text-xs font-medium text-brand-foreground"> Popular </Badge> ) : null} </div> <div className="my-3 flex items-baseline gap-1"> <span className="text-2xl font-semibold">{highlight.price}</span> {highlight.period ? ( <span className="text-sm text-muted-foreground">{highlight.period}</span> ) : null} </div> {highlight.description ? ( <p className="text-sm text-muted-foreground">{highlight.description}</p> ) : null} </div> {highlight.links && highlight.links.length > 0 ? ( <div className="flex flex-col gap-2"> {highlight.links.map(({ link }, linkIndex) => ( <CMSLink key={linkIndex} className="w-full" {...link} appearance="default" /> ))} </div> ) : null} </div> <div className="flex flex-col gap-4"> {highlight.features && highlight.features.length > 0 ? ( <ul className="flex flex-col gap-3 text-sm"> {highlight.features.map((item, featureIndex) => ( <li key={item.id ?? `${item.feature}-${featureIndex}`} className="flex items-center gap-2" > <Check aria-hidden="true" className="size-3" /> {item.feature} </li> ))} </ul> ) : null} </div> </div> </div> </div> ) : null} </div> </div> </section> )}import type { Field } from 'payload'import { linkGroup } from '@/fields/linkGroup'/** * Shared field core for the Pricing component family. * * Every pricing variant (pricing-cards, pricing-cards-muted, pricing-cards-cta, * pricing-split, pricing-enterprise) spreads `pricingFields` for the section * heading and reuses `planFields` for the editable plan array. Editing the * shared shape here updates every installed pricing block at once, so the * family never drifts field-by-field across a repo. * * Each plan carries its own price, period, blurb, a `featured` flag (which * drives the emerald "Popular" highlight), a list of included features, and a * single CTA link group — so editors manage the whole pricing table from the * admin instead of shipping hardcoded copy. * * The layout structure is adapted from tailark/blocks (MIT) pricing blocks, * retokenized onto this repo's monochrome + emerald design system. * * Installed once per repo at `src/blocks/shared/pricingFields.ts`; re-running * `payload-components add pricing-*` never overwrites a copy you have already edited. */export const pricingFields: Field[] = [ { name: 'eyebrow', type: 'text', }, { name: 'title', type: 'text', required: true, }, { name: 'description', type: 'textarea', },]/** * Subfields for one plan, reused as the `fields` of every variant's `plans` * array. `period` is optional (a one-off enterprise price may omit "/ mo"), * `featured` marks the highlighted column, and `link` is a single CTA group. */export const planFields: Field[] = [ { name: 'name', type: 'text', required: true, }, { name: 'price', type: 'text', required: true, }, { name: 'period', type: 'text', }, { name: 'description', type: 'text', }, { name: 'featured', type: 'checkbox', defaultValue: false, }, { name: 'features', type: 'array', required: true, minRows: 1, maxRows: 12, admin: { initCollapsed: true, }, fields: [ { name: 'feature', type: 'text', required: true, }, ], }, linkGroup({ overrides: { admin: { initCollapsed: true, }, maxRows: 1, }, }),]Installation
npx payload-components add pricing-splitCopy the files straight from the registry, then wire the Payload fragments by hand:
pnpm dlx shadcn@latest add https://www.payload-components.xyz/r/pricing-split.jsonWhat it installs
Copies 3 source files into your project:
src/blocks/shared/pricingFields.tssharedsrc/blocks/PricingSplit/config.tssrc/blocks/PricingSplit/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 |
pricingFields.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 pricingFields base; plans is built from the
shared planFields. This variant expects exactly two plans: the first renders as the compact
entry column on the left, the second as the expanded panel on the right (mark it featured
for the emerald "Popular" badge).
Prop
Type
Each item in plans carries:
Prop
Type
Usage
PricingSplit 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
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
pricing-cardspricing-cards-mutedpricing-cards-ctapricing-splitcurrentpricing-enterpriseLayout adapted from tailark/blocks (MIT), retokenized onto the Payload Components design system.