# Pricing 4

> Editorial split with Starter vs Pro panels — hover focus blurs the inactive tier, hero-1 asymmetry adapted for pricing.

A shadcn/ui block from the [@nusaiba](https://nusaiba.dev) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/nusaiba/pricing-4).

- Page, with a live preview: https://shadcn.exchange/r/nusaiba/pricing-4
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/nusaiba/pricing-4-light-60b356a4ef21ce1f.png
- Registry item: https://nusaiba.dev/r/base-nova/pricing-4.json
- Categories: pricing
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://nusaiba.dev/r/base-nova/pricing-4.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `motion`
- Registry items: `@nusaiba/foundation`, `button`

## Files

### `registry/blocks/pricing/4/pricing-section.tsx`

```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]">
										&nbsp;
									</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>
	);
}
```
