Workspace / block / pricing

Split Pricing

Plan selector on the left, a live receipt on the right that itemizes exactly what the selected plan unlocks.

Workspaceblockpricing

paxfx add blocks/block-pricing-split-08local only

Tier: 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>
  );
}