Skip to main content

Tailwind css & shadcn step form based register page

Updated 6 min readMarkdown
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">
          &copy; {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

About the author

  • Technology Enthusiast and voracious reader with a demonstrated history of working in the computer software industry. Skilled in PHP, JavaScript, NodeJS, Angular, MySQL, MongoDB, Web3, Product Development, Project Management, and Teamwork.

No comments yet. Be the first to comment.