Multi-Step Form
Beautiful, configurable multi-step form components with validation and progress tracking
Overview
A powerful, fully configurable multi-step form component with built-in validation, progress tracking, and beautiful UI. Perfect for registration flows, surveys, checkout processes, and any multi-step data collection needs.
Features
- Fully Configurable - Define steps and fields via configuration objects
- Built-in Validation - Powered by Zod and React Hook Form
- Multiple Field Types - Text, email, number, tel, date, textarea, and select
- Step Indicator - Beautiful progress visualization with animations
- Responsive Design - Works seamlessly on all screen sizes
- Success Dialog - Customizable completion confirmation
- Type Safe - Full TypeScript support
- Dark Mode - Fully supports dark/light themes
Variants
Multi-Step Form - Glass Morphism
A stunning form with glass morphism effects and liquid-style background.
Step 1
Getting started
Step 2
In progress
Step 3
Almost there
Step 4
Complete
Configuration
Step Configuration
Define the steps for your form:
interface Step {
id: number
name: string
description: string
}
const steps: Step[] = [
{ id: 1, name: "Personal", description: "Basic info" },
{ id: 2, name: "Contact", description: "Contact details" },
{ id: 3, name: "Review", description: "Confirm" },
]Field Configuration
Configure fields with full validation support:
type FieldType = "text" | "email" | "number" | "tel" | "date" | "textarea" | "select"
interface FieldConfig {
name: string
label: string
type: FieldType
placeholder?: string
gridColumn?: "full" | "half" // Layout control
validation?: {
required?: string
minLength?: { value: number; message: string }
maxLength?: { value: number; message: string }
pattern?: { value: RegExp; message: string }
min?: { value: number; message: string }
max?: { value: number; message: string }
}
options?: { label: string; value: string }[] // For select fields
}Example Field Configurations
{
name: "fullName",
label: "Full Name",
type: "text",
placeholder: "John Doe",
gridColumn: "full", // or "half"
validation: {
required: "Name is required",
minLength: { value: 2, message: "At least 2 characters" },
maxLength: { value: 50, message: "Max 50 characters" },
},
}Component Props
MultiStepFormSimple Props
interface MultiStepFormProps {
steps: Step[] // Array of step configurations
formData: StepFormData[] // Array of field configurations per step
title?: string // Form title (default: "Complete Your Registration")
description?: string // Form description
onComplete?: (data: Record<string, Record<string, FormFieldValue>>) => void // Callback on completion
successTitle?: string // Success dialog title
successDescription?: string // Success dialog description
}Complete Example
Here's a comprehensive example with multiple steps and field types:
// lib/form.config.ts
export const registrationSteps = [
{ id: 1, name: "Personal", description: "Basic info" },
{ id: 2, name: "Contact", description: "Details" },
{ id: 3, name: "Profile", description: "About you" },
{ id: 4, name: "Payment", description: "Billing" },
]
export const registrationFields = [
{
stepId: 1,
fields: [
{
name: "fullName",
label: "Full Name",
type: "text" as const,
placeholder: "John Doe",
gridColumn: "full" as const,
validation: {
required: "Name is required",
minLength: { value: 2, message: "Name must be at least 2 characters" },
},
},
{
name: "email",
label: "Email Address",
type: "email" as const,
placeholder: "john@example.com",
gridColumn: "half" as const,
validation: {
required: "Email is required",
},
},
{
name: "dateOfBirth",
label: "Date of Birth",
type: "date" as const,
gridColumn: "half" as const,
validation: {
required: "Date of birth is required",
},
},
],
},
{
stepId: 2,
fields: [
{
name: "address",
label: "Address",
type: "text" as const,
placeholder: "123 Main Street",
gridColumn: "full" as const,
validation: {
required: "Address is required",
},
},
{
name: "city",
label: "City",
type: "text" as const,
placeholder: "New York",
gridColumn: "half" as const,
validation: {
required: "City is required",
},
},
{
name: "zipCode",
label: "Zip Code",
type: "text" as const,
placeholder: "10001",
gridColumn: "half" as const,
validation: {
required: "Zip code is required",
},
},
],
},
{
stepId: 3,
fields: [
{
name: "country",
label: "Country",
type: "select" as const,
placeholder: "Select your country",
gridColumn: "full" as const,
options: [
{ label: "United States", value: "us" },
{ label: "Canada", value: "ca" },
{ label: "United Kingdom", value: "uk" },
],
validation: {
required: "Please select a country",
},
},
{
name: "bio",
label: "Bio",
type: "textarea" as const,
placeholder: "Tell us about yourself...",
gridColumn: "full" as const,
validation: {
minLength: { value: 10, message: "Bio must be at least 10 characters" },
},
},
],
},
{
stepId: 4,
fields: [
{
name: "cardNumber",
label: "Card Number",
type: "text" as const,
placeholder: "1234 5678 9012 3456",
gridColumn: "full" as const,
validation: {
required: "Card number is required",
minLength: { value: 16, message: "Card number must be 16 digits" },
},
},
{
name: "expiryDate",
label: "Expiry Date",
type: "text" as const,
placeholder: "MM/YY",
gridColumn: "half" as const,
validation: {
required: "Expiry date is required",
pattern: { value: /^(0[1-9]|1[0-2])\/\d{2}$/, message: "Format: MM/YY" },
},
},
{
name: "cvv",
label: "CVV",
type: "text" as const,
placeholder: "123",
gridColumn: "half" as const,
validation: {
required: "CVV is required",
minLength: { value: 3, message: "CVV must be 3 digits" },
},
},
],
},
]// app/registration/page.tsx
"use client"
import { MultiStepFormSimple } from '@/components/aesthe-ui/multi-form/multi-step-form'
import { registrationSteps, registrationFields } from '@/lib/form.config'
export default function RegistrationPage() {
const handleComplete = async (data: Record<string, Record<string, unknown>>) => {
console.log("Form completed with data:", data)
// Example: Send to API
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (response.ok) {
console.log("Registration successful!")
}
} catch (error) {
console.error("Registration failed:", error)
}
}
return (
<MultiStepFormSimple
steps={registrationSteps}
formData={registrationFields}
title="Complete Your Registration"
description="Follow the steps below to create your account"
onComplete={handleComplete}
successTitle="Welcome Aboard! 🎉"
successDescription="Your account has been successfully created. Check your email for verification."
/>
)
}Installation
Install Required Dependencies
npm install react-hook-form @hookform/resolvers zod date-fns lucide-reactCopy Component Files
-
Copy the main component:
components/aesthe-ui/multi-form/multi-step-form.tsxcomponents/aesthe-ui/multi-form/multi-step-form-simple.tsx(alternative style)
-
Copy the step indicator:
components/aesthe-ui/multi-form/step-indicator.tsx
-
Ensure you have the required shadcn/ui components:
- Button, Card, Dialog, Form, Input, Calendar, Popover, Select, Textarea
Install shadcn/ui Components
npx shadcn-ui@latest add button card dialog form input calendar popover select textareaComplete Component Code
Multi-Step Form (Glass Morphism)
Copy this complete code to components/aesthe-ui/multi-form/multi-step-form.tsx:
"use client"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { format } from "date-fns"
import { CalendarIcon, Check, ArrowLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { StepIndicator } from "./step-indicator"
import BtnShimmer from "@/components/aesthe-ui/button/btn-shimmer"
// Types for configuration
export type FormFieldValue = string | number | Date | boolean | undefined
export interface Step {
id: number
name: string
description: string
}
export type FieldType = "text" | "email" | "number" | "tel" | "date" | "textarea" | "select"
export interface FieldConfig {
name: string
label: string
type: FieldType
placeholder?: string
validation?: {
required?: string
minLength?: { value: number; message: string }
maxLength?: { value: number; message: string }
pattern?: { value: RegExp; message: string }
min?: { value: number; message: string }
max?: { value: number; message: string }
}
options?: { label: string; value: string }[] // For select fields
gridColumn?: "full" | "half" // For layout
}
export interface StepFormData {
stepId: number
fields: FieldConfig[]
}
export interface MultiStepFormProps {
steps: Step[]
formData: StepFormData[]
title?: string
description?: string
onComplete?: (data: Record<string, Record<string, FormFieldValue>>) => void
successTitle?: string
successDescription?: string
}
// Helper to create Zod schema from field config
function createZodSchema(fields: FieldConfig[]) {
const schemaFields: Record<string, z.ZodTypeAny> = {}
fields.forEach((field) => {
let fieldSchema: z.ZodTypeAny
switch (field.type) {
case "email":
fieldSchema = z.string().email(field.validation?.required || "Invalid email address")
break
case "number":
fieldSchema = z.coerce.number()
if (field.validation?.min) {
fieldSchema = (fieldSchema as z.ZodNumber).min(
field.validation.min.value,
field.validation.min.message
)
}
if (field.validation?.max) {
fieldSchema = (fieldSchema as z.ZodNumber).max(
field.validation.max.value,
field.validation.max.message
)
}
break
case "date":
fieldSchema = z.date({
message: field.validation?.required || "Date is required",
})
break
default:
fieldSchema = z.string()
if (field.validation?.required) {
fieldSchema = (fieldSchema as z.ZodString).min(1, field.validation.required)
}
if (field.validation?.minLength) {
fieldSchema = (fieldSchema as z.ZodString).min(
field.validation.minLength.value,
field.validation.minLength.message
)
}
if (field.validation?.maxLength) {
fieldSchema = (fieldSchema as z.ZodString).max(
field.validation.maxLength.value,
field.validation.maxLength.message
)
}
if (field.validation?.pattern) {
fieldSchema = (fieldSchema as z.ZodString).regex(
field.validation.pattern.value,
field.validation.pattern.message
)
}
}
schemaFields[field.name] = fieldSchema
})
return z.object(schemaFields)
}
export function MultiStepFormSimple({
steps,
formData,
title = "Complete Your Registration",
description = "Follow the steps below to get started",
onComplete,
successTitle = "Success!",
successDescription = "Your information has been successfully submitted.",
}: MultiStepFormProps) {
const [currentStep, setCurrentStep] = useState(1)
const [showSuccess, setShowSuccess] = useState(false)
const [collectedData, setCollectedData] = useState<Record<string, Record<string, FormFieldValue>>>({})
// Get current step form data
const currentStepData = formData.find((data) => data.stepId === currentStep)
const currentStepFields = currentStepData?.fields || []
// Create dynamic schema
const schema = createZodSchema(currentStepFields)
type FormData = z.infer<typeof schema>
// Initialize form
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: collectedData[\`step\${currentStep}\`] || {},
})
const isLastStep = currentStep === steps.length
const onSubmit = (data: FormData) => {
const newData = { ...collectedData, [\`step\${currentStep}\`]: data as Record<string, FormFieldValue> }
setCollectedData(newData)
if (isLastStep) {
// Final submission
setShowSuccess(true)
if (onComplete) {
onComplete(newData)
}
} else {
// Move to next step
setCurrentStep(currentStep + 1)
form.reset()
}
}
const handleBack = () => {
setCurrentStep(currentStep - 1)
form.reset()
}
const handleReset = () => {
setShowSuccess(false)
setCurrentStep(1)
setCollectedData({})
form.reset()
}
// Render field based on type
const renderField = (field: FieldConfig) => {
return (
<FormField
key={field.name}
control={form.control}
name={field.name as keyof FormData}
render={({ field: formField }) => (
<FormItem className={field.gridColumn === "half" ? "col-span-1" : "col-span-2"}>
<FormLabel className="text-sm font-semibold text-white/90">{field.label}</FormLabel>
<FormControl>
{field.type === "textarea" ? (
<Textarea
placeholder={field.placeholder}
{...formField}
value={formField.value as string}
className="min-h-[100px]"
/>
) : field.type === "date" ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"h-11 pl-3 text-left font-normal w-full bg-white/10 backdrop-blur-md border-white/20 text-white hover:bg-white/15 hover:border-white/40 transition-all duration-300",
!formField.value && "text-white/60"
)}
>
{formField.value ? format(formField.value as Date, "PPP") : <span>Pick a date</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0 bg-black/20 backdrop-blur-xl"
align="start"
>
<Calendar
mode="single"
selected={formField.value as Date}
onSelect={formField.onChange}
disabled={(date) => date > new Date() || date < new Date("1900-01-01")}
initialFocus
className="pointer-events-auto bg-transparent"
/>
</PopoverContent>
</Popover>
) : field.type === "select" ? (
<Select onValueChange={formField.onChange} defaultValue={formField.value as string}>
<SelectTrigger className="h-11 bg-white/10 backdrop-blur-md border-white/20 text-white hover:bg-white/15 hover:border-white/40 transition-all duration-300">
<SelectValue placeholder={field.placeholder || "Select an option"} className="text-white" />
</SelectTrigger>
<SelectContent
className="bg-black/20 backdrop-blur-xl border-white/20"
style={{
background: "rgba(0, 0, 0, 0.2)",
backdropFilter: "blur(20px)",
border: "1px solid rgba(255, 255, 255, 0.2)",
}}
>
{field.options?.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className="text-white focus:bg-white/20 focus:text-white"
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
type={field.type}
placeholder={field.placeholder}
{...formField}
value={formField.value as string | number}
className="h-11 bg-white/10 backdrop-blur-md border-white/20 text-white placeholder:text-white/60 focus:bg-white/15 focus:border-white/40 transition-all duration-300"
/>
)}
</FormControl>
<FormMessage className="text-red-300" />
</FormItem>
)}
/>
)
}
return (
<div className="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
{/* Animated Liquid Glass Background */}
<div className="absolute inset-0 -z-10">
{/* Base gradient */}
{/* <div className="absolute inset-0 bg-gradient-to-br from-blue-500/20 via-purple-500/20 to-pink-500/20" /> */}
{/* Animated glass bubbles */}
{/* <div className="absolute top-1/4 left-1/4 w-64 h-64 bg-gray-400/10 rounded-full blur-3xl animate-pulse" />
<div className="absolute top-1/3 right-1/4 w-80 h-80 bg-gray-400/10 rounded-full blur-3xl animate-pulse delay-1000" />
<div className="absolute bottom-1/4 left-1/3 w-72 h-72 bg-gray-400/10 rounded-full blur-3xl animate-pulse delay-500" />
<div className="absolute bottom-1/3 right-1/3 w-96 h-96 bg-gray-400/10 rounded-full blur-3xl animate-pulse delay-1500" /> */}
{/* Noise texture for authentic glass effect */}
</div>
{/* Main Card with Liquid Glass Effect */}
<Card
className="w-full max-w-3xl border-white/20 bg-black/10 backdrop-blur-xl shadow-2xl relative overflow-hidden"
style={{
// background: "rgba(0, 0, 0, 0.1)",
// backdropFilter: "blur(20px)",
// border: "1px solid rgba(255, 255, 255, 0.2)",
// boxShadow: "0 8px 32px 0 rgba(31, 38, 135, 0.37)",
}}
>
{/* Inner shine effect */}
{/* <div className="absolute inset-0 bg-gradient-to-br from-white/5 to-transparent pointer-events-none" /> */}
{/* Subtle border glow */}
<div className="absolute inset-0 rounded-xl bg-gray-400/10 blur-sm opacity-50 pointer-events-none" />
<CardHeader className="pb-8 relative z-10">
<CardTitle className="text-3xl font-bold bg-gradient-to-r from-white to-white/80 bg-clip-text text-transparent">
{title}
</CardTitle>
<CardDescription className="text-base mt-2 text-white/80">
{description}
</CardDescription>
</CardHeader>
{/* Step Indicator */}
<div className="px-6 pb-12 relative z-10">
<StepIndicator steps={steps} currentStep={currentStep} />
</div>
<CardContent className="pb-8 relative z-10">
<div className="animate-in fade-in duration-300">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
{currentStepFields.map((field) => renderField(field))}
</div>
<div className={cn("flex pt-6 gap-4", currentStep === 1 ? "justify-end" : "justify-between")}>
{currentStep > 1 && (
<Button
type="button"
variant="outline"
size="lg"
onClick={handleBack}
className="gap-2 bg-white/10 backdrop-blur-md border-white/20 text-white hover:bg-white/20 hover:border-white/40 transition-all duration-300"
>
<ArrowLeft className="w-4 h-4" />
Back
</Button>
)}
<BtnShimmer
onClick={form.handleSubmit(onSubmit)}
>
Submit
</BtnShimmer>
</div>
</form>
</Form>
</div>
</CardContent>
</Card>
{/* Success Dialog with Liquid Glass Effect */}
<Dialog open={showSuccess} onOpenChange={setShowSuccess}>
<DialogContent
className="sm:max-w-md border-white/20 bg-black/20 backdrop-blur-xl"
style={{
background: "rgba(0, 0, 0, 0.2)",
backdropFilter: "blur(20px)",
border: "1px solid rgba(255, 255, 255, 0.2)",
boxShadow: "0 8px 32px 0 rgba(31, 38, 135, 0.37)",
}}
>
{/* Inner shine for dialog */}
<div className="absolute inset-0 bg-gradient-to-br from-white/5 to-transparent pointer-events-none rounded-lg" />
<DialogHeader className="text-center relative z-10">
<div className="mx-auto mb-4 w-16 h-16 bg-gradient-to-r from-green-400 to-emerald-500 rounded-full flex items-center justify-center shadow-lg">
<Check className="w-10 h-10 text-white" />
</div>
<DialogTitle className="text-2xl bg-gradient-to-r from-white to-white/80 bg-clip-text text-transparent">
{successTitle}
</DialogTitle>
<DialogDescription className="text-base pt-2 text-white/80">
{successDescription}
</DialogDescription>
</DialogHeader>
<div className="flex justify-center pt-4 relative z-10">
<Button
className="bg-gradient-to-r from-blue-500/90 to-purple-500/90 backdrop-blur-md border-white/20 text-white hover:from-blue-600/90 hover:to-purple-600/90 hover:scale-105 transform transition-all duration-300"
onClick={handleReset}
>
Start New
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}Step Indicator Component
Copy this code to components/aesthe-ui/multi-form/step-indicator.tsx:
'use client'
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
interface Step {
id: number
name: string
description: string
}
interface StepIndicatorProps {
steps: Step[]
currentStep: number
}
export function StepIndicator({ steps, currentStep }: StepIndicatorProps) {
const progress = ((currentStep - 1) / (steps.length - 1)) * 100
return (
<div className="w-full relative">
{/* Background thread line */}
<div className="absolute top-5 left-0 right-0 h-1 flex items-center px-6">
<div className="w-full h-full bg-border rounded-full" />
</div>
{/* Active thread line */}
<div className="absolute top-5 left-0 right-0 h-1 flex items-center px-6">
<div
className="h-full bg-primary rounded-full transition-all duration-500 ease-out"
style={{
width: \`\${progress}%\`,
}}
/>
</div>
<div className="flex justify-between relative">
{steps.map((step) => {
const isCompleted = currentStep > step.id
const isActive = currentStep === step.id
return (
<div key={step.id} className="flex flex-col items-center">
{/* Step circle */}
<div
className={cn(
"w-12 h-12 rounded-full flex items-center justify-center font-semibold text-sm transition-all duration-300 shadow-md",
isCompleted
? "bg-primary text-primary-foreground ring-2 ring-primary ring-offset-2"
: isActive
? "bg-primary text-primary-foreground ring-2 ring-primary ring-offset-2 scale-110"
: "bg-card text-muted-foreground border-2 border-border"
)}
>
{isCompleted ? <Check className="w-5 h-5" /> : <span>{step.id}</span>}
</div>
{/* Step labels */}
<div className="text-center mt-3 w-[100px]">
<p
className={cn(
"text-sm font-medium transition-colors duration-300",
isActive || isCompleted ? "text-foreground" : "text-muted-foreground",
)}
>
{step.name}
</p>
<p className="text-xs text-muted-foreground">{step.description}</p>
</div>
</div>
)
})}
</div>
</div>
)
}Field Types Reference
| Type | Description | Example |
|---|---|---|
text | Standard text input | Name, address, etc. |
email | Email input with validation | Email addresses |
number | Numeric input | Age, quantity, etc. |
tel | Telephone number input | Phone numbers |
date | Date picker with calendar | Birth date, appointments |
textarea | Multi-line text area | Bio, comments, descriptions |
select | Dropdown select menu | Country, category, etc. |
Validation Rules
All fields support comprehensive validation:
- required: Field must be filled
- minLength: Minimum character length
- maxLength: Maximum character length
- min: Minimum value (for numbers)
- max: Maximum value (for numbers)
- pattern: Custom regex pattern matching
Grid Layout
Control field width with gridColumn:
"full"- Spans 2 columns (full width)"half"- Spans 1 column (half width)
{
name: "address",
gridColumn: "full", // Takes full width
// ...
}
{
name: "city",
gridColumn: "half", // Takes half width
// ...
}Styling
The component supports both glass morphism and clean variants:
- Glass Morphism (
multi-step-form.tsx) - Frosted glass effects with liquid backgrounds - Clean (
multi-step-form-simple.tsx) - Minimal design with focus on content
Both variants are fully themeable and support dark mode.
API Response Format
When form is completed, data is structured by step:
{
"step1": {
"fullName": "John Doe",
"email": "john@example.com",
"dateOfBirth": "1990-01-01T00:00:00.000Z"
},
"step2": {
"address": "123 Main St",
"city": "New York",
"zipCode": "10001"
},
"step3": {
"country": "us",
"bio": "Software developer..."
}
}Tips & Best Practices
- Keep steps logical - Group related fields together
- Limit fields per step - 3-5 fields per step is ideal
- Use appropriate field types - Improves UX and validation
- Provide helpful placeholders - Guide users on expected format
- Write clear validation messages - Help users correct errors
- Test on mobile - Ensure forms work well on small screens
- Consider progressive disclosure - Show only relevant fields
Accessibility
The component includes:
- Proper ARIA labels
- Keyboard navigation support
- Focus management
- Screen reader friendly
- Error announcements
- High contrast support
Examples
Check out the live examples:
- Minimal Example:
demo-minimal.tsx- Basic 3-step form - Full Example:
demo-simple.tsx- Complete registration flow
Copy these examples to get started quickly!