Forms

Floating Label Login Form

A dark mode login form with smooth floating labels, client-side validation, and a password visibility toggle.

ReactavailableIntermediate
formloginfloating labelanimatedglass
Author: Prism Bits @saiusesgithub
Live React preview
Loading preview…
"use client";

import { useState, type FormEvent, type ChangeEvent, useId } from "react";
import { Eye, EyeOff, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";

export type FloatingLabelLoginFormProps = {
  onSubmit?: (data: { email: string; password: string }) => Promise<void> | void;
  loading?: boolean;
  emailLabel?: string;
  passwordLabel?: string;
  buttonText?: string;
  className?: string;
  showValidation?: boolean;
};

export default function FloatingLabelLoginForm({
  onSubmit,
  loading = false,
  emailLabel = "Email address",
  passwordLabel = "Password",
  buttonText = "Sign in",
  className,
  showValidation = true,
}: FloatingLabelLoginFormProps) {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [showPassword, setShowPassword] = useState(false);
  const [focusedField, setFocusedField] = useState<"email" | "password" | null>(null);
  
  const [errors, setErrors] = useState<{ email?: string; password?: string }>({});

  const emailId = useId();
  const passwordId = useId();

  const validate = () => {
    if (!showValidation) return true;
    
    const newErrors: { email?: string; password?: string } = {};
    if (!email) {
      newErrors.email = "Email is required";
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      newErrors.email = "Please enter a valid email";
    }

    if (!password) {
      newErrors.password = "Password is required";
    } else if (password.length < 6) {
      newErrors.password = "Password must be at least 6 characters";
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    if (validate() && onSubmit) {
      await onSubmit({ email, password });
    }
  };

  return (
    <form
      onSubmit={handleSubmit}
      className={cn("w-full max-w-sm flex flex-col gap-5", className)}
      noValidate
    >
      <div className="relative">
        <input
          id={emailId}
          type="email"
          placeholder=" "
          value={email}
          onChange={(e: ChangeEvent<HTMLInputElement>) => {
            setEmail(e.target.value);
            if (errors.email) setErrors((prev) => ({ ...prev, email: undefined }));
          }}
          onFocus={() => setFocusedField("email")}
          onBlur={() => setFocusedField(null)}
          className={cn(
            "peer w-full rounded-2xl border border-white/15 bg-white/5 px-4 pb-2 pt-6 text-sm text-white backdrop-blur transition duration-200",
            "hover:bg-white/10 hover:border-white/30",
            "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:bg-white/10",
            "disabled:opacity-50 disabled:cursor-not-allowed",
            errors.email && "border-red-400/50 focus-visible:ring-red-400/70 hover:border-red-400/50"
          )}
          disabled={loading}
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? `${emailId}-error` : undefined}
        />
        <label
          htmlFor={emailId}
          className={cn(
            "absolute left-4 top-4 z-10 origin-[0] -translate-y-2.5 scale-[0.85] transform text-white/50 transition-all duration-200 pointer-events-none",
            "peer-placeholder-shown:translate-y-0 peer-placeholder-shown:scale-100",
            "peer-focus:-translate-y-2.5 peer-focus:scale-[0.85] peer-focus:text-white/70",
            (email || focusedField === "email") && "-translate-y-2.5 scale-[0.85]"
          )}
        >
          {emailLabel}
        </label>
        {errors.email && (
          <p id={`${emailId}-error`} className="mt-1.5 px-2 text-xs text-red-400/90" role="alert">
            {errors.email}
          </p>
        )}
      </div>

      <div className="relative">
        <input
          id={passwordId}
          type={showPassword ? "text" : "password"}
          placeholder=" "
          value={password}
          onChange={(e: ChangeEvent<HTMLInputElement>) => {
            setPassword(e.target.value);
            if (errors.password) setErrors((prev) => ({ ...prev, password: undefined }));
          }}
          onFocus={() => setFocusedField("password")}
          onBlur={() => setFocusedField(null)}
          className={cn(
            "peer w-full rounded-2xl border border-white/15 bg-white/5 pl-4 pr-11 pb-2 pt-6 text-sm text-white backdrop-blur transition duration-200",
            "hover:bg-white/10 hover:border-white/30",
            "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:bg-white/10",
            "disabled:opacity-50 disabled:cursor-not-allowed",
            errors.password && "border-red-400/50 focus-visible:ring-red-400/70 hover:border-red-400/50"
          )}
          disabled={loading}
          aria-invalid={!!errors.password}
          aria-describedby={errors.password ? `${passwordId}-error` : undefined}
        />
        <label
          htmlFor={passwordId}
          className={cn(
            "absolute left-4 top-4 z-10 origin-[0] -translate-y-2.5 scale-[0.85] transform text-white/50 transition-all duration-200 pointer-events-none",
            "peer-placeholder-shown:translate-y-0 peer-placeholder-shown:scale-100",
            "peer-focus:-translate-y-2.5 peer-focus:scale-[0.85] peer-focus:text-white/70",
            (password || focusedField === "password") && "-translate-y-2.5 scale-[0.85]"
          )}
        >
          {passwordLabel}
        </label>
        
        <button
          type="button"
          onClick={() => setShowPassword(!showPassword)}
          className="absolute right-4 top-[14px] z-20 text-white/40 transition-colors hover:text-white/80 focus-visible:outline-none focus-visible:text-white/80 rounded"
          aria-label={showPassword ? "Hide password" : "Show password"}
          disabled={loading}
        >
          {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
        </button>

        {errors.password && (
          <p id={`${passwordId}-error`} className="mt-1.5 px-2 text-xs text-red-400/90" role="alert">
            {errors.password}
          </p>
        )}
      </div>

      <button
        type="submit"
        disabled={loading}
        className={cn(
          "group relative mt-2 inline-flex h-12 items-center justify-center gap-2 overflow-hidden rounded-2xl bg-white px-6 text-sm font-bold text-zinc-950 shadow-[0_14px_36px_rgb(0_0_0/0.35)] transition duration-200",
          "hover:-translate-y-0.5 hover:shadow-[0_18px_44px_rgb(0_0_0/0.45)]",
          "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent",
          "active:translate-y-0",
          "disabled:opacity-70 disabled:pointer-events-none"
        )}
      >
        {loading ? <Loader2 className="animate-spin" size={18} /> : null}
        {buttonText}
      </button>
    </form>
  );
}