Editorial split with Starter vs Pro panels — hover focus blurs the inactive tier, hero-1 asymmetry adapted for pricing.
@nusaiba
registry/blocks/pricing/4/pricing-section.tsx
"use client";
import { useState } from "react";
import { CheckIcon } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { Button } from "@/components/ui/button";
import { defaultSpring, motionStagger, motionViewport } from "@/lib/motion-tokens";
import { cn } from "@/lib/utils";
import { SectionShell, ContentRail } from "@/components/layout-contract";
type PlanSlug = "starter" | "pro";
const plans = [
{
slug: "starter" as const,
name: "Starter",
price: "$0",
period: "forever",
description: "Install every free block with the shadcn CLI — no token required.",
features: ["All free registry blocks", "Copy prompt per block", "Live preview"],
},
{
slug: "pro" as const,
name: "Pro",
price: "$147",
period: "once",
description: "Unlock the Pro catalog, commercial license, and private registry token.",
features: ["Every Pro block", "Registry token", "Commercial license", "Lifetime updates"],
popular: true,
},
];
function planMotion(isActive: boolean, hasFocus: boolean, reduceMotion: boolean) {
if (!hasFocus) return { opacity: 1, filter: "blur(0px)", scale: 1 };
if (reduceMotion) return { opacity: isActive ? 1 : 0.5, scale: 1 };
if (isActive) return { opacity: 1, filter: "blur(0px)", scale: 1 };
return { opacity: 0.42, filter: "blur(2px)", scale: 0.98 };
}
export function PricingSplitSection() {
const reduceMotion = useReducedMotion();
const [hovered, setHovered] = useState<PlanSlug | null>(null);
const [selected, setSelected] = useState<PlanSlug>("pro");
const focused = hovered ?? selected;
return (
<SectionShell spacingMode="section" className="bg-background">
<ContentRail maxWidth="max-w-5xl" className="grid gap-10 lg:grid-cols-5 lg:gap-14">
<div className="max-w-md space-y-2 lg:col-span-2 lg:pt-2">
<p className="font-medium text-primary text-xs uppercase tracking-[0.22em]">
Choose
</p>
<h2 className="text-balance font-medium text-2xl tracking-tight md:text-4xl">
Free or Pro
</h2>
<p className="text-pretty text-muted-foreground text-sm md:text-base">
Most teams start on free blocks. Upgrade once when you need Pro sections,
motion depth, and a registry token.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:col-span-3">
{plans.map((plan, index) => {
const isActive = focused === plan.slug;
const hasFocus = hovered !== null;
return (
<motion.article
animate={planMotion(isActive, hasFocus, !!reduceMotion)}
className={cn(
"relative flex flex-col rounded-[var(--radius-md)] border p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)] transition-[box-shadow] md:p-6 dark:shadow-[0_1px_2px_rgba(0,0,0,0.2)]",
plan.popular
? "border-foreground/12 bg-gradient-to-b from-primary/[0.05] to-background"
: "border-border bg-background",
isActive &&
hasFocus &&
"shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_8px_24px_rgba(0,0,0,0.04)] dark:shadow-[0_0_0_1px_rgba(255,255,255,0.08),0_8px_24px_rgba(0,0,0,0.2)]",
)}
initial={reduceMotion ? false : { opacity: 0, y: 12, filter: "blur(4px)" }}
key={plan.slug}
onClick={() => setSelected(plan.slug)}
onMouseEnter={() => setHovered(plan.slug)}
onMouseLeave={() => setHovered(null)}
role="button"
tabIndex={0}
transition={{
...defaultSpring,
delay: index * motionStagger.item,
}}
viewport={motionViewport}
whileInView={reduceMotion ? undefined : { opacity: 1, y: 0, filter: "blur(0px)" }}
>
{plan.popular ? (
<p className="mb-2 font-medium text-[10px] text-primary uppercase tracking-[0.2em]">
Popular
</p>
) : (
<p aria-hidden className="mb-2 invisible font-medium text-[10px] uppercase tracking-[0.2em]">
</p>
)}
<p className="font-medium text-sm">{plan.name}</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="font-medium text-3xl tabular-nums tracking-tight">
{plan.price}
</span>
<span className="text-muted-foreground text-xs">{plan.period}</span>
</div>
<p className="mt-3 text-pretty text-muted-foreground text-sm leading-relaxed">
{plan.description}
</p>
<ul className="mt-5 flex-1 space-y-2.5">
{plan.features.map((feature) => (
<li className="flex items-start gap-2 text-sm" key={feature}>
<CheckIcon className="mt-0.5 size-4 shrink-0 text-primary" />
{feature}
</li>
))}
</ul>
<Button
className="mt-6 w-full active:scale-[0.96]"
variant={plan.popular ? "default" : "outline"}
>
{plan.slug === "starter" ? "Browse free blocks" : "Get Pro access"}
</Button>
</motion.article>
);
})}
</div>
</ContentRail>
</SectionShell>
);
}






























