Dependencies
tailwindcssshadcn/uireact-hook-formzod@hookform/resolverslucide-react
shadcn/ui components
npx shadcn@latest add buttonnpx shadcn@latest add inputnpx shadcn@latest add labelnpx shadcn@latest add cardnpx shadcn@latest add form

These 4 login form patterns cover the most common authentication UI requirements. All use react-hook-form for form state and zod for validation.

Installation

npx shadcn@latest add button input label card form
npm install react-hook-form zod @hookform/resolvers
npm install lucide-react

Minimal Setup

import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(6),
})

const form = useForm({ resolver: zodResolver(schema) })

1. Basic React Login Form with Validation

Clean login form using react-hook-form and zod validation with shadcn/ui components.

tsx
import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Eye, EyeOff, Loader2 } from "lucide-react"

const loginSchema = z.object({
  email: z.string().email("Please enter a valid email"),
  password: z.string().min(6, "Password must be at least 6 characters"),
})

type LoginForm = z.infer<typeof loginSchema>

export default function BasicLoginForm() {
  const [showPassword, setShowPassword] = useState(false)
  const [isLoading, setIsLoading] = useState(false)

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<LoginForm>({ resolver: zodResolver(loginSchema) })

  const onSubmit = async (data: LoginForm) => {
    setIsLoading(true)
    await new Promise((r) => setTimeout(r, 1500)) // simulate API
    console.log("Login:", data)
    setIsLoading(false)
  }

  return (
    <div className="w-full max-w-sm space-y-6">
      <div className="text-center">
        <h1 className="text-2xl font-bold" style={{ color: "#fd4766" }}>Sign In</h1>
        <p className="text-sm text-muted-foreground mt-1">Enter your credentials to continue</p>
      </div>

      <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
        <div className="space-y-1.5">
          <Label htmlFor="email">Email</Label>
          <Input
            id="email"
            type="email"
            placeholder="you@example.com"
            {...register("email")}
            className={errors.email ? "border-destructive" : ""}
          />
          {errors.email && (
            <p className="text-xs text-destructive">{errors.email.message}</p>
          )}
        </div>

        <div className="space-y-1.5">
          <div className="flex justify-between">
            <Label htmlFor="password">Password</Label>
            <a href="/forgot" className="text-xs text-muted-foreground hover:underline">
              Forgot password?
            </a>
          </div>
          <div className="relative">
            <Input
              id="password"
              type={showPassword ? "text" : "password"}
              placeholder="••••••••"
              {...register("password")}
              className={errors.password ? "border-destructive pr-10" : "pr-10"}
            />
            <button
              type="button"
              className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
              onClick={() => setShowPassword(!showPassword)}
            >
              {showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
            </button>
          </div>
          {errors.password && (
            <p className="text-xs text-destructive">{errors.password.message}</p>
          )}
        </div>

        <Button
          type="submit"
          className="w-full"
          style={{ background: "#fd4766" }}
          disabled={isLoading}
        >
          {isLoading ? (
            <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Signing in...</>
          ) : "Sign In →"}
        </Button>
      </form>

      <p className="text-center text-sm text-muted-foreground">
        No account?{" "}
        <a href="/register" className="font-semibold hover:underline" style={{ color: "#fd4766" }}>
          Create one free
        </a>
      </p>
    </div>
  )
}

2. React Login Card

Login form inside a shadcn/ui Card with remember me checkbox.

tsx
import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Eye, EyeOff, Loader2, AlertCircle } from "lucide-react"

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(6),
  remember: z.boolean().optional(),
})
type Form = z.infer<typeof schema>

export default function LoginCard() {
  const [showPwd, setShowPwd] = useState(false)
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState("")

  const { register, handleSubmit, formState: { errors } } = useForm<Form>({
    resolver: zodResolver(schema)
  })

  const onSubmit = async (data: Form) => {
    setLoading(true)
    setError("")
    await new Promise(r => setTimeout(r, 1200))
    // Simulate wrong password
    setError("Invalid email or password. Please try again.")
    setLoading(false)
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
      <Card className="w-full max-w-sm shadow-lg border-0">
        <CardHeader className="space-y-1 pb-4">
          <div className="text-center mb-2">
            <span className="font-bold text-xl" style={{ color: "#fd4766" }}>BootstrapPlanet</span>
          </div>
          <CardTitle className="text-xl text-center">Welcome back</CardTitle>
          <CardDescription className="text-center">Sign in to your account</CardDescription>
        </CardHeader>
        <CardContent>
          {error && (
            <div className="flex items-center gap-2 p-3 rounded-md bg-destructive/10 text-destructive text-sm mb-4">
              <AlertCircle size={14} />
              {error}
            </div>
          )}

          <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
            <div className="space-y-1">
              <Label>Email</Label>
              <Input type="email" placeholder="you@example.com" {...register("email")}
                className={errors.email ? "border-destructive" : ""} />
              {errors.email && <p className="text-xs text-destructive">Valid email required</p>}
            </div>

            <div className="space-y-1">
              <div className="flex justify-between">
                <Label>Password</Label>
                <a href="/forgot" className="text-xs text-muted-foreground hover:underline">Forgot?</a>
              </div>
              <div className="relative">
                <Input type={showPwd ? "text" : "password"} placeholder="••••••••"
                  {...register("password")} className="pr-10" />
                <button type="button" onClick={() => setShowPwd(!showPwd)}
                  className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
                  {showPwd ? <EyeOff size={15} /> : <Eye size={15} />}
                </button>
              </div>
              {errors.password && <p className="text-xs text-destructive">Min 6 characters</p>}
            </div>

            <div className="flex items-center gap-2">
              <input type="checkbox" id="remember" {...register("remember")} className="rounded border-gray-300" />
              <Label htmlFor="remember" className="text-sm font-normal cursor-pointer">Keep me signed in</Label>
            </div>

            <Button type="submit" className="w-full" style={{ background: "#fd4766" }} disabled={loading}>
              {loading ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Signing in...</> : "Sign In"}
            </Button>
          </form>

          <p className="text-center text-sm text-muted-foreground mt-4">
            No account?{" "}
            <a href="/register" className="font-semibold" style={{ color: "#fd4766" }}>Sign up free</a>
          </p>
        </CardContent>
      </Card>
    </div>
  )
}

3. React Login with Social Buttons

Login form with Google and GitHub OAuth buttons using shadcn/ui.

tsx
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Github } from "lucide-react"

function GoogleIcon() {
  return (
    <svg width="18" height="18" viewBox="0 0 24 24">
      <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
      <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
      <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
      <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
    </svg>
  )
}

export default function SocialLogin() {
  return (
    <div className="w-full max-w-sm space-y-4">
      <div className="text-center">
        <h2 className="text-2xl font-bold">Sign In</h2>
        <p className="text-sm text-muted-foreground mt-1">Choose your sign-in method</p>
      </div>

      {/* Social buttons */}
      <div className="grid gap-2">
        <Button variant="outline" className="w-full gap-2" onClick={() => console.log("Google")}>
          <GoogleIcon />
          Continue with Google
        </Button>
        <Button variant="outline" className="w-full gap-2" onClick={() => console.log("GitHub")}>
          <Github size={18} />
          Continue with GitHub
        </Button>
      </div>

      {/* Divider */}
      <div className="relative">
        <div className="absolute inset-0 flex items-center">
          <span className="w-full border-t" />
        </div>
        <div className="relative flex justify-center text-xs uppercase">
          <span className="bg-background px-2 text-muted-foreground">or continue with email</span>
        </div>
      </div>

      {/* Email form */}
      <div className="space-y-3">
        <div className="space-y-1">
          <Label>Email</Label>
          <Input type="email" placeholder="you@example.com" />
        </div>
        <div className="space-y-1">
          <Label>Password</Label>
          <Input type="password" placeholder="••••••••" />
        </div>
        <Button className="w-full" style={{ background: "#fd4766" }}>
          Sign In with Email
        </Button>
      </div>

      <p className="text-center text-sm text-muted-foreground">
        No account?{" "}
        <a href="/register" className="font-semibold" style={{ color: "#fd4766" }}>Sign up free</a>
      </p>
    </div>
  )
}

4. React Split Layout Login Page

Login page with a branding panel on the left and form on the right.

tsx
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Eye, EyeOff } from "lucide-react"
import { useState } from "react"

export default function SplitLoginPage() {
  const [showPwd, setShowPwd] = useState(false)

  return (
    <div className="min-h-screen flex">
      {/* Left panel - branding */}
      <div
        className="hidden lg:flex lg:w-1/2 flex-col items-center justify-center p-12 text-white"
        style={{ background: "linear-gradient(160deg, #0d0d0d 0%, #1a1a2e 100%)" }}
      >
        <div className="max-w-md text-center">
          <div className="text-4xl font-black mb-2" style={{ color: "#fd4766" }}>
            BootstrapPlanet
          </div>
          <p className="text-gray-400 text-lg mb-8">
            200+ free UI components for Bootstrap, React and Angular.
          </p>
          <div className="grid grid-cols-3 gap-4 text-center">
            {[
              { value: "200+", label: "Components" },
              { value: "3", label: "Frameworks" },
              { value: "100%", label: "Free" },
            ].map((s) => (
              <div key={s.label} className="bg-white/5 rounded-xl p-4">
                <div className="text-2xl font-bold text-white">{s.value}</div>
                <div className="text-xs text-gray-400 mt-1">{s.label}</div>
              </div>
            ))}
          </div>
        </div>
      </div>

      {/* Right panel - form */}
      <div className="flex-1 flex items-center justify-center p-8 bg-white">
        <div className="w-full max-w-sm">
          <div className="mb-8">
            <h2 className="text-2xl font-bold">Welcome back</h2>
            <p className="text-sm text-muted-foreground mt-1">Sign in to continue to your dashboard</p>
          </div>

          <div className="space-y-4">
            <div className="space-y-1.5">
              <Label>Email address</Label>
              <Input type="email" placeholder="you@example.com" />
            </div>

            <div className="space-y-1.5">
              <div className="flex justify-between">
                <Label>Password</Label>
                <a href="/forgot" className="text-xs text-muted-foreground hover:underline">Forgot?</a>
              </div>
              <div className="relative">
                <Input type={showPwd ? "text" : "password"} placeholder="••••••••" className="pr-10" />
                <button type="button" onClick={() => setShowPwd(!showPwd)}
                  className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
                  {showPwd ? <EyeOff size={15} /> : <Eye size={15} />}
                </button>
              </div>
            </div>

            <Button className="w-full mt-2" style={{ background: "#fd4766" }}>
              Sign In →
            </Button>
          </div>

          <p className="text-center text-sm text-muted-foreground mt-6">
            No account?{" "}
            <a href="/register" className="font-semibold" style={{ color: "#fd4766" }}>Create one free</a>
          </p>
        </div>
      </div>
    </div>
  )
}

Frequently Asked Questions

Use the shadcn Form wrapper for best integration: import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'. Wrap your form in <Form {...form}> and each field in <FormField> with a render prop. This connects to react-hook-form's register automatically.
Define a schema: const schema = z.object({ email: z.string().email(), password: z.string().min(6) }). Pass to useForm: useForm({ resolver: zodResolver(schema) }). Errors appear in formState.errors automatically on submit.
Track loading state with useState. Disable button and show spinner while loading: <Button disabled={loading}>{loading ? <><Loader2 className='animate-spin mr-2' />Loading...</> : 'Sign In'}</Button>. The Loader2 component from lucide-react works perfectly.
Catch errors in the submit handler and set error state: try { await login(data) } catch (err) { setError(err.message) }. Show the error above the form with a conditional render. Clear the error when the user starts typing again.

Need a Full React + Next.js Dashboard Template?

Get a complete React + Next.js dashboard with 50+ components — built by the same team behind BootstrapPlanet.

Browse Templates →

Use code FIRST30 for 30% off.

Related Components