Auth Pages in Tailwind: Login, Register, Forgot Password
Build polished login, register, and forgot-password pages with Tailwind CSS — real code, real patterns, and zero UI library bloat required.
Why Auth Pages Are Harder Than They Look
You'd think a login form would be the simplest UI you ever write. Two inputs, a button, done. But auth pages are actually where most design systems show their cracks — bad focus rings, inaccessible error states, zero mobile thought, and that one checkbox that looks like it belongs in a Windows XP dialog. Getting them right takes deliberate effort.
Tailwind CSS, introduced back in 2017 and now on v4 as of 2025, is genuinely the best tool for this job. The utility-first model means you compose the exact visual you want without fighting a component library that's already made style decisions on your behalf. You control every pixel of padding, every border radius, every ring width on focus.
That said, 'Tailwind auth page' tutorials tend to stop at the visual layer. They show you a centered card and call it a day. This guide goes further — validation feedback, loading states, accessible labels, and the full three-page flow: login, register, and forgot password. All copy-pasteable, all TypeScript-ready.
Honestly, the forgot-password page gets ignored 90% of the time, and it's the one users hit when they're already frustrated. A bad forgot-password flow will cost you conversions. We're not skipping it.
Setting Up the Shared Auth Layout
All three pages share the same outer shell — a full-height centered container with a card floating in the middle. Build it once, reuse it everywhere. Here's the layout wrapper that works with Next.js App Router or any React framework:
// components/auth/AuthLayout.tsx
import { ReactNode } from 'react';
export function AuthLayout({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-xl shadow-black/20 p-8">
{children}
</div>
</div>
</div>
);
}The max-w-md (448px) sweet spot has been the de-facto standard for auth cards since Material Design 1.0 popularized it. Go wider and the form feels lost. Go narrower and it's cramped on desktop. The px-4 on the outer wrapper handles the mobile case — on small screens the card becomes full-bleed with just 16px breathing room on each side.
Worth noting: if you want a glassmorphism variant instead of a solid card, swap bg-white for bg-white/10 backdrop-blur-md border border-white/20. The glassmorphism generator can preview the exact values before you commit to them. Looks particularly sharp with an aurora or gradient background behind it.
One more thing — shadow-xl shadow-black/20 uses Tailwind's shadow color syntax introduced in v3.0. If you're on an older version, you'll get the default shadow color (usually a gray) instead. Worth checking which version your project is on before you wonder why the shadow looks flat.
The Login Page
The login page is where most users land first, and first impressions are real. You need: email input, password input, a 'remember me' checkbox, a forgot-password link, the primary submit button, and optionally an OAuth divider. Here's a complete, accessible implementation:
// components/auth/LoginForm.tsx
'use client';
import { useState } from 'react';
export function LoginForm() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError('');
// your auth logic here
setLoading(false);
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"
>
Email address
</label>
<input
id="email"
type="email"
autoComplete="email"
required
className="w-full px-3.5 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600
bg-white dark:bg-slate-700 text-slate-900 dark:text-white
placeholder:text-slate-400
focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent
transition-shadow"
placeholder="you@example.com"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label
htmlFor="password"
className="block text-sm font-medium text-slate-700 dark:text-slate-300"
>
Password
</label>
<a
href="/auth/forgot-password"
className="text-xs text-violet-600 hover:text-violet-700 dark:text-violet-400"
>
Forgot password?
</a>
</div>
<input
id="password"
type="password"
autoComplete="current-password"
required
className="w-full px-3.5 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600
bg-white dark:bg-slate-700 text-slate-900 dark:text-white
focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent
transition-shadow"
/>
</div>
{error && (
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2.5 px-4 rounded-lg bg-violet-600 hover:bg-violet-700
disabled:opacity-60 disabled:cursor-not-allowed
text-white font-semibold text-sm
focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2
transition-colors"
>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
);
}A few things worth calling out explicitly. The autoComplete attributes (email, current-password) are not optional — they're what lets password managers fill the form correctly. Skip them and you'll get support tickets from confused users wondering why 1Password isn't working. The role="alert" on the error paragraph makes screen readers announce it automatically when it appears.
The focus ring is focus:ring-2 focus:ring-violet-500 focus:border-transparent. That 2px ring at 500 lightness satisfies WCAG 2.2's focus-visible requirement without looking like the default browser outline. In practice, the default browser focus ring on Chromium in 2025 is actually decent, but Tailwind's outline-none removes it, so you have to put something back.
Quick aside: the disabled:opacity-60 approach for loading state is fine for most apps. If you want something fancier, swap in a spinner — but honestly the text change from 'Sign in' to 'Signing in…' is enough user feedback for a sub-2-second request.
The Register Page
Register forms have more fields and therefore more room to go wrong. The classic mistake is dumping all fields in a single column with no visual grouping and shipping it. Users abandon registration forms at a shocking rate — even dropping one field can lift conversion by 20%+. Think hard about what you actually need upfront.
Here's a lean but complete register form — name, email, password, confirm password, and a terms checkbox:
// components/auth/RegisterForm.tsx
'use client';
import { useState } from 'react';
const inputClass = `
w-full px-3.5 py-2.5 rounded-lg
border border-slate-300 dark:border-slate-600
bg-white dark:bg-slate-700
text-slate-900 dark:text-white
placeholder:text-slate-400
focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent
transition-shadow
`;
export function RegisterForm() {
const [passwordError, setPasswordError] = useState('');
function handleConfirmPassword(e: React.ChangeEvent<HTMLInputElement>) {
const form = e.target.form!;
const pw = (form.elements.namedItem('password') as HTMLInputElement).value;
setPasswordError(
e.target.value && pw !== e.target.value ? 'Passwords don't match' : ''
);
}
return (
<form className="space-y-5">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
First name
</label>
<input id="firstName" type="text" autoComplete="given-name" required className={inputClass} />
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Last name
</label>
<input id="lastName" type="text" autoComplete="family-name" required className={inputClass} />
</div>
</div>
<div>
<label htmlFor="regEmail" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Email address
</label>
<input id="regEmail" type="email" autoComplete="email" required className={inputClass} />
</div>
<div>
<label htmlFor="regPassword" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Password
</label>
<input id="regPassword" name="password" type="password" autoComplete="new-password" required minLength={8} className={inputClass} />
<p className="mt-1 text-xs text-slate-500">Minimum 8 characters</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Confirm password
</label>
<input
id="confirmPassword"
type="password"
autoComplete="new-password"
required
onChange={handleConfirmPassword}
className={`${inputClass} ${passwordError ? 'border-red-500 focus:ring-red-500' : ''}`}
/>
{passwordError && (
<p role="alert" className="mt-1 text-xs text-red-600 dark:text-red-400">{passwordError}</p>
)}
</div>
<div className="flex items-start gap-3">
<input
id="terms"
type="checkbox"
required
className="mt-0.5 h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500"
/>
<label htmlFor="terms" className="text-sm text-slate-600 dark:text-slate-400">
I agree to the <a href="/terms" className="text-violet-600 hover:underline">Terms of Service</a> and <a href="/privacy" className="text-violet-600 hover:underline">Privacy Policy</a>
</label>
</div>
<button
type="submit"
className="w-full py-2.5 px-4 rounded-lg bg-violet-600 hover:bg-violet-700
text-white font-semibold text-sm
focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2
transition-colors"
>
Create account
</button>
</form>
);
}The inline password-match validation on the confirm field fires on onChange rather than onBlur. Look, you could debate this endlessly, but onChange wins for UX — users see the error disappear in real time as they type the matching characters, which feels satisfying. onBlur only makes sense for fields where intermediate states are always wrong (like email).
The two-column name grid with grid-cols-2 gap-4 is a common pattern that saves 40px of vertical space and feels more professional than two stacked full-width inputs. That said, on screens narrower than 360px it can get tight — you might want sm:grid-cols-2 grid-cols-1 if you're targeting very small handsets.
For styling the native checkbox, Tailwind's form plugin (@tailwindcss/forms) makes life easier. Without it, you'll get the OS default checkbox which ignores your color classes. With it, the text-violet-600 class actually colors the checkmark. If you're not using the plugin, you'll need a custom SVG checkbox component instead.
The Forgot Password Page
This page gets about 10 seconds of thought from most devs, and it shows. The pattern is simple — one email input, one submit button, a success state — but the details matter a lot. Do you tell users the email doesn't exist in your system? (Security risk.) Do you disable the button after submission? (You should.) Does the success message explain what to do next? (Usually not, but it should.)
// components/auth/ForgotPasswordForm.tsx
'use client';
import { useState } from 'react';
export function ForgotPasswordForm() {
const [submitted, setSubmitted] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
// your reset logic here — always resolve positively for security
await new Promise(r => setTimeout(r, 800)); // simulate request
setLoading(false);
setSubmitted(true);
}
if (submitted) {
return (
<div className="text-center py-4">
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">
Check your inbox
</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">
If that email is registered, you'll get a reset link within a few minutes. Check your spam folder if nothing arrives.
</p>
<a
href="/auth/login"
className="inline-block mt-6 text-sm text-violet-600 hover:text-violet-700 font-medium"
>
Back to sign in
</a>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<p className="text-sm text-slate-600 dark:text-slate-400">
Enter your email and we'll send you a link to reset your password.
</p>
<div>
<label
htmlFor="resetEmail"
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"
>
Email address
</label>
<input
id="resetEmail"
type="email"
autoComplete="email"
required
className="w-full px-3.5 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600
bg-white dark:bg-slate-700 text-slate-900 dark:text-white
placeholder:text-slate-400
focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent
transition-shadow"
placeholder="you@example.com"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 px-4 rounded-lg bg-violet-600 hover:bg-violet-700
disabled:opacity-60 disabled:cursor-not-allowed
text-white font-semibold text-sm
focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2
transition-colors"
>
{loading ? 'Sending…' : 'Send reset link'}
</button>
<p className="text-center">
<a href="/auth/login" className="text-sm text-slate-500 hover:text-violet-600">
Back to sign in
</a>
</p>
</form>
);
}Notice that the success message says 'If that email is registered' — not 'We've sent you an email.' That phrasing is intentional. You never want to confirm or deny whether a specific email exists in your database, because that information can be used to enumerate real accounts. It's a subtle but important security detail.
The success icon uses a simple inline SVG instead of pulling in an icon library. Is this dogmatic? Maybe. But for a three-page auth flow, importing Lucide or Heroicons just for a checkmark is overkill. If you already have an icon system set up, swap it in.
In practice, you'll also want to handle rate limiting on the backend and show an appropriate message if someone submits 10 reset requests in a minute. Tailwind handles none of that — it's purely visual — but the role="alert" pattern from the login form works equally well here for surfacing backend errors.
Adding OAuth Buttons and a Divider
Most apps in 2026 offer 'Continue with Google' alongside the email flow. The divider between OAuth and email sections is a small thing that trips up a lot of devs. Here's the cleanest way to do it:
// Divider between OAuth and email form
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-200 dark:border-slate-700" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white dark:bg-slate-800 px-3 text-slate-400 tracking-wider">
or continue with email
</span>
</div>
</div>
// OAuth button
<button
type="button"
className="w-full flex items-center justify-center gap-3 py-2.5 px-4
rounded-lg border border-slate-300 dark:border-slate-600
bg-white dark:bg-slate-700 hover:bg-slate-50 dark:hover:bg-slate-600
text-slate-700 dark:text-slate-200 text-sm font-medium
focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2
transition-colors"
>
{/* Google SVG icon */}
<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>
Continue with Google
</button>The divider trick uses relative positioning to overlap a centered <span> over a full-width border. The span's background matches the card background, effectively punching a hole through the line for the text. Zero JavaScript, zero extra libraries, eight utility classes.
For the OAuth button border style, border border-slate-300 on a white background mimics what Google's own sign-in button spec recommends — a light border rather than a filled background. It reads as secondary to the primary action and doesn't fight the main CTA button for attention. Want to see how this kind of visual hierarchy plays across different design systems? Compare it to the neobrutalism style where borders are heavy and deliberate — a completely different energy.
One more thing — if you're building this for a Next.js app with NextAuth.js or Supabase Auth, the signIn('google') and signUp() calls slot directly into these onClick handlers. The Tailwind markup is completely backend-agnostic.
Polish, Dark Mode, and the Final Layout
Putting it all together, each page follows the same pattern: <AuthLayout> wraps a heading block, then the appropriate form component. Dark mode works via Tailwind's dark: variant — as long as your project has darkMode: 'class' in tailwind.config.ts and toggles the dark class on <html>, everything adapts automatically. Check the tailwind dark mode guide if you haven't set that up yet.
// app/auth/login/page.tsx
import { AuthLayout } from '@/components/auth/AuthLayout';
import { LoginForm } from '@/components/auth/LoginForm';
export default function LoginPage() {
return (
<AuthLayout>
<div className="mb-8">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Welcome back</h1>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
Don't have an account?{' '}
<a href="/auth/register" className="text-violet-600 hover:text-violet-700 font-medium">
Sign up free
</a>
</p>
</div>
<LoginForm />
</AuthLayout>
);
}The heading copy matters. 'Welcome back' on login, 'Create your account' on register, 'Reset your password' on forgot-password. These are small things that make the UX feel intentional rather than scaffolded. Generic headings like 'Login' or 'Sign In' communicate nothing about the brand.
For the background, the from-slate-900 via-slate-800 to-slate-900 gradient in AuthLayout is deliberately neutral so it works for most apps. If your brand has a stronger color story — say you're building something with the vaporwave or aurora aesthetic — swap it out. Empire UI's gradient generator lets you preview and copy exact Tailwind gradient classes in under 30 seconds. The box shadow generator is similarly handy for dialing in the card shadow without guessing at blur/spread values.
Is this everything you need? Nearly. You'll still want a reset-password page (where the user lands after clicking the email link and enters a new password), and potentially an email-verification page. But with the AuthLayout, shared input classes, and these three forms in hand, those take minutes to scaffold — not hours.
FAQ
Yes, that's the whole point. Every class here is a standard Tailwind utility — no plugins required except @tailwindcss/forms if you want styled native checkboxes. Zero external component dependencies.
Set an error string in component state after the async call rejects, then render it with role="alert" so screen readers announce it. The login form example above shows exactly this pattern.
Completely agnostic. The forms handle UI state and markup only — wire your onSubmit handler to whichever auth provider you're using. All three have straightforward signIn/signUp APIs that slot right in.
Never confirm whether an email exists in your system — that lets attackers enumerate real accounts. The vague phrasing is deliberate security practice, not sloppy copy.