How To: Interactive Pricing Component with Tailwind CSS
by Matthew Rhoads,
What are Pricing Toggles?
Pricing toggles are interactive elements that allow users to switch between different billing cycles (typically monthly and annual). When implemented well, they provide clear price comparisons and enhance the user experience of pricing pages.
Benefits of Pricing Toggles Pricing toggles offer several advantages for your website:
- They simplify complex pricing information into an easily digestible format
- The interactive toggle encourages user engagement
- They effectively highlight potential savings on annual plans
- They help users make informed decisions about pricing options
- They reduce cognitive load by showing relevant pricing based on user preference
What we are building
Want to see a live demo? Live Demo Full Code and Repo [Full Code Example] (https://github.com/mematthew123/pricing-demo)
In this guide, we'll cover:
- How to create a smooth animated toggle switch
- Using Tailwind's utility classes for clean, responsive design
- Managing pricing state and calculations
- Creating consistent pricing cards with feature comparisons
- Adding hover animations and transitions
Let's dive in and see how it's done!
Initial Setup
If we haven't already let's go ahead and spin up a new Next.js site site using the command npx create-next-app@latest
and selecting Tailwind CSS from the setup instructions.
Next we will install the HeroIcons package npm i @heroicons/react
We will then need to create a new folder called Components
. Inside our new folder we will create a new file called PricingToggle.tsx.
1. Basic Component setup
We will want to start by creating a function and passing that to our page.tsx
file. Your component should look similar to below:
// PricingToggle.tsx
const PricingToggle = () => {
return (
<div className="max-w-6xl mx-auto p-6">
<div className="flex flex-col items-center mb-12">
<h1 className="text-3xl font-bold text-center">Pricing Component</h1>
</div>
</div>
);
};
export default PricingToggle;
2. Setting Up Types and Data Structure
Next let's start by defining our TypeScript types and organizing our pricing data structure:
type SupportLevel = 'basic' | 'priority'
interface Plan {
name: string
monthlyPrice: number
annualPrice: number
users: string
storage: string
support: SupportLevel
availableFeatures: string[]
}
This gives us a solid foundation for type safety throughout our component.
3. Defining the Plan Data
Let's add our plan data to the component we've created:
import React, { useState } from 'react';
const PricingToggle = () => {
const [isAnnual, setIsAnnual] = useState(false);
const baseFeatures = {
support: {
basic: 'Basic Support',
priority: 'Priority Support'
} as const
};
const additionalFeatures = [
'Email Reports',
'Advanced Analytics',
'Custom Domains',
'API Access',
'SSO Authentication'
];
const plans: Plan[] = [
{
name: 'Basic',
monthlyPrice: 9,
annualPrice: 90,
users: '1 User',
storage: '10GB Storage',
support: 'basic',
availableFeatures: [
'Email Reports'
]
},
{
name: 'Pro',
monthlyPrice: 29,
annualPrice: 290,
users: '5 Users',
storage: '100GB Storage',
support: 'priority',
availableFeatures: [
'Email Reports',
'Advanced Analytics',
'Custom Domains'
]
},
{
name: 'Enterprise',
monthlyPrice: 99,
annualPrice: 990,
users: 'Unlimited Users',
storage: '1TB Storage',
support: 'priority',
availableFeatures: [
'Email Reports',
'Advanced Analytics',
'Custom Domains',
'API Access',
'SSO Authentication'
]
}
];
return (
<div className="max-w-6xl mx-auto p-6">
{/* Content will go here */}
</div>
);
};
export default PricingToggle;
Data Structure Explanation
additionalFeatures
: Array of all possible features for comparison across plansplans
: Array of Plan objects that follow our TypeScript interface- Each plan includes pricing for both billing periods, enabling easy switching
- Features are stored as arrays for flexible rendering and comparison
4. Building the Toggle Switch
Now let's add the toggle switch to our component:
import React, { useState } from 'react';
const PricingToggle = () => {
const [isAnnual, setIsAnnual] = useState(false);
const baseFeatures = {
support: {
basic: 'Basic Support',
priority: 'Priority Support'
} as const
};
const additionalFeatures = [
'Email Reports',
'Advanced Analytics',
'Custom Domains',
'API Access',
'SSO Authentication'
];
const plans: Plan[] = [
// ... plans array from previous section
];
return (
<div className="max-w-6xl mx-auto p-6">
<div className="flex flex-col items-center mb-12">
<h2 className="text-3xl font-bold mb-8">Choose Your Plan</h2>
<div className="relative flex items-center space-x-3">
<span className={`text-sm transition-colors duration-200 ${
!isAnnual ? 'text-blue-600 font-bold' : 'text-gray-600'
}`}>
Monthly
</span>
<button
onClick={() => setIsAnnual(!isAnnual)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-300"
style={{ backgroundColor: isAnnual ? '#3B82F6' : '#E5E7EB' }}
role="switch"
aria-checked={isAnnual}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-lg ring-0 transition duration-300 ease-in-out ${
isAnnual ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<span className={`text-sm transition-colors duration-200 ${
isAnnual ? 'text-blue-600 font-bold' : 'text-gray-600'
}`}>
Annual <span className="ml-1 text-green-500 text-xs">Save 20%</span>
</span>
</div>
</div>
</div>
);
};
export default PricingToggle;
Tailwind Classes Breakdown
Layout Classes:
flex flex-col
: Creates a vertical flex containeritems-center
: Centers children horizontallymb-12
: Adds margin bottom of 3remspace-x-3
: Adds horizontal spacing between flex items
Toggle Button Classes:
h-6 w-11
: Sets height (1.5rem) and width (2.75rem)rounded-full
: Creates fully rounded cornerstransition-colors duration-300
: Smooth color transition over 300mstranslate-x-6/translate-x-1
: Moves toggle knob between positions
5. Adding Feature List Helpers
Let's add our helper functions and start building out the feature display:
import React, { useState } from 'react';
const PricingToggle = () => {
const [isAnnual, setIsAnnual] = useState(false);
// Previous constants remain the same...
const getUnavailableFeatures = (plan: Plan) => {
return additionalFeatures.filter(feature => !plan.availableFeatures.includes(feature));
};
const renderFeatureItem = (feature: string, isAvailable: boolean) => (
<li key={feature} className="flex items-center">
<svg
className={`h-4 w-4 mr-3 flex-shrink-0 ${
isAvailable ? 'text-blue-500' : 'text-gray-300'
}`}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M5 13l4 4L19 7" />
</svg>
<span className={isAvailable ? 'text-gray-600' : 'text-gray-300'}>
{feature}
</span>
</li>
);
return (
<div className="max-w-6xl mx-auto p-6">
<div className="flex flex-col items-center mb-12">
{/* Toggle switch from previous section */}
</div>
{/* We'll add the pricing cards next */}
</div>
);
};
export default PricingToggle;
6. Building the Complete Component
Now let's put everything together with the pricing cards grid:
import React, { useState } from 'react';
type SupportLevel = 'basic' | 'priority';
interface Plan {
name: string;
monthlyPrice: number;
annualPrice: number;
users: string;
storage: string;
support: SupportLevel;
availableFeatures: string[];
}
const PricingToggle = () => {
const [isAnnual, setIsAnnual] = useState(false);
const baseFeatures = {
support: {
basic: 'Basic Support',
priority: 'Priority Support'
} as const
};
const additionalFeatures = [
'Email Reports',
'Advanced Analytics',
'Custom Domains',
'API Access',
'SSO Authentication'
];
const plans: Plan[] = [
{
name: 'Basic',
monthlyPrice: 9,
annualPrice: 90,
users: '1 User',
storage: '10GB Storage',
support: 'basic',
availableFeatures: [
'Email Reports'
]
},
{
name: 'Pro',
monthlyPrice: 29,
annualPrice: 290,
users: '5 Users',
storage: '100GB Storage',
support: 'priority',
availableFeatures: [
'Email Reports',
'Advanced Analytics',
'Custom Domains'
]
},
{
name: 'Enterprise',
monthlyPrice: 99,
annualPrice: 990,
users: 'Unlimited Users',
storage: '1TB Storage',
support: 'priority',
availableFeatures: [
'Email Reports',
'Advanced Analytics',
'Custom Domains',
'API Access',
'SSO Authentication'
]
}
];
const getUnavailableFeatures = (plan: Plan) => {
return additionalFeatures.filter(feature => !plan.availableFeatures.includes(feature));
};
const renderFeatureItem = (feature: string, isAvailable: boolean) => (
<li key={feature} className="flex items-center">
<svg
className={`h-4 w-4 mr-3 flex-shrink-0 ${isAvailable ? 'text-blue-500' : 'text-gray-300'}`}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M5 13l4 4L19 7" />
</svg>
<span className={isAvailable ? 'text-gray-600' : 'text-gray-300'}>
{feature}
</span>
</li>
);
return (
<div className="max-w-6xl mx-auto p-6">
<div className="flex flex-col items-center mb-12">
<h2 className="text-3xl font-bold mb-8">Choose Your Plan</h2>
<div className="relative flex items-center space-x-3">
<span className={`text-sm transition-colors duration-200 ${!isAnnual ? 'text-blue-600 font-bold' : 'text-gray-600'}`}>Monthly</span>
<button
onClick={() => setIsAnnual(!isAnnual)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-300"
style={{ backgroundColor: isAnnual ? '#3B82F6' : '#E5E7EB' }}
role="switch"
aria-checked={isAnnual}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-lg ring-0 transition duration-300 ease-in-out ${
isAnnual ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<span className={`text-sm transition-colors duration-200 ${isAnnual ? 'text-blue-600 font-bold' : 'text-gray-600'}`}>
Annual <span className="ml-1 text-green-500 text-xs">Save 20%</span>
</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{plans.map((plan) => (
<div
key={plan.name}
className="relative p-6 bg-white rounded-xl border border-gray-200 shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
>
<div className="space-y-4">
<h3 className="text-xl font-bold text-gray-900">{plan.name}</h3>
<div className="flex items-baseline text-gray-900">
<span className="text-2xl font-semibold">$</span>
<span className="text-4xl font-bold tracking-tight transition-all duration-300">
{isAnnual ? Math.round(plan.annualPrice / 12) : plan.monthlyPrice}
</span>
<span className="ml-1 text-sm font-medium text-gray-500">/month</span>
</div>
{isAnnual && (
<p className="text-sm text-green-500">
${plan.annualPrice} billed annually
</p>
)}
<ul className="space-y-3 text-sm">
{/* Core features */}
{renderFeatureItem(plan.users, true)}
{renderFeatureItem(plan.storage, true)}
{renderFeatureItem(baseFeatures.support[plan.support], true)}
{/* Available features */}
{plan.availableFeatures.map(feature => renderFeatureItem(feature, true))}
{/* Divider if there are unavailable features */}
{getUnavailableFeatures(plan).length > 0 && (
<li className="border-t border-gray-200 my-4"></li>
)}
{/* Unavailable features */}
{getUnavailableFeatures(plan).map(feature => renderFeatureItem(feature, false))}
</ul>
<button className="mt-6 w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-200">
Get Started
</button>
</div>
</div>
))}
</div>
</div>
);
};
export default PricingToggle;
Understanding the Complete Implementation
Let's break down the key aspects of our final pricing component:
Grid Layout and Responsive Design
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
- Starts as a single column on mobile devices
- Expands to three columns on medium screens (768px and up)
- Maintains consistent 2rem (32px) gap between cards
Pricing Card Structure
Each card is built with several layers:
<div className="relative p-6 bg-white rounded-xl border border-gray-200 shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl">
relative
positioning for potential overlays or badgesp-6
padding creates comfortable spacing- Subtle shadow and border for depth
- Smooth hover animation with scale and enhanced shadow
Price Display
<div className="flex items-baseline text-gray-900"> <span className="text-2xl font-semibold">$</span> <span className="text-4xl font-bold tracking-tight transition-all duration-300"> {isAnnual ? Math.round(plan.annualPrice / 12) : plan.monthlyPrice} </span> <span className="ml-1 text-sm font-medium text-gray-500">/month</span> </div>
- Aligned at baseline for clean typography
- Larger price with bold weight for emphasis
- Smooth transition when switching between billing periods
- Clear indication of billing frequency
Feature Organization
Features are organized into three distinct sections:
- Core features (users, storage, support)
- Available additional features
- Unavailable features with reduced opacity
The divider appears only when there are unavailable features:
{getUnavailableFeatures(plan).length > 0 && ( <li className="border-t border-gray-200 my-4"></li> )}
Call-to-Action Button
<button className="mt-6 w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-200">
- Full width for maximum visibility
- Clear hover and focus states for accessibility
- Smooth color transition on hover
- Focus ring for keyboard navigation
Key Takeaways
- State Management: Single
isAnnual
state controls all pricing calculations and display toggles - Type Safety: TypeScript interfaces ensure consistent data structure
- Responsive Design: Mobile-first approach with breakpoints for larger screens
- User Experience:
- Smooth transitions for all interactive elements
- Clear visual hierarchy
- Consistent spacing and alignment
- Accessible interactive elements
Zephyr Pixels builds websites and online experiences using Sanity and Next.js. Interested in learning more? Get in touch!