Web InnoventixFreeCode

Login Form

Original · free

clean login form with social buttons

byWeb InnoventixReact + Tailwind
formloginforms
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/form-login.json
form-login.tsx
"use client";

import { useId, useRef, useState, type FormEvent } from "react";
import { motion, useReducedMotion } from "motion/react";

type FieldErrors = { email?: string; password?: string };
type Status = "idle" | "submitting" | "success";

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

function GoogleMark() {
  return (
    <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
      <path
        fill="#4285F4"
        d="M23.06 12.25c0-.86-.07-1.49-.22-2.14H12v3.87h6.33c-.13 1.02-.82 2.56-2.36 3.59l-.02.15 3.43 2.65.24.02c2.18-2.02 3.44-4.98 3.44-8.14z"
      />
      <path
        fill="#34A853"
        d="M12 24c3.11 0 5.72-1.03 7.62-2.79l-3.63-2.82c-.97.68-2.28 1.16-3.99 1.16-3.05 0-5.64-2.01-6.56-4.79l-.14.01-3.57 2.76-.05.13C3.6 21.31 7.5 24 12 24z"
      />
      <path
        fill="#FBBC05"
        d="M5.44 14.76c-.24-.72-.38-1.49-.38-2.29s.14-1.57.37-2.29l-.01-.15-3.61-2.8-.12.06A11.98 11.98 0 0 0 .5 12.47c0 1.93.46 3.76 1.29 5.38l3.65-3.09z"
      />
      <path
        fill="#EA4335"
        d="M12 4.75c2.16 0 3.62.93 4.45 1.71l3.25-3.17C17.71 1.19 15.11 0 12 0 7.5 0 3.6 2.69 1.79 6.55l3.64 3.09C6.36 6.86 8.95 4.75 12 4.75z"
      />
    </svg>
  );
}

function GithubMark() {
  return (
    <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" className="text-slate-900 dark:text-slate-100">
      <path
        fill="currentColor"
        d="M12 .5C5.37.5 0 5.87 0 12.5c0 5.3 3.44 9.8 8.21 11.39.6.11.82-.26.82-.58 0-.28-.01-1.04-.02-2.05-3.34.72-4.04-1.61-4.04-1.61-.55-1.39-1.33-1.76-1.33-1.76-1.09-.74.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.83 2.81 1.3 3.5.99.11-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.13-.3-.54-1.53.11-3.18 0 0 1.01-.32 3.3 1.23.96-.27 1.98-.4 3-.41 1.02.01 2.04.14 3 .41 2.29-1.55 3.3-1.23 3.3-1.23.65 1.65.24 2.88.12 3.18.77.84 1.23 1.91 1.23 3.22 0 4.61-2.81 5.62-5.48 5.92.43.37.82 1.1.82 2.22 0 1.61-.02 2.9-.02 3.29 0 .32.22.7.83.58C20.56 22.29 24 17.79 24 12.5 24 5.87 18.63.5 12 .5z"
      />
    </svg>
  );
}

function AppleMark() {
  return (
    <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" className="text-slate-900 dark:text-slate-100">
      <path
        fill="currentColor"
        d="M16.37 1.43c0 1.14-.42 2.05-1.25 2.9-.99 1.02-2.18 1.61-3.47 1.5-.02-.13-.03-.27-.03-.41 0-1.09.47-2.05 1.24-2.85.39-.4.9-.74 1.53-1.01.62-.28 1.21-.44 1.77-.49.01.09.01.24.01.36zm4.14 15.4c-.44 1.02-.65 1.47-1.22 2.37-.79 1.26-1.9 2.83-3.28 2.84-1.22.02-1.54-.79-3.2-.78-1.66 0-2.01.79-3.23.79-1.38.01-2.43-1.4-3.22-2.66-2.21-3.53-2.44-7.67-1.08-9.87.97-1.56 2.5-2.47 3.94-2.47 1.47 0 2.39.81 3.6.81 1.18 0 1.9-.81 3.6-.81 1.29 0 2.65.7 3.63 1.91-3.19 1.75-2.67 6.3.66 7.87z"
      />
    </svg>
  );
}

export default function FormLogin() {
  const reduceMotion = useReducedMotion();
  const uid = useId();
  const emailId = `${uid}-email`;
  const passwordId = `${uid}-password`;
  const emailErrId = `${uid}-email-err`;
  const passwordErrId = `${uid}-password-err`;
  const statusId = `${uid}-status`;

  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [showPassword, setShowPassword] = useState(false);
  const [remember, setRemember] = useState(true);
  const [errors, setErrors] = useState<FieldErrors>({});
  const [status, setStatus] = useState<Status>("idle");
  const [shake, setShake] = useState(false);
  const [social, setSocial] = useState<string | null>(null);

  const emailRef = useRef<HTMLInputElement>(null);
  const passwordRef = useRef<HTMLInputElement>(null);

  const busy = status === "submitting" || status === "success" || social !== null;

  function validate(): FieldErrors {
    const next: FieldErrors = {};
    const trimmed = email.trim();
    if (!trimmed) next.email = "Enter your email address.";
    else if (!EMAIL_RE.test(trimmed)) next.email = "That doesn't look like a valid email.";
    if (!password) next.password = "Enter your password.";
    else if (password.length < 8) next.password = "Password must be at least 8 characters.";
    return next;
  }

  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    if (busy) return;
    const next = validate();
    setErrors(next);
    if (next.email || next.password) {
      setShake(true);
      if (next.email) emailRef.current?.focus();
      else passwordRef.current?.focus();
      return;
    }
    setStatus("submitting");
    window.setTimeout(() => setStatus("success"), 1400);
  }

  function handleSocial(provider: string) {
    if (busy) return;
    setSocial(provider);
    window.setTimeout(() => setSocial(null), 1600);
  }

  const inputBase =
    "w-full rounded-lg border bg-white px-3.5 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-950/40 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus-visible:ring-offset-slate-900";

  const socialBase =
    "relative inline-flex w-full items-center justify-center gap-2.5 rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:opacity-60 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900";

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-slate-100 px-4 py-16 sm:py-24 dark:from-slate-950 dark:to-slate-900">
      <style>{`
        @keyframes fl-spin { to { transform: rotate(360deg); } }
        @keyframes fl-shake {
          10%, 90% { transform: translateX(-1px); }
          20%, 80% { transform: translateX(2px); }
          30%, 50%, 70% { transform: translateX(-5px); }
          40%, 60% { transform: translateX(5px); }
        }
        @keyframes fl-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
        @keyframes fl-check { from { stroke-dashoffset: 26; } to { stroke-dashoffset: 0; } }
        .fl-spin { animation: fl-spin 0.7s linear infinite; }
        .fl-shake { animation: fl-shake 0.5s cubic-bezier(.36,.07,.19,.97) both; }
        .fl-rise { animation: fl-rise 0.3s ease-out both; }
        .fl-check { stroke-dasharray: 26; animation: fl-check 0.5s ease-out 0.05s both; }
        @media (prefers-reduced-motion: reduce) {
          .fl-spin, .fl-shake, .fl-rise, .fl-check { animation: none !important; }
          .fl-check { stroke-dashoffset: 0; }
        }
      `}</style>

      <div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
        <div className="absolute -left-24 -top-24 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-600/20" />
        <div className="absolute -bottom-32 -right-16 h-80 w-80 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-700/20" />
      </div>

      <motion.div
        initial={reduceMotion ? false : { opacity: 0, y: 16 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
        className="relative mx-auto w-full max-w-md"
      >
        <div
          className={`rounded-2xl border border-slate-200 bg-white/85 p-7 shadow-xl shadow-slate-900/5 backdrop-blur-sm sm:p-8 dark:border-slate-800 dark:bg-slate-900/75 dark:shadow-black/20 ${shake ? "fl-shake" : ""}`}
          onAnimationEnd={() => setShake(false)}
        >
          <div className="mb-6 flex items-center gap-2.5">
            <span className="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-sm">
              <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none">
                <path
                  d="M4 15c2.5-4 5.5-6 8-6s3.5 2 6 2c1.5 0 2.5-.5 2.5-.5M4 15c0 3.5 3 5 6 5 4.5 0 8-3.5 8-8"
                  stroke="currentColor"
                  strokeWidth="2"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </span>
            <span className="text-base font-semibold tracking-tight text-slate-900 dark:text-white">Cascade</span>
          </div>

          <h1 className="text-2xl font-semibold tracking-tight text-slate-900 dark:text-white">Welcome back</h1>
          <p className="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
            Sign in to pick up where your team left off.
          </p>

          <div className="mt-6 space-y-3">
            <button
              type="button"
              onClick={() => handleSocial("Google")}
              disabled={busy}
              aria-label="Continue with Google"
              className={socialBase}
            >
              {social === "Google" ? (
                <span className="fl-spin h-4 w-4 rounded-full border-2 border-slate-300 border-t-indigo-500" />
              ) : (
                <GoogleMark />
              )}
              <span>Continue with Google</span>
            </button>

            <div className="grid grid-cols-2 gap-3">
              <button
                type="button"
                onClick={() => handleSocial("GitHub")}
                disabled={busy}
                aria-label="Continue with GitHub"
                className={socialBase}
              >
                {social === "GitHub" ? (
                  <span className="fl-spin h-4 w-4 rounded-full border-2 border-slate-300 border-t-indigo-500" />
                ) : (
                  <GithubMark />
                )}
                <span>GitHub</span>
              </button>
              <button
                type="button"
                onClick={() => handleSocial("Apple")}
                disabled={busy}
                aria-label="Continue with Apple"
                className={socialBase}
              >
                {social === "Apple" ? (
                  <span className="fl-spin h-4 w-4 rounded-full border-2 border-slate-300 border-t-indigo-500" />
                ) : (
                  <AppleMark />
                )}
                <span>Apple</span>
              </button>
            </div>
          </div>

          <div className="my-6 flex items-center gap-3" aria-hidden="true">
            <span className="h-px flex-1 bg-slate-200 dark:bg-slate-800" />
            <span className="text-xs font-medium uppercase tracking-wider text-slate-400 dark:text-slate-500">
              or sign in with email
            </span>
            <span className="h-px flex-1 bg-slate-200 dark:bg-slate-800" />
          </div>

          <form onSubmit={handleSubmit} noValidate className="space-y-4">
            <div>
              <label htmlFor={emailId} className="mb-1.5 block text-sm font-medium text-slate-700 dark:text-slate-300">
                Email address
              </label>
              <input
                ref={emailRef}
                id={emailId}
                name="email"
                type="email"
                inputMode="email"
                autoComplete="email"
                placeholder="you@company.com"
                value={email}
                disabled={busy}
                onChange={(e) => {
                  setEmail(e.target.value);
                  if (errors.email) setErrors((prev) => ({ ...prev, email: undefined }));
                }}
                aria-invalid={errors.email ? true : undefined}
                aria-describedby={errors.email ? emailErrId : undefined}
                className={`${inputBase} ${
                  errors.email
                    ? "border-rose-400 focus-visible:ring-rose-500 dark:border-rose-500/70"
                    : "border-slate-300 dark:border-slate-700"
                }`}
              />
              {errors.email ? (
                <p id={emailErrId} className="fl-rise mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400">
                  {errors.email}
                </p>
              ) : null}
            </div>

            <div>
              <div className="mb-1.5 flex items-baseline justify-between">
                <label htmlFor={passwordId} className="block text-sm font-medium text-slate-700 dark:text-slate-300">
                  Password
                </label>
                <a
                  href="#reset"
                  className="rounded text-xs font-medium text-indigo-600 hover:text-indigo-500 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-indigo-400 dark:focus-visible:ring-offset-slate-900"
                >
                  Forgot password?
                </a>
              </div>
              <div className="relative">
                <input
                  ref={passwordRef}
                  id={passwordId}
                  name="password"
                  type={showPassword ? "text" : "password"}
                  autoComplete="current-password"
                  placeholder="Enter your password"
                  value={password}
                  disabled={busy}
                  onChange={(e) => {
                    setPassword(e.target.value);
                    if (errors.password) setErrors((prev) => ({ ...prev, password: undefined }));
                  }}
                  aria-invalid={errors.password ? true : undefined}
                  aria-describedby={errors.password ? passwordErrId : undefined}
                  className={`${inputBase} pr-11 ${
                    errors.password
                      ? "border-rose-400 focus-visible:ring-rose-500 dark:border-rose-500/70"
                      : "border-slate-300 dark:border-slate-700"
                  }`}
                />
                <button
                  type="button"
                  onClick={() => setShowPassword((v) => !v)}
                  aria-pressed={showPassword}
                  aria-label={showPassword ? "Hide password" : "Show password"}
                  className="absolute inset-y-0 right-0 flex items-center rounded-r-lg px-3 text-slate-400 transition-colors hover:text-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-900"
                >
                  {showPassword ? (
                    <svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
                      <path
                        d="M3 3l18 18M10.6 10.7a2 2 0 002.8 2.8M9.9 4.6A9.8 9.8 0 0112 4.5c5 0 9 4.5 9 7.5a12 12 0 01-2.2 3.3M6.1 6.2C3.9 7.7 2.5 10 2.5 12c0 3 4 7.5 9 7.5 1.3 0 2.5-.3 3.6-.8"
                        stroke="currentColor"
                        strokeWidth="1.8"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      />
                    </svg>
                  ) : (
                    <svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
                      <path
                        d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"
                        stroke="currentColor"
                        strokeWidth="1.8"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      />
                      <circle cx="12" cy="12" r="2.6" stroke="currentColor" strokeWidth="1.8" />
                    </svg>
                  )}
                </button>
              </div>
              {errors.password ? (
                <p id={passwordErrId} className="fl-rise mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400">
                  {errors.password}
                </p>
              ) : null}
            </div>

            <label className="flex cursor-pointer items-center gap-2.5 text-sm text-slate-600 select-none dark:text-slate-400">
              <input
                type="checkbox"
                name="remember"
                checked={remember}
                disabled={busy}
                onChange={(e) => setRemember(e.target.checked)}
                className="h-4 w-4 rounded border-slate-300 accent-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-600 dark:focus-visible:ring-offset-slate-900"
              />
              Keep me signed in for 30 days
            </label>

            <button
              type="submit"
              disabled={busy}
              className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-90 dark:focus-visible:ring-offset-slate-900"
            >
              {status === "submitting" ? (
                <>
                  <span className="fl-spin h-4 w-4 rounded-full border-2 border-white/40 border-t-white" />
                  Signing in&hellip;
                </>
              ) : status === "success" ? (
                <>
                  <svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
                    <path
                      className="fl-check"
                      d="M4 12.5l5 5 11-11"
                      stroke="currentColor"
                      strokeWidth="2.4"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    />
                  </svg>
                  Signed in
                </>
              ) : (
                "Sign in"
              )}
            </button>
          </form>

          <div id={statusId} role="status" aria-live="polite" className="sr-only">
            {status === "success" ? "Signed in successfully. Redirecting to your workspace." : ""}
          </div>

          {status === "success" ? (
            <p className="fl-rise mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3.5 py-2.5 text-sm font-medium text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300">
              You&rsquo;re in. Taking you to your workspace&hellip;
            </p>
          ) : null}

          <p className="mt-6 text-center text-sm text-slate-500 dark:text-slate-400">
            New to Cascade?{" "}
            <a
              href="#signup"
              className="rounded font-semibold text-indigo-600 hover:text-indigo-500 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-indigo-400 dark:focus-visible:ring-offset-slate-900"
            >
              Create an account
            </a>
          </p>
        </div>

        <p className="mt-5 text-center text-xs text-slate-400 dark:text-slate-500">
          Protected by reCAPTCHA and subject to the Cascade{" "}
          <a
            href="#privacy"
            className="rounded underline decoration-slate-300 underline-offset-2 hover:text-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:decoration-slate-600 dark:hover:text-slate-300 dark:focus-visible:ring-offset-slate-900"
          >
            Privacy Policy
          </a>
          .
        </p>
      </motion.div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →