Pricing pages are where conversions happen or die. A poorly structured pricing section creates hesitation; a well-designed one makes the choice feel obvious. This guide covers the exact patterns that move the needle: the 3-tier layout, the monthly/annual toggle, highlighted tiers, and trust signals — all implemented in React with Tailwind CSS and TypeScript.
Why Three Tiers
Psychology research on choice architecture consistently shows that three options outperform two or four. Two options force a binary choice that feels high-stakes. Four options cause analysis paralysis. Three options let visitors orient themselves — the middle tier acts as an anchor, making the higher tier feel reasonable and the lower tier feel limited.
The tier names matter too. "Starter / Pro / Enterprise" is overused. Consider names that reflect the customer's identity: "Solo / Team / Agency", or "Builder / Studio / Scale". The name should make the visitor think "that's me."
Data Structure
Start by defining the shape of a pricing tier in TypeScript:
interface Tier {
name: string;
monthlyPrice: number;
yearlyPrice: number;
currency: "USD" | "EUR";
description: string;
features: string[];
ctaLabel: string;
ctaUrl: string;
highlighted: boolean;
badge?: string; // e.g. "Most popular"
}
Keeping monthlyPrice and yearlyPrice as separate numbers makes the toggle logic trivial — no need to compute percentages at render time.
The Monthly/Annual Toggle
The toggle is a two-state component with a smooth pill animation. It uses useState and Framer Motion's motion.div for the sliding indicator:
"use client";
import { useState } from "react";
import { motion } from "framer-motion";
const ease: [number, number, number, number] = [0.16, 1, 0.3, 1];
function BillingToggle({ onChange }: { onChange: (yearly: boolean) => void }) {
const [yearly, setYearly] = useState(false);
function toggle() {
const next = !yearly;
setYearly(next);
onChange(next);
}
return (
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<span
style={{
fontSize: "0.875rem",
fontWeight: 500,
color: !yearly ? "var(--color-foreground)" : "var(--color-foreground-muted)",
}}
>
Monthly
</span>
<button
onClick={toggle}
style={{
position: "relative",
width: "3.5rem",
height: "1.75rem",
borderRadius: "var(--radius-full)",
background: yearly ? "var(--color-accent)" : "var(--color-border)",
border: "none",
cursor: "pointer",
transition: `background var(--duration-normal) var(--ease-out)`,
}}
aria-label="Toggle billing period"
aria-pressed={yearly}
>
<motion.div
animate={{ left: yearly ? "calc(100% - 24px)" : "4px" }}
transition={{ duration: 0.25, ease }}
style={{
position: "absolute",
top: "4px",
width: "20px",
height: "20px",
borderRadius: "50%",
background: "var(--color-background)",
}}
/>
</button>
<span
style={{
fontSize: "0.875rem",
fontWeight: 500,
color: yearly ? "var(--color-foreground)" : "var(--color-foreground-muted)",
}}
>
Annual
<span
style={{
marginLeft: "0.5rem",
padding: "0.125rem 0.5rem",
borderRadius: "var(--radius-full)",
background: "var(--color-accent)",
color: "var(--color-background)",
fontSize: "0.75rem",
fontWeight: 600,
opacity: yearly ? 1 : 0.5,
transition: `opacity var(--duration-normal) var(--ease-out)`,
}}
>
Save 20%
</span>
</span>
</div>
);
}
The aria-pressed attribute makes the toggle accessible to screen readers. The aria-label provides context when the button has no visible text label of its own.
Highlighted Tier
The "Most Popular" tier needs to visually stand out from its siblings. The most effective technique inverts the color scheme — the highlighted card uses var(--color-foreground) as background and var(--color-background) for text, creating a natural visual hierarchy without any extra colors:
function PricingCard({ tier, yearly }: { tier: Tier; yearly: boolean }) {
const price = yearly ? tier.yearlyPrice : tier.monthlyPrice;
return (
<div
style={{
display: "flex",
flexDirection: "column",
borderRadius: "var(--radius-xl)",
padding: "2rem",
background: tier.highlighted
? "var(--color-foreground)"
: "var(--color-background-card)",
border: tier.highlighted
? "none"
: "1px solid var(--color-border)",
position: "relative",
}}
>
{tier.badge && tier.highlighted && (
<span
style={{
position: "absolute",
top: "-0.75rem",
left: "50%",
transform: "translateX(-50%)",
padding: "0.25rem 1rem",
borderRadius: "var(--radius-full)",
background: "var(--color-accent)",
color: "var(--color-foreground)",
fontSize: "0.75rem",
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase",
whiteSpace: "nowrap",
}}
>
{tier.badge}
</span>
)}
{/* Price, features, CTA */}
</div>
);
}
Positioning the badge with top: -0.75rem and centering it horizontally makes it "float" above the card, drawing the eye naturally.
Price Animation on Toggle
When the user switches billing periods, animate the price transition so it feels responsive rather than jarring:
import { AnimatePresence, motion } from "framer-motion";
// Inside PricingCard
<AnimatePresence mode="wait">
<motion.span
key={price} // Changing the key triggers exit + enter animation
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
style={{
fontSize: "2.5rem",
fontWeight: 700,
color: tier.highlighted
? "var(--color-background)"
: "var(--color-foreground)",
}}
>
${price}
</motion.span>
</AnimatePresence>
The key={price} trick is essential — React uses the key to distinguish between elements, so changing it tells AnimatePresence to exit the old price and enter the new one.
Feature List
Keep feature descriptions short and concrete. "Unlimited projects" beats "No project limit". Use a checkmark icon from lucide-react:
import { Check } from "lucide-react";
<ul style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{tier.features.map((feature) => (
<li
key={feature}
style={{ display: "flex", alignItems: "center", gap: "0.75rem", fontSize: "0.875rem" }}
>
<Check
size={16}
style={{
flexShrink: 0,
color: tier.highlighted
? "var(--color-background)"
: "var(--color-accent)",
}}
/>
<span
style={{
color: tier.highlighted
? "var(--color-background)"
: "var(--color-foreground)",
opacity: tier.highlighted ? 0.85 : 1,
}}
>
{feature}
</span>
</li>
))}
</ul>
Trust Signals Below the Grid
The grid alone isn't enough. Add a row of trust signals immediately below the pricing cards. These convert fence-sitters:
const trustSignals = [
"No credit card required",
"14-day free trial",
"Cancel anytime",
"SOC 2 Type II certified",
];
<div
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
gap: "1.5rem",
marginTop: "2.5rem",
}}
>
{trustSignals.map((signal) => (
<span
key={signal}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.8125rem",
color: "var(--color-foreground-muted)",
}}
>
<Check size={14} style={{ color: "var(--color-accent)" }} />
{signal}
</span>
))}
</div>
Stagger the Cards on Scroll
Use whileInView with a delay based on the card index so cards cascade in as the user scrolls:
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease, delay: index * 0.1 }}
viewport={{ once: true }}
>
<PricingCard tier={tier} yearly={yearly} />
</motion.div>
The viewport={{ once: true }} prevents the animation from re-triggering every time the user scrolls past the section.
Ready-Made Pricing Components
If you want to skip the boilerplate and go straight to customizing, the Incubator pricing catalog has 20+ pricing section variants: toggle layouts, feature matrix comparisons, usage-based sliders, freemium walls, spotlight cards, and more — all built with the patterns above and ready to drop into your project.