The cards (a Server Component)
components/pricing-cards.tsx
import type { CSSProperties } from "react";
import "./pricing-cards.css";
export type Plan = {
id: string;
name: string;
price: string; // already formatted from your data, never hard-coded here
period: string;
features: string[];
featured?: boolean;
href: string;
};
// A Server Component: no animation library, no "use client".
export function PricingCards({ plans }: { plans: Plan[] }) {
return (
<div className="grid gap-6 md:grid-cols-3">
{plans.map((plan, i) => (
<article
key={plan.id}
style={{ "--i": i } as CSSProperties}
className={"pc-card pc-reveal relative rounded-2xl p-px " + (plan.featured ? "pc-featured" : "bg-zinc-800")}
>
<div className="flex h-full flex-col rounded-[15px] bg-zinc-950 p-6">
<h3 className="text-sm font-medium text-zinc-400">{plan.name}</h3>
<p className="mt-3 text-4xl font-semibold tabular-nums text-white">
{plan.price}
<span className="ml-1 text-sm font-normal text-zinc-500">/{plan.period}</span>
</p>
<ul className="mt-6 space-y-2 text-sm text-zinc-300">
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-2">
<svg viewBox="0 0 16 16" className="size-4 shrink-0 text-emerald-400" aria-hidden="true">
<path className="pc-check" d="M3 8.5l3 3 7-7" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
{f}
</li>
))}
</ul>
<a href={plan.href} className="mt-auto inline-flex justify-center rounded-lg bg-white px-4 py-2 text-sm font-medium text-zinc-950">
Choose {plan.name}
</a>
</div>
</article>
))}
</div>
);
}
components/pricing-cards.css
/* 1. Rotating gradient border on the featured plan. @property lets the
browser animate the angle; without it the border just sits still. */
@property --pc-angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
.pc-featured {
background: conic-gradient(from var(--pc-angle), #34d399, #6366f1, #f472b6, #34d399);
animation: pc-spin 6s linear infinite;
}
@keyframes pc-spin { to { --pc-angle: 360deg; } }
/* 2. Hover: lift and shadow. Only transform and box-shadow change. */
.pc-card {
transition: transform 200ms ease, box-shadow 200ms ease;
}
.pc-card:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px -20px rgb(0 0 0 / 0.6);
}
/* 3. Reveal on scroll, one card after another (--i is set inline).
It moves the card with translate, so it never fights the hover transform. */
@keyframes pc-in {
from { opacity: 0; translate: 0 16px; }
to { opacity: 1; translate: 0 0; }
}
@supports (animation-timeline: view()) {
.pc-reveal {
animation: pc-in linear both;
animation-timeline: view();
animation-range: entry calc(var(--i) * 8%) cover 30%;
}
}
/* 4. Checkmarks draw in after the card shows up. */
.pc-check {
stroke-dasharray: 16;
stroke-dashoffset: 16;
animation: pc-draw 500ms ease-out forwards;
animation-delay: calc(300ms + var(--i, 0) * 120ms);
}
@keyframes pc-draw { to { stroke-dashoffset: 0; } }
@media (prefers-reduced-motion: reduce) {
.pc-featured, .pc-reveal, .pc-check { animation: none; }
.pc-check { stroke-dashoffset: 0; }
.pc-card:hover { transform: none; }
}
app/pricing/page.tsx
import { PricingCards, type Plan } from "@/components/pricing-cards";
import { getPlans } from "@/lib/plans";
export default async function PricingPage() {
// Prices come from your billing data (for example Stripe Prices),
// formatted on the server with Intl.NumberFormat.
const plans: Plan[] = await getPlans();
return (
<main className="mx-auto max-w-5xl px-6 py-20">
<h1 className="mb-10 text-center text-4xl font-semibold">Pick a plan</h1>
<PricingCards plans={plans} />
</main>
);
}