Center-stage plan panel with chip selector, billing toggle, and blur handoff — witness motion adapted for pricing.
@nusaiba
registry/blocks/pricing/2/pricing-section.tsx
"use client";
import { useCallback, useEffect, useState } from "react";
import { CheckIcon } from "lucide-react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react";
import { Badge } from "@/components/ui/badge";
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 BillingPeriod = "monthly" | "annual";
type Plan = {
name: string;
tag: string;
description: string;
features: string[];
popular?: boolean;
custom?: boolean;
monthly: { amount: string; suffix: string };
annual: { amount: string; suffix: string };
};
const plans: Plan[] = [
{
name: "Starter",
tag: "solo",
description: "Install free blocks, wire your own backend, ship at your pace.",
features: ["All free registry blocks", "Shadcn CLI install", "Community support"],
monthly: { amount: "$0", suffix: "forever" },
annual: { amount: "$0", suffix: "forever" },
},
{
name: "Pro",
tag: "team",
description: "Pro blocks, lifetime updates, and a token for private registry installs.",
features: [
"Every Pro block",
"Lifetime catalog updates",
"Commercial license",
"Registry token",
],
popular: true,
monthly: { amount: "$147", suffix: "once" },
annual: { amount: "$117", suffix: "once · save 20%" },
},
{
name: "Enterprise",
tag: "org",
description: "Seats, SSO, and a motion audit for teams standardizing on the registry.",
features: [
"Everything in Pro",
"Team workspace",
"Role-based access",
"Priority support",
],
custom: true,
monthly: { amount: "Custom", suffix: "annual contract" },
annual: { amount: "Custom", suffix: "annual contract" },
},
];
const CYCLE_MS = 4200;
const DEFAULT_ACTIVE = 1;
function BillingToggle({
period,
onChange,
reduceMotion,
}: {
period: BillingPeriod;
onChange: (period: BillingPeriod) => void;
reduceMotion: boolean;
}) {
return (
<div
className="relative inline-flex rounded-full border border-border bg-muted/30 p-1"
role="group"
aria-label="Billing period"
>
{(["monthly", "annual"] as const).map((option) => {
const isActive = period === option;
return (
<button
aria-pressed={isActive}
className={cn(
"relative z-10 rounded-full px-4 py-2 font-medium text-sm transition-colors active:scale-[0.96]",
"after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
isActive ? "text-foreground" : "text-muted-foreground",
)}
key={option}
onClick={() => onChange(option)}
type="button"
>
{isActive ? (
reduceMotion ? (
<span className="absolute inset-0 -z-10 rounded-full bg-background shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.25)]" />
) : (
<motion.span
className="absolute inset-0 -z-10 rounded-full bg-background shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.25)]"
layoutId="pricing-2-billing-pill"
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
/>
)
) : null}
{option === "monthly" ? "Monthly" : "Annual"}
</button>
);
})}
</div>
);
}
function PlanPrice({
plan,
period,
reduceMotion,
large,
}: {
plan: Plan;
period: BillingPeriod;
reduceMotion: boolean;
large?: boolean;
}) {
const pricing = period === "monthly" ? plan.monthly : plan.annual;
return (
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
animate={reduceMotion ? undefined : { opacity: 1, y: 0, filter: "blur(0px)" }}
className={cn(
"font-medium tabular-nums tracking-tight",
large ? "text-4xl md:text-5xl" : "text-2xl",
)}
exit={
reduceMotion
? undefined
: {
opacity: 0,
y: -6,
filter: "blur(4px)",
transition: { duration: 0.15, ease: "easeIn" },
}
}
initial={reduceMotion ? false : { opacity: 0, y: 8, filter: "blur(4px)" }}
key={`${plan.name}-${period}-${pricing.amount}`}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
{pricing.amount}
</motion.span>
</AnimatePresence>
<span className="text-muted-foreground text-sm">{pricing.suffix}</span>
</div>
);
}
function PlanChip({
plan,
isActive,
onSelect,
reduceMotion,
}: {
plan: Plan;
isActive: boolean;
onSelect: () => void;
reduceMotion: boolean;
}) {
return (
<button
aria-label={`Show ${plan.name} plan`}
aria-pressed={isActive}
className={cn(
"relative flex min-h-10 items-center gap-2 rounded-[var(--radius-md)] px-3 py-2 transition-[background-color,box-shadow,transform] active:scale-[0.96]",
"after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
isActive
? "bg-muted/50 shadow-[0_0_0_1px_rgba(0,0,0,0.06)] dark:shadow-[0_0_0_1px_rgba(255,255,255,0.08)]"
: "bg-transparent hover:bg-muted/25",
)}
onClick={onSelect}
type="button"
>
<motion.span
animate={
reduceMotion
? undefined
: {
opacity: isActive ? 1 : 0.5,
filter: isActive ? "blur(0px)" : "blur(1px)",
scale: isActive ? 1 : 0.94,
}
}
className="font-mono text-[10px] text-muted-foreground uppercase tracking-[0.14em]"
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
{plan.tag}
</motion.span>
<span className="font-medium text-sm">{plan.name}</span>
</button>
);
}
export function PricingToggleSection() {
const reduceMotion = useReducedMotion();
const [rootNode, setRootNode] = useState<HTMLElement | null>(null);
const isInView = useInView(
{ current: rootNode },
{ ...motionViewport, amount: 0.35 },
);
const [period, setPeriod] = useState<BillingPeriod>("monthly");
const [active, setActive] = useState(DEFAULT_ACTIVE);
const [cycleActive, setCycleActive] = useState(false);
const activePlan = plans[active]!;
useEffect(() => {
if (!isInView || reduceMotion) return;
setCycleActive(true);
}, [isInView, reduceMotion]);
useEffect(() => {
if (!cycleActive || reduceMotion) return;
const timer = setInterval(() => {
setActive((current) => (current + 1) % plans.length);
}, CYCLE_MS);
return () => clearInterval(timer);
}, [cycleActive, reduceMotion]);
const selectPlan = useCallback((index: number) => {
setActive(index);
setCycleActive(false);
setTimeout(() => setCycleActive(true), CYCLE_MS);
}, []);
return (
<SectionShell spacingMode="section" className="bg-background" ref={setRootNode}>
<ContentRail maxWidth="max-w-3xl" className="space-y-10 md:space-y-12">
<div className="mx-auto max-w-xl space-y-6 text-center">
<div className="space-y-2">
<p className="font-medium text-primary text-xs uppercase tracking-[0.22em]">
Billing
</p>
<h2 className="text-balance font-medium text-2xl tracking-tight md:text-4xl">
One plan in focus
</h2>
<p className="text-pretty text-muted-foreground text-sm md:text-base">
Cycle through tiers or pick a chip — prices shift when you toggle annual
billing.
</p>
</div>
<BillingToggle
onChange={setPeriod}
period={period}
reduceMotion={!!reduceMotion}
/>
</div>
<div className="relative">
<motion.div
animate={
reduceMotion
? undefined
: { opacity: [0.35, 0.55, 0.35], scale: [1, 1.03, 1] }
}
aria-hidden
className="pointer-events-none absolute inset-x-6 top-1/2 -z-10 h-48 -translate-y-1/2 rounded-full bg-primary/10 blur-3xl md:inset-x-12 md:h-56"
transition={{
duration: 4.5,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
/>
<motion.div
className="relative overflow-hidden border border-border bg-background p-6 shadow-[0_1px_2px_rgba(0,0,0,0.04)] md:p-10 dark:shadow-[0_1px_2px_rgba(0,0,0,0.2)]"
initial={reduceMotion ? false : { opacity: 0, y: 16, filter: "blur(4px)" }}
transition={{ ...defaultSpring, delay: motionStagger.item }}
viewport={motionViewport}
whileInView={reduceMotion ? undefined : { opacity: 1, y: 0, filter: "blur(0px)" }}
>
<AnimatePresence initial={false} mode="wait">
<motion.div
animate={{ opacity: 1, filter: "blur(0px)", y: 0 }}
className="space-y-6"
exit={{
opacity: 0,
filter: "blur(6px)",
y: -10,
transition: { duration: 0.18, ease: "easeIn" },
}}
initial={{ opacity: 0, filter: "blur(6px)", y: 8 }}
key={activePlan.name}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium text-lg md:text-xl">{activePlan.name}</p>
{activePlan.popular ? <Badge>Most picked</Badge> : null}
</div>
<p className="font-mono text-[10px] text-muted-foreground uppercase tracking-[0.16em]">
{activePlan.tag} tier
</p>
</div>
<PlanPrice
large
period={period}
plan={activePlan}
reduceMotion={!!reduceMotion}
/>
</div>
<p className="max-w-lg text-pretty text-muted-foreground text-sm leading-relaxed md:text-base">
{activePlan.description}
</p>
<ul className="grid gap-3 sm:grid-cols-2">
{activePlan.features.map((feature, index) => (
<motion.li
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
className="flex items-start gap-2 text-sm"
initial={
reduceMotion
? false
: { opacity: 0, y: 6, filter: "blur(4px)" }
}
key={feature}
transition={{
type: "spring",
duration: 0.3,
bounce: 0,
delay: 0.04 + index * motionStagger.item,
}}
>
<CheckIcon className="mt-0.5 size-4 shrink-0 text-primary" />
{feature}
</motion.li>
))}
</ul>
<Button
className="w-full active:scale-[0.96] sm:w-auto"
variant={activePlan.popular ? "default" : "outline"}
>
{activePlan.custom ? "Talk to us" : "Get started"}
</Button>
</motion.div>
</AnimatePresence>
</motion.div>
</div>
<ul className="flex flex-wrap items-center justify-center gap-2 md:gap-2.5">
{plans.map((plan, index) => (
<li key={plan.name}>
<PlanChip
isActive={index === active}
onSelect={() => selectPlan(index)}
plan={plan}
reduceMotion={!!reduceMotion}
/>
</li>
))}
</ul>
</ContentRail>
</SectionShell>
);
}




























