Menu

Interactive Pricing

A pricing section with morphing numbers and a premium toggle.

Preview

Live interactive preview.

Installation

Add this component to your project using the CLI:

terminal
npx -y vui-registry-cli-v1@latest add pricing

Source Code

pricing.tsx
'use client'

import React, { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { cn } from '@/lib/utils'

interface PricingPlan {
  id: string
  name: string
  price: {
    monthly: number
    yearly: number
  }
  description: string
  features: string[]
  popular?: boolean
}

const CheckIcon = ({ className }: { className?: string }) => (
  <svg className={cn('w-3 h-3', className)} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={4}>
    <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  </svg>
)

export interface PricingProps {
  plans?: PricingPlan[]
  defaultBillingCycle?: 'monthly' | 'yearly'
  className?: string
}

export function Pricing({ plans: plansProp, defaultBillingCycle = 'monthly', className }: PricingProps) {
  const [isYearly, setIsYearly] = useState(defaultBillingCycle === 'yearly')

  const plans: PricingPlan[] = plansProp ?? [
    {
      id: 'starter',
      name: 'Starter',
      price: { monthly: 15, yearly: 12 },
      description: 'Perfect for side projects',
      features: ['5 Projects', 'Basic Analytics', 'Community Support', '1GB Storage'],
    },
    {
      id: 'pro',
      name: 'Pro',
      price: { monthly: 39, yearly: 32 },
      description: 'For growing businesses',
      features: ['Unlimited Projects', 'Advanced Analytics', 'Priority Support', 'Team Access', '10GB Storage', 'Custom Domain'],
      popular: true,
    },
    {
      id: 'enterprise',
      name: 'Enterprise',
      price: { monthly: 99, yearly: 89 },
      description: 'For large scale apps',
      features: ['Unlimited Everything', 'Custom SLA', 'Dedicated Manager', 'SSO', 'Unlimited Storage', 'On-premise Deployment'],
    },
  ]

  return (
    <div className={cn('w-full max-w-6xl mx-auto px-4 py-12 flex flex-col items-center', className)}>
      {/* Toggle Switch */}
      <div className="flex items-center gap-4 mb-16 bg-muted/50 p-1.5 rounded-full border border-border backdrop-blur-sm">
        <span
          className={cn(
            'px-3 py-1 text-sm font-medium transition-colors cursor-pointer',
            !isYearly ? 'text-foreground' : 'text-muted-foreground'
          )}
          onClick={() => setIsYearly(false)}
        >
          Monthly
        </span>
        <button
          onClick={() => setIsYearly(!isYearly)}
          className="relative w-14 h-8 rounded-full bg-muted border border-border p-1 transition-colors hover:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/20"
        >
          <motion.div
            className="w-6 h-6 rounded-full bg-primary shadow-sm"
            animate={{ x: isYearly ? 24 : 0 }}
            transition={{ type: 'spring', stiffness: 500, damping: 30 }}
          />
        </button>
        <span
          className={cn(
            'px-3 py-1 text-sm font-medium transition-colors cursor-pointer flex items-center gap-2',
            isYearly ? 'text-foreground' : 'text-muted-foreground'
          )}
          onClick={() => setIsYearly(true)}
        >
          Yearly{' '}
          <span className="text-[10px] font-bold text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20">
            -20%
          </span>
        </span>
      </div>

      {/* Cards Grid */}
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8 w-full items-start">
        {plans.map((plan) => (
          <PricingCard key={plan.id} plan={plan} isYearly={isYearly} />
        ))}
      </div>
    </div>
  )
}

function PricingCard({ plan, isYearly }: { plan: PricingPlan; isYearly: boolean }) {
  const isPopular = plan.popular

  return (
    <div
      className={cn(
        'relative flex flex-col p-6 rounded-3xl transition-all duration-300 group h-full',
        isPopular
          ? 'bg-zinc-900 dark:bg-[#121212] shadow-2xl shadow-black/20 dark:shadow-black/50 z-10 md:scale-105 border border-zinc-800 dark:border-white/10'
          : 'bg-card border border-border hover:border-border/80 shadow-sm hover:shadow-lg hover:-translate-y-1'
      )}
    >
      <div
        className={cn(
          'absolute inset-0 rounded-3xl pointer-events-none transition-opacity duration-500 border-2',
          isPopular ? 'border-white/5' : 'border-transparent group-hover:border-border/40'
        )}
      />

      {isPopular && (
        <div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-foreground text-background text-[10px] font-bold shadow-lg tracking-widest uppercase border border-border/20">
          Most Popular
        </div>
      )}

      <div
        className={cn(
          'absolute inset-0 rounded-3xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none',
          isPopular
            ? 'bg-[radial-gradient(600px_circle_at_var(--mouse-x,50%)_var(--mouse-y,0%),rgba(255,255,255,0.04),transparent_40%)]'
            : 'bg-[radial-gradient(600px_circle_at_var(--mouse-x,50%)_var(--mouse-y,0%),hsl(var(--foreground)/0.04),transparent_40%)]'
        )}
      />

      <div className="mb-4 relative z-10">
        <h3
          className={cn(
            'text-lg font-medium tracking-wide',
            isPopular ? 'text-white' : 'text-foreground'
          )}
        >
          {plan.name}
        </h3>
        <p
          className={cn(
            'text-xs mt-1 font-medium leading-relaxed',
            isPopular ? 'text-white/50' : 'text-muted-foreground'
          )}
        >
          {plan.description}
        </p>
      </div>

      <div className="mb-6 flex items-baseline gap-1 relative z-10">
        <span className={cn('text-xl font-bold', isPopular ? 'text-white/30' : 'text-muted-foreground')}>
          $
        </span>
        <div className="h-10 relative flex items-center">
          <AnimatePresence mode="popLayout">
            <motion.span
              key={isYearly ? 'year' : 'month'}
              initial={{ y: 15, opacity: 0, filter: 'blur(4px)' }}
              animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}
              exit={{ y: -15, opacity: 0, filter: 'blur(4px)' }}
              transition={{ type: 'spring', stiffness: 200, damping: 20 }}
              className={cn(
                'text-4xl font-extrabold tracking-tight leading-none',
                isPopular ? 'text-white' : 'text-foreground'
              )}
            >
              {isYearly ? plan.price.yearly : plan.price.monthly}
            </motion.span>
          </AnimatePresence>
        </div>
        <span className={cn('text-sm font-medium ml-1', isPopular ? 'text-white/40' : 'text-muted-foreground')}>
          /mo
        </span>
      </div>

      <div className="flex-1 space-y-3 mb-8 relative z-10">
        {plan.features.map((feature) => (
          <div key={feature} className="flex items-center gap-3 text-sm group/item">
            <div
              className={cn(
                'w-4 h-4 rounded-full flex items-center justify-center shrink-0 transition-all duration-300',
                isPopular
                  ? 'bg-white/10 text-white shadow-[0_0_10px_rgba(255,255,255,0.1)]'
                  : 'bg-muted text-foreground group-hover/item:bg-primary/10 group-hover/item:text-primary group-hover/item:scale-110'
              )}
            >
              <CheckIcon className={isPopular ? 'text-white' : undefined} />
            </div>
            <span
              className={cn(
                'transition-colors duration-300 text-xs',
                isPopular
                  ? 'text-white/60 group-hover/item:text-white/90'
                  : 'text-muted-foreground group-hover/item:text-foreground'
              )}
            >
              {feature}
            </span>
          </div>
        ))}
      </div>

      <button
        className={cn(
          'w-full py-3 rounded-xl font-semibold text-xs transition-all duration-300 relative overflow-hidden z-10 border tracking-wide uppercase',
          isPopular
            ? 'bg-white text-zinc-900 border-transparent hover:shadow-[0_0_20px_rgba(255,255,255,0.2)] hover:scale-[1.02]'
            : 'bg-transparent border-border text-foreground hover:bg-muted hover:border-border/80'
        )}
      >
        Get Started
      </button>
    </div>
  )
}

Dependencies

  • framer-motion: latest
  • lucide-react: latest
  • clsx: latest
  • tailwind-merge: latest

Props

Component property reference.

NameTypeDefaultDescription
plansArray<{ id: string; name: string; price: { monthly: number; yearly: number }; description: string; features: string[]; popular?: boolean }>Built-in plansCustom pricing plans to display.
defaultBillingCycle'monthly' | 'yearly''monthly'Initial billing cycle.
classNamestringundefinedAdditional CSS classes.
Context Worth Keeping In Orbit

Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.

I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.

I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.