Login Form
Original · freeclean login form with social buttons
byWeb InnoventixReact + Tailwind
formloginforms
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.jsonform-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…
</>
) : 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’re in. Taking you to your workspace…
</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 quoteSimilar components
Browse all →
Signup Form
Originalsignup form with password strength

Contact Form
Originalcontact form with fields and message

Multistep Form
Originalmulti-step wizard form with progress

Payment Form
Originalcard payment form with formatting

Newsletter Form
Originalinline newsletter subscribe form

Search Filters Form
Originalfaceted search filter panel

Profile Form
Originalprofile settings form

OTP Verify Form
OriginalOTP verification form

Upload Dropzone Form
Originaldrag-and-drop file upload with preview

Feedback Form
Originalfeedback form with emoji rating

Survey Form
Originalshort survey with radios and scale

Booking Form
Originalbooking form with date and slots

