Horizontal scroll-snap filmstrip of plan cards — active tier scales up while neighbors recede (blog-5 snap-carousel DNA).
@nusaiba
registry/blocks/pricing/5/pricing-section.tsx
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { CheckIcon } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { SectionShell, ContentRail } from "@/components/layout-contract";
const plans = [
{
name: "Starter",
tag: "free",
price: "$0",
period: "forever",
features: ["Free blocks", "CLI install", "Copy prompt"],
},
{
name: "Pro",
tag: "once",
price: "$147",
period: "lifetime",
features: ["Pro catalog", "Registry token", "Commercial license"],
popular: true,
},
{
name: "Enterprise",
tag: "team",
price: "Custom",
period: "annual",
features: ["Team workspace", "SSO", "Priority support"],
},
];
export function PricingFilmstripSection() {
const reduceMotion = useReducedMotion();
const scrollRef = useRef<HTMLDivElement>(null);
const slideRefs = useRef<(HTMLElement | null)[]>([]);
const [active, setActive] = useState(1);
const updateActive = useCallback(() => {
const container = scrollRef.current;
if (!container) return;
const center = container.scrollLeft + container.clientWidth / 2;
let closest = 0;
let minDistance = Number.POSITIVE_INFINITY;
slideRefs.current.forEach((slide, index) => {
if (!slide) return;
const slideCenter = slide.offsetLeft + slide.offsetWidth / 2;
const distance = Math.abs(center - slideCenter);
if (distance < minDistance) {
minDistance = distance;
closest = index;
}
});
setActive(closest);
}, []);
useEffect(() => {
const container = scrollRef.current;
if (!container) return;
updateActive();
container.addEventListener("scroll", updateActive, { passive: true });
window.addEventListener("resize", updateActive);
return () => {
container.removeEventListener("scroll", updateActive);
window.removeEventListener("resize", updateActive);
};
}, [updateActive]);
return (
<SectionShell spacingMode="section" className="bg-background">
<ContentRail maxWidth="max-w-6xl" className="space-y-8 md:space-y-10">
<div className="max-w-xl space-y-2">
<p className="font-medium text-primary text-xs uppercase tracking-[0.22em]">
Plans
</p>
<h2 className="text-balance font-medium text-2xl tracking-tight md:text-4xl">
Swipe to compare
</h2>
<p className="text-pretty text-muted-foreground text-sm md:text-base">
Scroll-snap filmstrip — active tier scales up while neighbors recede.
</p>
</div>
<div
className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2 [-ms-overflow-style:none] [scrollbar-width:none] md:gap-6 [&::-webkit-scrollbar]:hidden"
ref={scrollRef}
>
{plans.map((plan, index) => {
const isActive = index === active;
return (
<motion.article
animate={
reduceMotion
? undefined
: { scale: isActive ? 1 : 0.94, opacity: isActive ? 1 : 0.55 }
}
className="w-[min(88vw,22rem)] shrink-0 snap-center"
initial={reduceMotion ? false : { opacity: 0, y: 16 }}
key={plan.name}
ref={(node) => {
slideRefs.current[index] = node;
}}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
viewport={{ once: true }}
whileInView={reduceMotion ? undefined : { opacity: 1, y: 0 }}
>
<div
className={cn(
"flex h-full flex-col rounded-[var(--radius-md)] border p-6 shadow-[0_1px_2px_rgba(0,0,0,0.04)] md:p-7 dark:shadow-[0_1px_2px_rgba(0,0,0,0.2)]",
plan.popular
? "border-foreground/12 bg-gradient-to-b from-primary/[0.06] to-background"
: "border-border bg-background",
)}
>
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-lg">{plan.name}</p>
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-[0.14em]">
{plan.tag}
</span>
</div>
<div className="mt-4 flex items-baseline gap-2">
<span className="font-medium text-4xl tabular-nums tracking-tight">
{plan.price}
</span>
<span className="text-muted-foreground text-sm">{plan.period}</span>
</div>
<ul className="mt-6 flex-1 space-y-2.5">
{plan.features.map((feature) => (
<li className="flex items-center gap-2 text-sm" key={feature}>
<CheckIcon className="size-4 shrink-0 text-primary" />
{feature}
</li>
))}
</ul>
<Button
className="mt-8 w-full active:scale-[0.96]"
variant={plan.popular ? "default" : "outline"}
>
{plan.price === "Custom" ? "Talk to us" : "Get started"}
</Button>
</div>
</motion.article>
);
})}
</div>
</ContentRail>
</SectionShell>
);
}





























