Plan Change Modal
A multi-step modal wizard for upgrading or downgrading subscription plans with proration preview and confirmation.
Preview
Features
- 3-step wizard: Select Plan → Review Changes → Confirm
- Visual step indicator with progress
- Upgrade/downgrade badges on plan options
- Proration calculation with credit and charge breakdown
- Net amount display (charge or refund)
- Async confirmation handler
- Responsive modal design with wrapped steps and stacked actions on mobile
Install Summary
This kit will add the following files and dependencies to your project. Download the bundle and extract it into your project root.
Component Files
| File | Path |
|---|---|
| plan-change-modal.tsx | components/billing/plan-change-modal/plan-change-modal.tsx |
| plan-change-context.tsx | components/billing/plan-change-modal/plan-change-context.tsx |
| step-select-plan.tsx | components/billing/plan-change-modal/step-select-plan.tsx |
| step-review-changes.tsx | components/billing/plan-change-modal/step-review-changes.tsx |
| step-confirm.tsx | components/billing/plan-change-modal/step-confirm.tsx |
| types.ts | components/billing/plan-change-modal/types.ts |
| index.ts | components/billing/plan-change-modal/index.ts |
Utility Files
| File | Path |
|---|---|
| format.ts | lib/format.ts |
| design-tokens.ts | lib/design-tokens.ts |
Dependencies
Install these dependencies before using the component:
Terminal
bash
npx shadcn@latest add dialog buttonTerminal
bash
npm install @phosphor-icons/reactInstallation
Download the complete bundle as a ZIP file, or copy the text bundle to your clipboard:
Component Files
The component consists of the following files:
1. plan-change-modal.tsx
plan-change-modal.tsx
tsx
"use client";
import { useState, useEffect } from "react";
import { ArrowClockwise as RefreshCw } from "@phosphor-icons/react";
import {2. plan-change-context.tsx
plan-change-context.tsx
tsx
"use client";
import React, { createContext, useContext, useState, useMemo } from "react";
import type {
PlanChangeStep,3. step-select-plan.tsx
step-select-plan.tsx
tsx
"use client";
import { Check, ArrowUp, ArrowDown } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";4. step-review-changes.tsx
step-review-changes.tsx
tsx
"use client";
import { ArrowRight, ArrowUp, ArrowDown, Calendar, CreditCard } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";5. step-confirm.tsx
step-confirm.tsx
tsx
"use client";
import { SealCheck as CheckCircle2 } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import { usePlanChange } from "./plan-change-context";6. types.ts
types.ts
tsx
export type PlanChangeStep = "select" | "review" | "confirm";
export type BillingInterval = "monthly" | "yearly";
export interface Plan {7. index.ts
index.ts
tsx
export { PlanChangeModal } from "./plan-change-modal";
export { PlanChangeProvider, usePlanChange } from "./plan-change-context";
export type {
PlanChangeStep,
Plan,
ProrationDetails,
PlanChangeModalProps,
} from "./types";
Shared Utilities
This component uses shared utility functions. These are included in the bundle above:
format.ts
format.ts
tsx
export function formatDate(date: Date): string {
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export function formatRelativeTime(date: Date, now: Date = new Date()): string {
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return formatDate(date);
}
export function formatPrice(amount: number, currency = "USD"): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(amount);
}
export function formatNumber(num: number): string {
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
return num.toString();
}
design-tokens.ts
design-tokens.ts
tsx
// Border radius
export const radius = {
card: "rounded-xl",
badge: "rounded-full",
button: "rounded-lg",Usage
app/billing/page.tsx
tsx
"use client";
import { useState } from "react";
import { PlanChangeModal, type Plan } from "@/components/billing";
import { Button } from "@/components/ui/button";Props
| Prop | Type | Default |
|---|---|---|
| open | boolean | Required |
| onOpenChange | (open: boolean) => void | Required |
| currentPlan | Plan | Required |
| availablePlans | Plan[] | Required |
| currentPeriodEnd | Date | Required |
| onConfirm | (plan: Plan, proration: ProrationDetails) => void | Promise | - |
| className | string | - |