Tailwind css & shadcn step form based register page
import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Link, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { format } from "date-fns"
import {
CalendarIcon,
Loader2,
Eye,
EyeOff,
ShieldCheck,
HeartPulse,
ArrowLeft,
ArrowRight,
} from "lucide-react"
import { cn, NativeSelect } from "@hms/shared"
import { Button } from "@hms/shared/components/ui/button"
import { Calendar } from "@hms/shared/components/ui/calendar"
import { Input } from "@hms/shared/components/ui/input"
import { Label } from "@hms/shared/components/ui/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@hms/shared/components/ui/popover"
const STEPS = [
{ title: "Personal Info", description: "Tell us about yourself" },
{ title: "Contact Details", description: "How can we reach you?" },
{ title: "Security", description: "Set up your password and preferences" },
] as const
const stepOneSchema = z.object({
fullName: z.string().min(2, "Full name is required"),
gender: z.string().min(1, "Please select a gender"),
dateOfBirth: z.string().min(1, "Date of birth is required"),
})
const stepTwoSchema = z.object({
phone: z.string().min(10, "Enter a valid phone number"),
email: z.string().email("Enter a valid email").optional().or(z.literal("")),
})
const registerSchema = z
.object({
fullName: z.string().min(2, "Full name is required"),
gender: z.string().min(1, "Please select a gender"),
dateOfBirth: z.string().min(1, "Date of birth is required"),
phone: z.string().min(10, "Enter a valid phone number"),
email: z.string().email("Enter a valid email").optional().or(z.literal("")),
password: z.string().min(6, "Password must be at least 6 characters"),
confirmPassword: z.string(),
bloodGroup: z.string().optional(),
city: z.string().optional(),
})
.refine((d) => d.password === d.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
})
type RegisterFormValues = z.infer<typeof registerSchema>
export default function SplitRegister() {
const navigate = useNavigate()
const [step, setStep] = useState(0)
const [dobOpen, setDobOpen] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const {
register,
handleSubmit,
setValue,
watch,
trigger,
formState: { errors, isSubmitting },
} = useForm<RegisterFormValues>({
resolver: zodResolver(registerSchema),
mode: "onTouched",
})
const dobValue = watch("dateOfBirth")
async function onSubmit(data: RegisterFormValues) {
await new Promise((r) => setTimeout(r, 1000))
localStorage.setItem("access_token", "mock-token")
localStorage.setItem(
"user",
JSON.stringify({
name: data.fullName,
email: data.email || data.phone,
role: "PATIENT",
})
)
toast.success("Account created successfully!")
navigate("/dashboard")
}
async function handleNext() {
if (step === 0) {
const fields = Object.keys(stepOneSchema.shape) as (keyof z.infer<typeof stepOneSchema>)[]
const valid = await trigger(fields)
if (!valid) return
} else if (step === 1) {
const fields = Object.keys(stepTwoSchema.shape) as (keyof z.infer<typeof stepTwoSchema>)[]
const valid = await trigger(fields)
if (!valid) return
}
setStep((s) => Math.min(s + 1, STEPS.length - 1))
}
function handleBack() {
setStep((s) => Math.max(s - 1, 0))
}
const inputClass =
"h-11 rounded-lg border-zinc-300 bg-white text-zinc-900 placeholder:text-zinc-400 focus-visible:border-zinc-900 focus-visible:ring-zinc-900/20"
const selectClass =
"h-11 rounded-lg border-zinc-300 bg-white text-zinc-900 focus-visible:border-zinc-900 focus-visible:ring-zinc-900/20"
return (
<div className="flex min-h-svh">
{/* Left Panel - Dark Branding */}
<div className="hidden lg:flex lg:w-1/2 bg-zinc-950 text-white flex-col justify-between p-10">
<div>
<h1 className="text-3xl font-bold tracking-tight">Get Started</h1>
<p className="mt-2 text-zinc-400">
Create your account to manage your health journey
</p>
</div>
<div className="space-y-6">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-zinc-800">
<ShieldCheck className="h-5 w-5 text-zinc-300" />
</div>
<div>
<h3 className="font-semibold">Private & Secure</h3>
<p className="text-sm text-zinc-400">
Your health data is encrypted and protected at all times
</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-zinc-800">
<HeartPulse className="h-5 w-5 text-zinc-300" />
</div>
<div>
<h3 className="font-semibold">Complete Health Management</h3>
<p className="text-sm text-zinc-400">
Appointments, records, prescriptions — all in one place
</p>
</div>
</div>
</div>
<p className="text-sm text-zinc-500">
© {new Date().getFullYear()} Desk. All rights reserved.
</p>
</div>
{/* Right Panel - Step Form */}
<div className="flex w-full lg:w-1/2 items-center justify-center bg-white p-6 sm:p-10">
<div className="w-full max-w-md space-y-6">
{/* Header */}
<div>
<h2 className="text-2xl font-bold text-zinc-900">{STEPS[step].title}</h2>
<p className="mt-1 text-sm text-zinc-500">{STEPS[step].description}</p>
</div>
{/* Step Indicator */}
<div className="flex items-center gap-2">
{STEPS.map((_, i) => (
<div
key={i}
className={cn(
"h-1.5 flex-1 rounded-full transition-colors",
i <= step ? "bg-zinc-900" : "bg-zinc-200"
)}
/>
))}
</div>
<p className="text-xs text-zinc-400">
Step {step + 1} of {STEPS.length}
</p>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Step 1: Personal Info */}
{step === 0 && (
<>
<div className="space-y-2">
<Label htmlFor="fullName" className="text-zinc-700">Full Name</Label>
<Input
id="fullName"
placeholder="John Doe"
className={inputClass}
aria-invalid={!!errors.fullName}
{...register("fullName")}
/>
{errors.fullName && (
<p className="text-sm text-destructive">{errors.fullName.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="gender" className="text-zinc-700">Gender</Label>
<NativeSelect
id="gender"
className={selectClass}
aria-invalid={!!errors.gender}
{...register("gender")}
>
<option value="">Select gender</option>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</NativeSelect>
{errors.gender && (
<p className="text-sm text-destructive">{errors.gender.message}</p>
)}
</div>
<div className="space-y-2">
<Label className="text-zinc-700">Date of Birth</Label>
<Popover open={dobOpen} onOpenChange={setDobOpen}>
<PopoverTrigger
className={cn(
"flex h-11 w-full items-center justify-start gap-2 rounded-lg border border-zinc-300 bg-white px-3 text-sm",
"focus-visible:border-zinc-900 focus-visible:ring-3 focus-visible:ring-zinc-900/20 focus-visible:outline-none",
!dobValue && "text-zinc-400"
)}
>
<CalendarIcon className="size-4" />
{dobValue ? format(new Date(dobValue), "PPP") : "Pick a date"}
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
mode="single"
selected={dobValue ? new Date(dobValue) : undefined}
onSelect={(date) => {
setValue(
"dateOfBirth",
date ? format(date, "yyyy-MM-dd") : "",
{ shouldValidate: true }
)
setDobOpen(false)
}}
captionLayout="dropdown"
defaultMonth={new Date(1990, 0)}
disabled={{ after: new Date() }}
/>
</PopoverContent>
</Popover>
{errors.dateOfBirth && (
<p className="text-sm text-destructive">{errors.dateOfBirth.message}</p>
)}
</div>
</>
)}
{/* Step 2: Contact Details */}
{step === 1 && (
<>
<div className="space-y-2">
<Label htmlFor="phone" className="text-zinc-700">Mobile Number</Label>
<Input
id="phone"
type="tel"
placeholder="+91 9876543210"
className={inputClass}
aria-invalid={!!errors.phone}
{...register("phone")}
/>
{errors.phone && (
<p className="text-sm text-destructive">{errors.phone.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-zinc-700">Email (optional)</Label>
<Input
id="email"
type="email"
placeholder="patient@example.com"
autoComplete="email"
className={inputClass}
aria-invalid={!!errors.email}
{...register("email")}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
</>
)}
{/* Step 3: Security & Preferences */}
{step === 2 && (
<>
<div className="space-y-2">
<Label htmlFor="password" className="text-zinc-700">Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
autoComplete="new-password"
className={cn(inputClass, "pr-10")}
aria-invalid={!!errors.password}
{...register("password")}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600"
tabIndex={-1}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-zinc-700">Confirm Password</Label>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
placeholder="••••••••"
autoComplete="new-password"
className={cn(inputClass, "pr-10")}
aria-invalid={!!errors.confirmPassword}
{...register("confirmPassword")}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600"
tabIndex={-1}
aria-label={showConfirmPassword ? "Hide password" : "Show password"}
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{errors.confirmPassword && (
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="bloodGroup" className="text-zinc-700">Blood Group</Label>
<NativeSelect id="bloodGroup" className={selectClass} {...register("bloodGroup")}>
<option value="">Select (optional)</option>
<option value="A+">A+</option>
<option value="A-">A-</option>
<option value="B+">B+</option>
<option value="B-">B-</option>
<option value="AB+">AB+</option>
<option value="AB-">AB-</option>
<option value="O+">O+</option>
<option value="O-">O-</option>
</NativeSelect>
</div>
<div className="space-y-2">
<Label htmlFor="city" className="text-zinc-700">City</Label>
<Input
id="city"
placeholder="Chennai"
className={inputClass}
{...register("city")}
/>
</div>
</div>
</>
)}
{/* Navigation Buttons */}
<div className="flex gap-3 pt-2">
{step > 0 && (
<Button
type="button"
variant="outline"
size="lg"
className="h-11 flex-1 rounded-lg border-zinc-300 text-zinc-700 hover:bg-zinc-50"
onClick={handleBack}
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
)}
{step < STEPS.length - 1 ? (
<Button
type="button"
size="lg"
className="h-11 flex-1 rounded-lg bg-zinc-900 text-white hover:bg-zinc-800"
onClick={handleNext}
>
Continue
<ArrowRight className="h-4 w-4" />
</Button>
) : (
<Button
type="submit"
size="lg"
className="h-11 flex-1 rounded-lg bg-zinc-900 text-white hover:bg-zinc-800"
disabled={isSubmitting}
>
{isSubmitting && <Loader2 className="animate-spin" />}
{isSubmitting ? "Creating account..." : "Create Account"}
</Button>
)}
</div>
</form>
<p className="text-sm text-zinc-500 text-center">
Already have an account?{" "}
<Link to="/auth/login" className="text-zinc-900 font-medium hover:underline">
Sign in
</Link>
</p>
</div>
</div>
</div>
)
}
Example output
