EmpireUI
Get Pro
← Blog8 min read#tailwind#auth#login

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.

laptop showing secure login form on dark background

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

Can I use these Tailwind auth forms without a UI library?

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.

How do I handle auth form validation errors from the server?

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.

Do these forms work with NextAuth, Supabase Auth, or Clerk?

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.

Why does the forgot-password success message say 'if that email is registered'?

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.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Tailwind Form Validation UI: Error States, Success and LoadingInput Components in Tailwind: Text, Select, Checkbox, RadioColor Picker in React: HSL Slider, Hex Input and Copy ButtonOTP Input in React: 6-Digit Code Entry With Auto-Focus and Paste