Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 03:13:34Get Lifetime →
loopkit
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.