Summit Themes
Blog

How to build a split-screen sign-in with an overlay card using Tailwind CSS and Alpine.js

Authentication pages are often an afterthought — a plain white card dropped in the center of the screen. But the sign-in page is sometimes the first thing a returning user sees, and it shapes how polished the product feels. A split-screen layout with an overlay card is a clean, modern pattern that gives you room for branding on one side and a focused form on the other. This guide builds one from scratch using Tailwind CSS v4 and Alpine.js, with working tab switching between sign-in and sign-up and a password visibility toggle.

The final result: a full-viewport layout, left side is the form pane, right side is a decorative image panel with an absolutely positioned card floating above it. On mobile the image panel collapses and only the form shows. The overlay card and tab switching are handled entirely with Alpine.js — no build step, no framework, just a CDN script tag.

Prerequisites and setup

You need a project with Tailwind CSS v4 installed. In v4, configuration moves out of tailwind.config.js and into your CSS file using the @theme directive. Your main stylesheet should start like this:

@import "tailwindcss";

@theme {
  --color-brand-600: oklch(0.55 0.22 264);
  --color-brand-700: oklch(0.48 0.22 264);
  --radius-card: 1rem;
}

[x-cloak] { display: none !important; }

The [x-cloak] rule is important — it hides Alpine-controlled elements before Alpine initializes, preventing a flash of visible content. Add it once in your global CSS and Alpine's x-cloak attribute handles the rest.

For Alpine.js, add the CDN script tag before your closing </body> tag. At time of writing, Alpine.js v3 is current:

<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>

If you are using Astro, install Alpine via the official integration (@astrojs/alpinejs) and add it to astro.config.mjs instead of the CDN tag.

The outer shell: split-screen grid

The page is a min-h-screen flex container. On large screens it becomes a two-column layout. On mobile, the right panel is hidden entirely.

<div class="flex min-h-screen">

  <!-- Form pane -->
  <div class="flex flex-1 flex-col justify-center px-6 py-12 lg:px-16 xl:px-24">
    <!-- form content goes here -->
  </div>

  <!-- Decorative image pane — hidden on mobile -->
  <div class="relative hidden lg:block lg:w-1/2 xl:w-3/5">
    <img
      src="/images/auth-bg.jpg"
      alt=""
      class="absolute inset-0 h-full w-full object-cover"
    />
    <!-- overlay card goes here -->
  </div>

</div>

flex-1 on the form pane lets it grow to fill all available space when the image pane is hidden. relative on the image pane creates the stacking context the overlay card needs.

The overlay card

The overlay card sits at the bottom of the image panel, pinned with absolute inset-x-6 bottom-6 (or whatever spacing looks right for your design). It uses a semi-transparent background and a backdrop blur to give it a frosted-glass lift without covering the image entirely.

<div class="absolute inset-x-6 bottom-6 rounded-2xl bg-white/10 p-6 shadow-2xl ring-1 ring-white/20 backdrop-blur-md">
  <blockquote class="text-balance text-white">
    <p class="text-lg font-semibold leading-relaxed">
      "We launched our new site in a weekend. Our phone hasn't stopped ringing since."
    </p>
    <footer class="mt-4 text-sm text-white/70">
      <strong class="font-medium text-white">Maria Santos</strong>,
      owner at Santos Plumbing
    </footer>
  </blockquote>
</div>

A few notes on these choices. bg-white/10 uses Tailwind's opacity modifier — it produces rgba(255,255,255,0.1) without any extra CSS. ring-1 ring-white/20 adds a subtle border that separates the card from the image without a hard edge. backdrop-blur-md gives the frosted effect; it has broad browser support as of 2026 with no prefix needed.

Tab switching with Alpine.js

The form pane holds both the sign-in form and the sign-up form. Alpine.js controls which one is visible. Wrap everything in a single x-data component so the tab state is shared:

<div
  x-data="{
    tab: 'signin',
    showPassword: false,
    switchTab(t) { this.tab = t; this.showPassword = false; }
  }"
  class="flex flex-1 flex-col justify-center px-6 py-12 lg:px-16 xl:px-24"
>

tab holds the active panel — either 'signin' or 'signup'. showPassword resets to false whenever you switch tabs (handled in switchTab), which avoids leaking a revealed password across the transition.

The tab bar

<div class="mb-8 flex rounded-xl bg-gray-100 p-1">
  <button
    @click="switchTab('signin')"
    :class="tab === 'signin'
      ? 'bg-white shadow text-gray-900'
      : 'text-gray-500 hover:text-gray-700'"
    class="flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200"
  >
    Sign in
  </button>
  <button
    @click="switchTab('signup')"
    :class="tab === 'signup'
      ? 'bg-white shadow text-gray-900'
      : 'text-gray-500 hover:text-gray-700'"
    class="flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200"
  >
    Create account
  </button>
</div>

The :class binding merges the dynamic classes with the static ones already in class. Alpine evaluates the ternary and adds whichever string is returned — no string concatenation, no manual classList calls.

The sign-in form

<form x-show="tab === 'signin'" x-cloak x-transition action="/auth/signin" method="POST">
  <div class="space-y-5">

    <div>
      <label for="email" class="block text-sm font-medium text-gray-700">
        Email address
      </label>
      <input
        id="email"
        name="email"
        type="email"
        autocomplete="email"
        required
        class="mt-1.5 block w-full rounded-xl border border-gray-300 px-4 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600/20"
        placeholder="[email protected]"
      />
    </div>

    <div>
      <div class="flex items-center justify-between">
        <label for="password" class="block text-sm font-medium text-gray-700">
          Password
        </label>
        <a href="/auth/forgot-password" class="text-xs text-brand-600 hover:underline">
          Forgot password?
        </a>
      </div>
      <div class="relative mt-1.5">
        <input
          id="password"
          name="password"
          :type="showPassword ? 'text' : 'password'"
          autocomplete="current-password"
          required
          class="block w-full rounded-xl border border-gray-300 px-4 py-2.5 pr-10 text-sm text-gray-900 shadow-sm focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600/20"
        />
        <button
          type="button"
          @click="showPassword = !showPassword"
          class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600"
          :aria-label="showPassword ? 'Hide password' : 'Show password'"
        >
          <!-- Eye icon: swap based on showPassword -->
          <svg x-show="!showPassword" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-5 w-5" aria-hidden="true">
            <path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.641 0-8.578-3.007-9.964-7.178Z" />
            <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
          </svg>
          <svg x-show="showPassword" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-5 w-5" aria-hidden="true">
            <path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
          </svg>
        </button>
      </div>
    </div>

    <button
      type="submit"
      class="w-full rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white shadow hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-brand-600 focus:ring-offset-2"
    >
      Sign in
    </button>

  </div>
</form>

The password input uses :type="showPassword ? 'text' : 'password'" — Alpine's colon-prefix binding evaluates the expression and sets the attribute dynamically. The two SVG icons each have x-show so only one is visible at a time. Both carry aria-hidden="true" since the button's :aria-label already communicates the action to screen readers.

The sign-up form

<form x-show="tab === 'signup'" x-cloak x-transition action="/auth/signup" method="POST">
  <div class="space-y-5">

    <div class="grid grid-cols-2 gap-4">
      <div>
        <label for="first-name" class="block text-sm font-medium text-gray-700">First name</label>
        <input id="first-name" name="first_name" type="text" autocomplete="given-name" required
          class="mt-1.5 block w-full rounded-xl border border-gray-300 px-4 py-2.5 text-sm shadow-sm focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600/20"
        />
      </div>
      <div>
        <label for="last-name" class="block text-sm font-medium text-gray-700">Last name</label>
        <input id="last-name" name="last_name" type="text" autocomplete="family-name" required
          class="mt-1.5 block w-full rounded-xl border border-gray-300 px-4 py-2.5 text-sm shadow-sm focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600/20"
        />
      </div>
    </div>

    <div>
      <label for="signup-email" class="block text-sm font-medium text-gray-700">Email address</label>
      <input id="signup-email" name="email" type="email" autocomplete="email" required
        class="mt-1.5 block w-full rounded-xl border border-gray-300 px-4 py-2.5 text-sm shadow-sm focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600/20"
        placeholder="[email protected]"
      />
    </div>

    <div>
      <label for="signup-password" class="block text-sm font-medium text-gray-700">Password</label>
      <div class="relative mt-1.5">
        <input id="signup-password" name="password"
          :type="showPassword ? 'text' : 'password'"
          autocomplete="new-password" required
          class="block w-full rounded-xl border border-gray-300 px-4 py-2.5 pr-10 text-sm shadow-sm focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600/20"
        />
        <button type="button" @click="showPassword = !showPassword"
          class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600"
          :aria-label="showPassword ? 'Hide password' : 'Show password'"
        >
          <svg x-show="!showPassword" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-5 w-5" aria-hidden="true">
            <path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.641 0-8.578-3.007-9.964-7.178Z" />
            <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
          </svg>
          <svg x-show="showPassword" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-5 w-5" aria-hidden="true">
            <path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
          </svg>
        </button>
      </div>
    </div>

    <button type="submit"
      class="w-full rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white shadow hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-brand-600 focus:ring-offset-2"
    >
      Create account
    </button>

  </div>
</form>

Putting the page together

Here is the full document skeleton so you can see how all the pieces fit. This is the version you would drop into an Astro .astro file or any plain HTML page:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Sign in</title>
  <link rel="stylesheet" href="/styles/global.css" />
</head>
<body class="bg-white">

  <div class="flex min-h-screen">

    <!-- Form pane -->
    <div
      x-data="{
        tab: 'signin',
        showPassword: false,
        switchTab(t) { this.tab = t; this.showPassword = false; }
      }"
      class="flex flex-1 flex-col justify-center px-6 py-12 lg:px-16 xl:px-24"
    >
      <div class="mx-auto w-full max-w-sm">

        <!-- Logo or wordmark -->
        <a href="/" class="mb-10 block text-xl font-bold text-gray-900">Acme Co</a>

        <!-- Tab switcher -->
        <div class="mb-8 flex rounded-xl bg-gray-100 p-1">
          <button @click="switchTab('signin')"
            :class="tab === 'signin' ? 'bg-white shadow text-gray-900' : 'text-gray-500 hover:text-gray-700'"
            class="flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200">
            Sign in
          </button>
          <button @click="switchTab('signup')"
            :class="tab === 'signup' ? 'bg-white shadow text-gray-900' : 'text-gray-500 hover:text-gray-700'"
            class="flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200">
            Create account
          </button>
        </div>

        <!-- Sign-in form (see full markup above) -->
        <form x-show="tab === 'signin'" x-cloak x-transition ...> ... </form>

        <!-- Sign-up form (see full markup above) -->
        <form x-show="tab === 'signup'" x-cloak x-transition ...> ... </form>

      </div>
    </div>

    <!-- Decorative image pane -->
    <div class="relative hidden lg:block lg:w-1/2 xl:w-3/5">
      <img src="/images/auth-bg.jpg" alt="" class="absolute inset-0 h-full w-full object-cover" />
      <!-- optional dark gradient scrim -->
      <div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>

      <!-- Overlay card -->
      <div class="absolute inset-x-6 bottom-6 rounded-2xl bg-white/10 p-6 shadow-2xl ring-1 ring-white/20 backdrop-blur-md">
        <blockquote class="text-balance text-white">
          <p class="text-lg font-semibold leading-relaxed">
            "We launched in a weekend. Our phone hasn't stopped ringing since."
          </p>
          <footer class="mt-4 text-sm text-white/70">
            <strong class="font-medium text-white">Maria Santos</strong>, owner at Santos Plumbing
          </footer>
        </blockquote>
      </div>
    </div>

  </div>

  <script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
</body>
</html>

Common adjustments

Adding a transition between tabs

The bare x-transition attribute on each form applies Alpine's default fade. If you want more control, use the modifiers:

<form
  x-show="tab === 'signin'"
  x-cloak
  x-transition:enter="transition duration-200 ease-out"
  x-transition:enter-start="opacity-0 translate-y-1"
  x-transition:enter-end="opacity-100 translate-y-0"
  x-transition:leave="transition duration-150 ease-in"
  x-transition:leave-start="opacity-100"
  x-transition:leave-end="opacity-0"
>

Linking to a tab via query string

If you want ?tab=signup to open the registration panel directly, update x-data to read the URL on init:

x-data="{
  tab: new URLSearchParams(window.location.search).get('tab') === 'signup' ? 'signup' : 'signin',
  showPassword: false,
  switchTab(t) { this.tab = t; this.showPassword = false; }
}"

Social login buttons

Slot them between the tab bar and the form. A horizontal rule with "or continue with" text separates them from the email/password fields. Keep the forms in the same x-data wrapper so tab state is shared:

<div class="mb-6 flex gap-3">
  <a href="/auth/google" class="flex flex-1 items-center justify-center gap-2 rounded-xl border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50">
    Google
  </a>
  <a href="/auth/github" class="flex flex-1 items-center justify-center gap-2 rounded-xl border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50">
    GitHub
  </a>
</div>
<div class="relative mb-6">
  <div class="absolute inset-0 flex items-center">
    <div class="w-full border-t border-gray-200"></div>
  </div>
  <div class="relative flex justify-center text-xs text-gray-400">
    <span class="bg-white px-3">or continue with email</span>
  </div>
</div>

Accessibility notes

A few things worth checking before shipping:

  • Each input has a <label> with a matching for / id pair. Never rely on placeholder as a label substitute — placeholders disappear on focus.
  • The password toggle button is a type="button" — not a <div>, not an <a>. Using an actual button element gives you keyboard focus and Enter/Space activation for free.
  • The tab buttons do not use role="tab" and aria-selected here for simplicity, but if this layout is in an accessibility-critical product, add the full ARIA tab pattern.
  • The decorative image has an empty alt="" attribute, which tells screen readers to skip it.

Wrapping up

The split-screen pattern earns its weight because it separates concerns cleanly: the left side handles the task (authentication), the right side handles brand confidence (photography, social proof). Alpine.js is the right tool for the interactivity here — it is tiny, it ships no virtual DOM overhead, and the reactive bindings map directly onto Tailwind's utility classes without any impedance mismatch. The overlay card pattern works because backdrop-filter: blur and opacity modifiers let you layer UI over photography without hiding it. Once you have the pattern down, it adapts readily to onboarding flows, checkout pages, or any other task-plus-reassurance layout.