Forms

Login Form

A simple, lightweight, animated login form. Fades and slides in on mount, has a password show/hide toggle, and shakes with an inline error on invalid submit. No form library, no validation library.

Loading preview
Advertisement
Ad space reserved for a future placement

Source code

"use client";

import { useId, useState, type FormEvent } from "react";

interface LoginFormProps {
  accentColor?: string;
  title?: string;
  submitLabel?: string;
  onSubmit?: (values: { email: string; password: string }) => void;
  className?: string;
}

export function LoginForm({
  accentColor = "#3f3cf2",
  title = "Sign in",
  submitLabel = "Sign in",
  onSubmit,
  className,
}: LoginFormProps) {
  const emailId = useId();
  const passwordId = useId();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [showPassword, setShowPassword] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [shake, setShake] = useState(false);

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (!email.trim() || !password.trim()) {
      setError("Enter both your email and password.");
      setShake(true);
      window.setTimeout(() => setShake(false), 400);
      return;
    }
    setError(null);
    onSubmit?.({ email, password });
  }

  return (
    <form
      onSubmit={handleSubmit}
      className={[
        "login-form-card",
        shake ? "login-form-card--shake" : "",
        "w-full max-w-sm rounded-xl border border-gray-200 bg-white p-6 shadow-sm",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      style={{ ["--login-accent" as string]: accentColor }}
    >
      <h2 className="mb-5 text-lg font-semibold text-gray-900">{title}</h2>

      <div className="mb-4">
        <label htmlFor={emailId} className="mb-1.5 block text-sm font-medium text-gray-700">
          Email
        </label>
        <input
          id={emailId}
          type="email"
          value={email}
          onChange={(event) => setEmail(event.target.value)}
          placeholder="you@example.com"
          className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 outline-none transition-colors focus:border-[var(--login-accent)] focus:ring-2 focus:ring-[var(--login-accent)]/20"
        />
      </div>

      <div className="mb-2">
        <label htmlFor={passwordId} className="mb-1.5 block text-sm font-medium text-gray-700">
          Password
        </label>
        <div className="relative">
          <input
            id={passwordId}
            type={showPassword ? "text" : "password"}
            value={password}
            onChange={(event) => setPassword(event.target.value)}
            placeholder="••••••••"
            className="w-full rounded-lg border border-gray-300 px-3 py-2 pr-10 text-sm text-gray-900 outline-none transition-colors focus:border-[var(--login-accent)] focus:ring-2 focus:ring-[var(--login-accent)]/20"
          />
          <button
            type="button"
            onClick={() => setShowPassword((prev) => !prev)}
            aria-label={showPassword ? "Hide password" : "Show password"}
            className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
          >
            {showPassword ? (
              <svg width={18} height={18} viewBox="0 0 24 24" fill="none" aria-hidden="true">
                <path
                  d="M3 3l18 18M10.6 10.6a2 2 0 002.8 2.8M9.4 5.5A9.9 9.9 0 0112 5c5 0 9 4 10 7-.4 1.2-1.2 2.6-2.3 3.9M6.3 6.3C4.2 7.7 2.7 9.6 2 12c1 3 5 7 10 7 1.3 0 2.5-.2 3.6-.7"
                  stroke="currentColor"
                  strokeWidth="1.6"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            ) : (
              <svg width={18} height={18} viewBox="0 0 24 24" fill="none" aria-hidden="true">
                <path
                  d="M2 12c1-3 5-7 10-7s9 4 10 7c-1 3-5 7-10 7s-9-4-10-7z"
                  stroke="currentColor"
                  strokeWidth="1.6"
                  strokeLinejoin="round"
                />
                <circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.6" />
              </svg>
            )}
          </button>
        </div>
      </div>

      {error ? <p className="mb-3 text-xs text-red-500">{error}</p> : null}

      <button
        type="submit"
        className="login-form-submit mt-3 w-full rounded-lg py-2.5 text-sm font-semibold text-white transition-transform active:scale-[0.98]"
      >
        {submitLabel}
      </button>

      <style jsx>{`
        .login-form-card {
          animation: login-form-in 380ms cubic-bezier(0.16, 1, 0.3, 1);
        }

        .login-form-card--shake {
          animation: login-form-shake 400ms ease;
        }

        .login-form-submit {
          background: var(--login-accent);
        }

        .login-form-submit:hover {
          filter: brightness(0.92);
        }

        @keyframes login-form-in {
          from {
            opacity: 0;
            transform: translateY(12px);
          }
          to {
            opacity: 1;
            transform: translateY(0);
          }
        }

        @keyframes login-form-shake {
          10%,
          90% {
            transform: translateX(-1px);
          }
          20%,
          80% {
            transform: translateX(2px);
          }
          30%,
          50%,
          70% {
            transform: translateX(-4px);
          }
          40%,
          60% {
            transform: translateX(4px);
          }
        }

        @media (prefers-reduced-motion: reduce) {
          .login-form-card,
          .login-form-card--shake {
            animation: none;
          }
        }
      `}</style>
    </form>
  );
}

Usage

Import the component and use it like this.

import { LoginForm } from "./LoginForm";

export default function Example() {
  return (
    <LoginForm
      accentColor="#3f3cf2"
      title="Sign in"
      submitLabel="Sign in"
      onSubmit={({ email, password }) => {
        console.log(email, password);
      }}
    />
  );
}

Dependencies

No external dependencies.

Usage notes

Pass accentColor, title, submitLabel, and an onSubmit({ email, password }) callback. Only client-side presence validation is built in; wire real auth via onSubmit.