Workspace / block / pricing
Split Pricing
paxfx add blocks/block-pricing-split-08local onlyTier: low. Reduced motion: Receipt updates without transitions.
"use client";
import { useId, useState } from "react";
import { cn, useInView, useReducedMotion, type BlockCopy } from "@pax-sh/blocks";
export interface PricingSplitPlan {
id: string;
name: string;
price: string;
cadence: string;
summary: string;
unlocks: { item: string; detail: string }[];
}
export interface PricingSplitProps {
copy?: BlockCopy;
plans?: PricingSplitPlan[];
className?: string;
}
const DEFAULT_COPY: BlockCopy = {
eyebrow: "Pricing",
title: "Choose a plan, read the receipt",
description: "Select a plan on the left and the right column itemizes exactly what it unlocks.",
primaryCta: { label: "Continue", href: "/docs/installation" },
};
const DEFAULT_PLANS: PricingSplitPlan[] = [
{
id: "solo",
name: "Solo",
price: "$0",
cadence: "forever",
summary: "The open-source core for personal projects.",
unlocks: [
{ item: "Open-source blocks", detail: "Every MIT-licensed section, installed by CLI" },
{ item: "Brand tokens", detail: "Six CSS variables theme the whole library" },
{ item: "Community support", detail: "GitHub discussions, answered in the open" },
],
},
{
id: "studio",
name: "Studio",
price: "$29",
cadence: "per editor / month",
summary: "The full catalog for professional work.",
unlocks: [
{ item: "Everything in Solo", detail: "The complete open-source foundation" },
{ item: "Premium collections", detail: "Hero, pricing, and template sets updated monthly" },
{ item: "MCP registry", detail: "Your coding agent searches and installs blocks" },
{ item: "Priority fixes", detail: "Confirmed defects patched within two days" },
],
},
{
id: "agency",
name: "Agency",
price: "$99",
cadence: "per team / month",
summary: "Client work at volume, one subscription.",
unlocks: [
{ item: "Everything in Studio", detail: "Full catalog for every teammate" },
{ item: "White-label installs", detail: "Ship client projects with no attribution" },
{ item: "Multi-brand tokens", detail: "Keep a token set per client, switch instantly" },
{ item: "Shared registry", detail: "One private catalog for the whole team" },
],
},
];
/**
* A split pricing selector: a real radio group on the left (native inputs,
* arrow-key navigation) drives a live receipt on the right that itemizes
* exactly what the selected plan unlocks. Under reduced motion the receipt
* updates without any transition.
*/
export function PricingSplit({
copy = DEFAULT_COPY,
plans = DEFAULT_PLANS,
className,
}: PricingSplitProps) {
const reduced = useReducedMotion();
const [ref, inView] = useInView<HTMLDivElement>({ threshold: 0.15 });
const settled = reduced || inView;
const uid = useId();
const [selectedId, setSelectedId] = useState(plans[1]?.id ?? plans[0]?.id ?? "");
const selected = plans.find((plan) => plan.id === selectedId) ?? plans[0];
return (
<section
className={cn(
"bg-[var(--pax-paper,#f6f2ea)] px-6 py-20 text-[var(--pax-ink,#12110f)] md:px-10 md:py-28",
className,
)}
data-pax-asset="block-pricing-split-08"
>
{reduced ? null : (
<style>{`
@keyframes pax-pricingsplit-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.pax-pricingsplit-receipt {
animation: pax-pricingsplit-in 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
`}</style>
)}
<div
ref={ref}
className="mx-auto grid max-w-6xl gap-10 md:grid-cols-2 md:gap-16"
style={
reduced
? undefined
: {
opacity: settled ? 1 : 0,
transform: settled ? "translateY(0)" : "translateY(12px)",
transition: "opacity 0.55s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1)",
}
}
>
<div>
{copy.eyebrow ? (
<p className="m-0 text-xs font-medium uppercase tracking-[0.22em] text-[var(--pax-mute,#6b645c)]">
{copy.eyebrow}
</p>
) : null}
<h2 className="mt-3 text-3xl font-semibold tracking-tight [text-wrap:balance] md:text-4xl">
{copy.title}
</h2>
{copy.description ? (
<p className="mt-4 max-w-md text-base leading-relaxed text-[var(--pax-mute,#6b645c)]">
{copy.description}
</p>
) : null}
<fieldset className="mt-10 border-0 p-0">
<legend className="sr-only">Plan</legend>
<div className="space-y-3">
{plans.map((plan) => {
const inputId = `${uid}-plan-${plan.id}`;
const active = plan.id === selectedId;
return (
<label
key={plan.id}
htmlFor={inputId}
className={cn(
"flex cursor-pointer items-center gap-4 rounded-xl border p-5",
!reduced && "transition-colors duration-200",
active
? "border-[var(--pax-ink,#12110f)] bg-[rgba(18,17,15,0.03)]"
: "border-[var(--pax-border,rgba(18,17,15,0.12))] hover:border-[var(--pax-ink,#12110f)]",
)}
>
<input
type="radio"
id={inputId}
name={`${uid}-plan`}
value={plan.id}
checked={active}
onChange={() => setSelectedId(plan.id)}
className="h-4 w-4 shrink-0 accent-[var(--pax-ink,#12110f)]"
/>
<span className="flex-1">
<span className="block text-sm font-semibold">{plan.name}</span>
<span className="mt-0.5 block text-xs text-[var(--pax-mute,#6b645c)]">
{plan.summary}
</span>
</span>
<span className="text-right">
<span className="block text-lg font-semibold [font-variant-numeric:tabular-nums]">
{plan.price}
</span>
<span className="block text-[11px] text-[var(--pax-mute,#6b645c)]">
{plan.cadence}
</span>
</span>
</label>
);
})}
</div>
</fieldset>
</div>
<div
className="self-start rounded-2xl border border-[var(--pax-border,rgba(18,17,15,0.12))] p-8 md:sticky md:top-24"
aria-live="polite"
>
{selected ? (
<div key={selected.id} className={reduced ? undefined : "pax-pricingsplit-receipt"}>
<p className="m-0 text-xs font-medium uppercase tracking-[0.22em] text-[var(--pax-mute,#6b645c)]">
Your receipt
</p>
<div className="mt-4 flex items-baseline justify-between border-b border-[var(--pax-border,rgba(18,17,15,0.12))] pb-4">
<h3 className="m-0 text-xl font-semibold tracking-tight">{selected.name}</h3>
<p className="m-0 text-xl font-semibold [font-variant-numeric:tabular-nums]">
{selected.price}
<span className="ml-1 text-xs font-normal text-[var(--pax-mute,#6b645c)]">
{selected.cadence}
</span>
</p>
</div>
<ul className="m-0 list-none divide-y divide-[var(--pax-border,rgba(18,17,15,0.12))] p-0">
{selected.unlocks.map((unlock) => (
<li key={unlock.item} className="py-4">
<p className="m-0 text-sm font-semibold">{unlock.item}</p>
<p className="mt-1 text-xs leading-relaxed text-[var(--pax-mute,#6b645c)]">
{unlock.detail}
</p>
</li>
))}
</ul>
{copy.primaryCta ? (
<a
href={copy.primaryCta.href}
className="mt-6 block rounded-full bg-[var(--pax-ink,#12110f)] px-6 py-3 text-center text-sm font-semibold text-[var(--pax-paper,#f6f2ea)] outline-offset-4 transition-colors hover:bg-[var(--pax-night,#0b0d10)] focus-visible:outline focus-visible:outline-2"
{...(copy.primaryCta.goal ? { "data-fast-goal": copy.primaryCta.goal } : {})}
>
{copy.primaryCta.label} with {selected.name}
</a>
) : null}
</div>
) : null}
</div>
</div>
</section>
);
}