Website Sections
PricingPrism
Pricing cards with a monthly / yearly switch. A spectrum splits around the pointer and runs along the card edge, like light through glass.
Download
- React
- Tailwind CSS v4
- motion
components/ui/pricing-prism.tsx
"use client";
import { useId, useState, type CSSProperties, type ElementType, type PointerEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export type PricingPrismPlan = {
name: string;
text?: string;
/** Price text, so any currency works: "₹499", "Free". */
monthly: string;
/** Leave out if the plan costs the same either way. */
yearly?: string;
features: string[];
/** Button text. */
cta?: string;
/** Makes the button a link. */
href?: string;
/** Keeps the spectrum edge on all the time. */
featured?: boolean;
/** Small label on the card, such as "Popular". */
badge?: string;
};
type Period = "monthly" | "yearly";
type PricingPrismProps = {
plans: PricingPrismPlan[];
title?: string;
/** Small line above the title. */
eyebrow?: string;
subtitle?: string;
/** Called when a plan button without href is pressed. */
onSelect?: (plan: PricingPrismPlan, period: Period) => void;
/** Words for the switch and the price ending. */
labels?: { monthly: string; yearly: string; perMonth: string; perYear: string };
/** Small note beside the yearly switch, such as "Save 20%". */
saving?: string;
/** Card colour. */
tile?: string;
/** Spectrum colours. Repeat the first at the end for a smooth ring. */
colors?: string[];
/** h2 by default. */
headingAs?: ElementType;
/** Set padding and colours here. */
className?: string;
};
const LABELS = { monthly: "Monthly", yearly: "Yearly", perMonth: "/mo", perYear: "/yr" };
const COLORS = ["#ff6b6b", "#ffc857", "#5eead4", "#38bdf8", "#a78bfa", "#f472b6", "#ff6b6b"];
const RING = "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)";
const SPOT = "radial-gradient(15rem at var(--pp-x) var(--pp-y), #000, transparent)";
/* PricingPrism: pricing cards with a monthly / yearly switch. A spectrum splits around the pointer and runs along the card edge, like light through glass. */
export function PricingPrism({
plans,
title,
eyebrow,
subtitle,
onSelect,
labels = LABELS,
saving,
tile = "#15122a",
colors = COLORS,
headingAs: Heading = "h2",
className = "bg-[#0b0a14] px-5 py-16 text-white",
}: PricingPrismProps) {
const reduce = useReducedMotion();
const id = useId();
const [period, setPeriod] = useState<Period>("monthly");
const hasYearly = plans.some((plan) => plan.yearly);
const spectrum = `conic-gradient(from var(--pp-a) at var(--pp-x) var(--pp-y), ${colors.join(", ")})`;
const track = (e: PointerEvent<HTMLLIElement>) => {
const box = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - box.left) / box.width;
e.currentTarget.style.setProperty("--pp-x", `${x * 100}%`);
e.currentTarget.style.setProperty("--pp-y", `${((e.clientY - box.top) / box.height) * 100}%`);
e.currentTarget.style.setProperty("--pp-a", `${x * 200}deg`);
};
return (
<section aria-labelledby={title ? id : undefined} className={`@container ${className}`}>
{title && (
<header className="mx-auto mb-8 max-w-2xl text-center">
{eyebrow && <p className="mb-2 text-xs font-medium tracking-[0.2em] uppercase opacity-70">{eyebrow}</p>}
<Heading id={id} className="text-[clamp(1.5rem,6cqw,2.5rem)] leading-tight font-semibold tracking-tight text-balance">
{title}
</Heading>
{subtitle && <p className="mt-3 text-pretty opacity-70">{subtitle}</p>}
</header>
)}
{hasYearly && (
<div className="mb-8 flex flex-wrap items-center justify-center gap-3">
<div role="group" aria-label="Billing period" className="inline-flex rounded-full border border-current/15 p-1 text-sm">
{(["monthly", "yearly"] as const).map((option) => (
<button
key={option}
type="button"
aria-pressed={period === option}
onClick={() => setPeriod(option)}
className={`relative rounded-full px-4 py-1.5 font-medium transition-opacity ${period === option ? "" : "opacity-60 hover:opacity-100"}`}
>
{period === option && (
<motion.span
layoutId={`${id}-pill`}
className="absolute inset-0 rounded-full bg-current/15"
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 34 }}
/>
)}
<span className="relative">{labels[option]}</span>
</button>
))}
</div>
{saving && (
<span className="text-sm font-medium" style={{ color: colors[2] }}>
{saving}
</span>
)}
</div>
)}
<ul className="mx-auto grid max-w-6xl grid-cols-[repeat(auto-fit,minmax(min(100%,15rem),1fr))] gap-4">
{plans.map((plan, i) => {
const yearly = period === "yearly" && !!plan.yearly;
const price = yearly ? plan.yearly! : plan.monthly;
const unit = yearly ? labels.perYear : labels.perMonth;
const button = "block w-full rounded-xl px-4 py-2.5 text-center text-sm font-semibold transition-opacity hover:opacity-85";
const look = plan.featured ? { backgroundColor: "#fff", color: "#0b0a14" } : { boxShadow: "inset 0 0 0 1px color-mix(in srgb, currentColor 25%, transparent)" };
return (
<motion.li
key={plan.name}
onPointerMove={track}
className="group/plan relative isolate flex flex-col overflow-hidden rounded-3xl p-6"
style={{ backgroundColor: tile, "--pp-x": "50%", "--pp-y": "0%", "--pp-a": "0deg" } as CSSProperties}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 20 }}
whileInView={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.15 }}
transition={{ duration: 0.6, delay: Math.min(i, 6) * 0.07, ease: [0.16, 1, 0.3, 1] }}
>
<span
aria-hidden
className="absolute inset-0 -z-10 opacity-0 transition-opacity duration-500 group-hover/plan:opacity-20 motion-reduce:transition-none"
style={{ backgroundImage: spectrum, maskImage: SPOT, WebkitMaskImage: SPOT }}
/>
<span
aria-hidden
className={`pointer-events-none absolute inset-0 rounded-[inherit] p-[1.5px] transition-opacity duration-500 group-focus-within/plan:opacity-100 group-hover/plan:opacity-100 motion-reduce:transition-none ${plan.featured ? "opacity-100" : "opacity-15"}`}
style={{ backgroundImage: spectrum, mask: RING, WebkitMask: RING, maskComposite: "exclude", WebkitMaskComposite: "xor" }}
/>
<div className="flex items-center justify-between gap-2">
<p className="font-semibold">{plan.name}</p>
{plan.badge && <span className="rounded-full bg-current/10 px-2.5 py-0.5 text-xs font-medium">{plan.badge}</span>}
</div>
{plan.text && <p className="mt-1 text-sm text-pretty opacity-70">{plan.text}</p>}
<p className="mt-5 flex items-baseline gap-1">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={price}
className="text-4xl font-semibold tracking-tight"
initial={reduce ? false : { opacity: 0, y: 14, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: -14, filter: "blur(4px)" }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
>
{price}
</motion.span>
</AnimatePresence>
{/\d/.test(price) && <span className="text-sm opacity-60">{unit}</span>}
</p>
<ul className="mt-5 mb-6 grid gap-2 text-sm">
{plan.features.map((feature) => (
<li key={feature} className="flex gap-2">
<span aria-hidden style={{ color: colors[3] }}>
✓
</span>
<span className="opacity-85">{feature}</span>
</li>
))}
</ul>
<div className="mt-auto">
{plan.href ? (
<a href={plan.href} className={button} style={look}>
{plan.cta ?? "Choose plan"}
</a>
) : (
<button type="button" onClick={() => onSelect?.(plan, yearly ? "yearly" : "monthly")} className={button} style={look}>
{plan.cta ?? "Choose plan"}
</button>
)}
</div>
</motion.li>
);
})}
</ul>
</section>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/pricing-prism.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/pricing-prism-demo.tsx
import { PricingPrism } from "@/components/ui/pricing-prism"; const PLANS = [ { name: "Starter", text: "For a first project.", monthly: "Free", features: ["3 pages", "Community help"], cta: "Start free" }, { name: "Studio", text: "For small teams.", monthly: "₹799", yearly: "₹7,990", features: ["Unlimited pages", "Custom domain", "Email help"], cta: "Start Studio", featured: true, badge: "Popular", }, { name: "Agency", text: "For client work.", monthly: "₹1,999", yearly: "₹19,990", features: ["Everything in Studio", "10 seats", "Priority help"], cta: "Talk to us" }, ]; export default function PricingPrismExample() { return ( <div className="h-full overflow-y-auto"> <PricingPrism title="Simple pricing" plans={PLANS} saving="2 months free" headingAs="h3" className="min-h-full bg-[#0b0a14] p-4 text-white" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| plans | PricingPrismPlan[] | — | Each has name, monthly and features, plus optional text, yearly, cta, href, featured and badge. |
| title | string | — | Heading above the cards. |
| eyebrow | string | — | Small line above the title. |
| subtitle | string | — | Line under the title. |
| onSelect | (plan, period) => void | — | Called when a plan button without href is pressed. |
| labels | object | English | Words for the switch and the price ending: monthly, yearly, perMonth, perYear. |
| saving | string | — | Small note beside the switch, such as Save 20%. |
| tile | string | "#15122a" | Card colour. |
| colors | string[] | 7 colours | Spectrum colours. Repeat the first at the end for a smooth ring. |
| headingAs | ElementType | "h2" | Heading tag. |
| className | string | "bg-[#0b0a14] px-5 py-16 text-white" | Set padding and colours here. |