freeNew122 lines · framer-motion
Pricing Plan Switcher
One row of tabs, one full plan at a time. A segmented tab row above a single plan card; switching tabs cross-fades the whole card (price, blurb, feature list, button) to the next plan instead of showing three cards side by side.
- Use it for
- A narrower pricing section — a sidebar, a modal, a mobile-first page — where three full cards side by side would be cramped, but a tab switcher still lets people compare.
- Themes
- Light + dark
- License
- Commercial use
Live
Pricing
One row of tabs, one plan at a time
Starter
A single workspace, everything you need to try it for real.
$19/mo
- 1 workspace
- 10k requests / mo
- Community support
- Both themes
tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
function useReducedMotionSafe() {
const prefersReduced = useReducedMotion();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
return mounted ? !!prefersReduced : false;
}
export interface SwitcherPlan {
name: string;
price: string;
period?: string;
blurb: string;
features: string[];
cta?: string;
}
export interface PricingPlanSwitcherProps {
eyebrow?: string;
title?: string;
plans?: SwitcherPlan[];
defaultPlan?: string;
className?: string;
}
const DEFAULT_PLANS: SwitcherPlan[] = [
{ name: "Starter", price: "$19", period: "/mo", blurb: "A single workspace, everything you need to try it for real.", features: ["1 workspace", "10k requests / mo", "Community support", "Both themes"], cta: "Start free" },
{ name: "Growth", price: "$49", period: "/mo", blurb: "A team shipping to customers who are actually paying.", features: ["5 workspaces", "150k requests / mo", "Priority support", "Usage analytics", "Remove Loopkit badge"], cta: "Start trial" },
{ name: "Scale", price: "$129", period: "/mo", blurb: "Headroom for a company that doesn't want to think about limits.", features: ["Unlimited workspaces", "2M requests / mo", "Dedicated Slack channel", "SSO & audit logs", "Custom contract"], cta: "Talk to sales" },
];
export function PricingPlanSwitcher({ eyebrow = "Pricing", title = "One row of tabs, one plan at a time", plans = DEFAULT_PLANS, defaultPlan, className }: PricingPlanSwitcherProps) {
const reduce = useReducedMotionSafe();
const [active, setActive] = React.useState(defaultPlan ?? plans[0]?.name);
const plan = plans.find((p) => p.name === active) ?? plans[0];
const activeIndex = Math.max(0, plans.findIndex((p) => p.name === active));
return (
<section className={cn("w-full bg-background px-4 py-20 sm:px-6 lg:px-10", className)}>
<div className="mx-auto max-w-3xl text-center">
<p className="font-mono text-[11px] uppercase tracking-[0.2em] text-muted-foreground">{eyebrow}</p>
<h2 className="mt-3 font-display text-[clamp(2rem,5vw,3.5rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-foreground">{title}</h2>
</div>
<div className="mx-auto mt-10 flex max-w-2xl justify-center" role="tablist" aria-label="Pricing plans">
<div className="relative inline-flex items-center gap-1 rounded-full border-2 border-foreground bg-card p-1.5">
{plans.map((p) => {
const isActive = p.name === active;
return (
<button
key={p.name}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => setActive(p.name)}
className={cn(
"relative z-10 rounded-full px-5 py-2 text-sm font-semibold transition-colors",
isActive ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{isActive && (
<motion.span
layoutId="plan-switcher-pill"
transition={{ type: "spring", stiffness: 380, damping: 32 }}
className="absolute inset-0 -z-10 rounded-full bg-primary"
/>
)}
{p.name}
</button>
);
})}
</div>
</div>
<div className="relative mx-auto mt-10 max-w-2xl overflow-hidden rounded-[2rem] border-2 border-foreground bg-card p-8 shadow-[8px_8px_0_0_var(--signal)] sm:p-11">
<AnimatePresence mode="wait" custom={activeIndex}>
<motion.div
key={plan?.name}
initial={reduce ? false : { opacity: 0, x: 16 }}
animate={{ opacity: 1, x: 0 }}
exit={reduce ? undefined : { opacity: 0, x: -16 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
>
<div className="flex flex-col items-start gap-5 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="font-display text-xl font-bold tracking-tight text-foreground">{plan?.name}</p>
<p className="mt-1.5 max-w-sm text-sm leading-relaxed text-muted-foreground">{plan?.blurb}</p>
</div>
<p className="flex items-baseline gap-1 whitespace-nowrap">
<span className="font-display text-5xl font-extrabold tracking-[-0.03em] text-foreground">{plan?.price}</span>
{plan?.period && <span className="text-sm text-muted-foreground">{plan.period}</span>}
</p>
</div>
<ul className="mt-7 grid grid-cols-1 gap-2.5 border-t border-border pt-7 sm:grid-cols-2">
{plan?.features.map((f) => (
<li key={f} className="flex gap-2.5 text-sm text-foreground">
<span className="mt-0.5 grid size-4 shrink-0 place-items-center rounded-full bg-primary text-[9px] text-primary-foreground" aria-hidden>
✓
</span>
{f}
</li>
))}
</ul>
<button type="button" className="mt-8 inline-flex h-11 w-full items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground transition hover:opacity-90 sm:w-auto sm:px-8">
{plan?.cta ?? "Choose plan"}
</button>
</motion.div>
</AnimatePresence>
</div>
</section>
);
}
export default PricingPlanSwitcher;
How it behaves
- →An animated pill slides between tabs using a shared layout id, landing under whichever plan is active.
- →Switching plans cross-fades the card's content with a small horizontal slide rather than a hard cut.
- →The slide transition is skipped under prefers-reduced-motion; the next plan's content simply appears.
Accessibility
- ✓Tabs use `role="tablist""/"tab"` with `aria-selected`, and the sliding pill is purely decorative behind the tab's own text.
- ✓The feature list always uses a checkmark icon plus text, never colour alone.
- ✓Honours prefers-reduced-motion.