Skip to content
Bladewell

<x-widget.auth-layout>

Auth layout Blade component for Laravel and Livewire

A sign-in, sign-up or password page: your brand, a heading, the form and a line under it. Split puts the form beside a panel in your brand's colour on large screens, with a quote or a few words at its foot, and gives the form the page on phones; centered sets it on a card in the middle. The form rises into place as the page paints, unless motion is reduced. No script, so it works inside Livewire components as it is.

php artisan bladewell:add auth-layout
Also adds
Nothing else. It stands alone.

Usage

Livewire

The layout has no script, so inside a Livewire component it renders like any other markup: bind the fields with wire:model and submit with wire:submit. Errors from $this->validate() show under each field.

Blade
<x-widget.auth-layout title="Welcome back" subtitle="Sign in to your account.">
    <form wire:submit="login" class="flex flex-col gap-5">
        <x-widget.text-input wire:model="email" type="email" label="Email" autocomplete="username" required />
        <x-widget.password wire:model="password" label="Password" required />
        <x-widget.checkbox wire:model="remember" label="Remember me" />
        <x-widget.button type="submit" class="w-full">Sign in</x-widget.button>
    </form>
</x-widget.auth-layout>

Examples

These examples also use Text input, Password, Checkbox and Button, which Auth layout doesn't need on its own. To use them as they are:

Terminal
php artisan bladewell:add auth-layout text-input password checkbox button

Sign in

A sign-in page, with your brand's panel beside the form on a large screen and the form alone on a phone. It posts to the login route Laravel's starter kits name, so it works with Fortify or your own controller as it is. In your app, leave out the box around it: it fills the window.

Welcome back

Sign in to your Acme account.

Forgot password?
No account yet? Create one
Show code
Blade
<div class="border-line h-[44rem] overflow-hidden rounded-2xl border">
    <x-widget.auth-layout title="Welcome back" subtitle="Sign in to your Acme account.">
        <x-slot:brand>
            <a href="{{ url('/') }}" class="focus-visible:ring-primary flex items-center gap-3 rounded-lg outline-none focus-visible:ring-2">
                <span aria-hidden="true" class="bg-primary-fill text-on-primary grid size-8 place-items-center rounded-lg text-sm font-bold">A</span>
                <span class="font-semibold">Acme</span>
            </a>
        </x-slot:brand>

        <form method="POST" action="{{ route('login') }}" class="flex flex-col gap-5">
            @csrf
            <x-widget.text-input name="email" type="email" label="Email" autocomplete="username" required />
            <x-widget.password name="password" label="Password" required />
            <div class="flex items-center justify-between gap-4">
                <x-widget.checkbox name="remember" label="Remember me" />
                <a href="{{ route('password.request') }}" class="text-link hover:text-link-hover shrink-0 text-sm font-medium whitespace-nowrap underline-offset-4 hover:underline">Forgot password?</a>
            </div>
            <x-widget.button type="submit" class="mt-2 w-full">Sign in</x-widget.button>
        </form>

        <x-slot:footer>
            No account yet? <a href="{{ route('register') }}" class="text-link hover:text-link-hover font-medium underline-offset-4 hover:underline">Create one</a>
        </x-slot:footer>

        <x-slot:aside>
            <figure class="max-w-md">
                <blockquote class="text-xl leading-relaxed font-medium xl:text-2xl">“We moved our whole back office to Acme in a week. Our team hasn't looked back.”</blockquote>
                <figcaption class="mt-6 flex items-center gap-3 text-sm">
                    <span aria-hidden="true" class="bg-on-primary text-primary-fill grid size-10 place-items-center rounded-full font-semibold">MC</span>
                    <span>
                        <span class="block font-semibold">Maya Chen</span>
                        <span class="block">Head of Operations, Northwind</span>
                    </span>
                </figcaption>
            </figure>
        </x-slot:aside>
    </x-widget.auth-layout>
</div>

Sign up

A sign-up page. The password field shows its rules as a checklist while it's typed; match them to your Form Request, e.g. Password::min(8)->numbers(). The terms box is required, so the browser asks for it before the form is sent.

Create your account

Start your free 14-day trial. No card needed.

  • At least 8 characters
  • A number
Already have an account? Sign in
Show code
Blade
<div class="border-line h-[54rem] overflow-hidden rounded-2xl border">
    <x-widget.auth-layout title="Create your account" subtitle="Start your free 14-day trial. No card needed.">
        <x-slot:brand>
            <a href="{{ url('/') }}" class="focus-visible:ring-primary flex items-center gap-3 rounded-lg outline-none focus-visible:ring-2">
                <span aria-hidden="true" class="bg-primary-fill text-on-primary grid size-8 place-items-center rounded-lg text-sm font-bold">A</span>
                <span class="font-semibold">Acme</span>
            </a>
        </x-slot:brand>

        <form method="POST" action="{{ route('register') }}" class="flex flex-col gap-5">
            @csrf
            <x-widget.text-input name="name" label="Name" autocomplete="name" required />
            <x-widget.text-input name="email" type="email" label="Email" autocomplete="email" required />
            <x-widget.password name="password" label="Password" new :min="8" numbers required />
            <x-widget.password name="password_confirmation" label="Confirm password" autocomplete="new-password" :toggle="false" required />
            <x-widget.checkbox name="terms" required>
                I agree to the <a href="#terms" class="text-link hover:text-link-hover underline underline-offset-4">terms</a> and <a href="#privacy" class="text-link hover:text-link-hover underline underline-offset-4">privacy policy</a>
            </x-widget.checkbox>
            <x-widget.button type="submit" class="mt-2 w-full">Create account</x-widget.button>
        </form>

        <x-slot:footer>
            Already have an account? <a href="{{ route('login') }}" class="text-link hover:text-link-hover font-medium underline-offset-4 hover:underline">Sign in</a>
        </x-slot:footer>

        <x-slot:aside>
            <p class="text-sm font-semibold tracking-wide uppercase">Everything in one place</p>
            <p class="mt-3 max-w-md text-2xl leading-snug font-semibold xl:text-3xl">Orders, customers and reports, ready the moment you sign up.</p>
            <ul class="mt-8 flex flex-col gap-3 text-sm">
                <li>Unlimited team members</li>
                <li>Exports to CSV and Excel</li>
                <li>Cancel any time</li>
            </ul>
        </x-slot:aside>
    </x-widget.auth-layout>
</div>

Forgot password

variant="centered": the form on a card in the middle of a tinted page, for a short form or an app without a brand panel. It posts to password.email, the route Laravel's starter kits send the reset link from.

Forgot your password?

Enter your email and we'll send you a link to choose a new one.

Remembered it? Back to sign in
Show code
Blade
<div class="border-line h-[34rem] overflow-hidden rounded-2xl border">
    <x-widget.auth-layout variant="centered" title="Forgot your password?" subtitle="Enter your email and we'll send you a link to choose a new one.">
        <x-slot:brand>
            <a href="{{ url('/') }}" class="focus-visible:ring-primary flex items-center gap-3 rounded-lg outline-none focus-visible:ring-2">
                <span aria-hidden="true" class="bg-primary-fill text-on-primary grid size-8 place-items-center rounded-lg text-sm font-bold">A</span>
                <span class="font-semibold">Acme</span>
            </a>
        </x-slot:brand>

        <form method="POST" action="{{ route('password.email') }}" class="flex flex-col gap-5">
            @csrf
            <x-widget.text-input name="email" type="email" label="Email" autocomplete="email" required />
            <x-widget.button type="submit" class="w-full">Email me a reset link</x-widget.button>
        </form>

        <x-slot:footer>
            Remembered it? <a href="{{ route('login') }}" class="text-link hover:text-link-hover font-medium underline-offset-4 hover:underline">Back to sign in</a>
        </x-slot:footer>
    </x-widget.auth-layout>
</div>

Props

Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.

<x-widget.auth-layout>

Prop Default Description
title null The page's heading, its h1 ("Welcome back").
subtitle null A line under the heading ("Sign in to your account.").
variant 'split' split: the form beside a panel in your brand's colour from lg up, the form alone below that. centered: the form on a card in the middle of a tinted page.

Accessibility

All 3 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.

Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.

Source

What bladewell:add auth-layout writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from the components it also adds , and the theme and base CSS.

resources/views/components/widget/auth-layout/index.blade.php Show
index.blade.php
@props([
    // The page's heading, its h1 ("Welcome back").
    'title' => null,
    // A line under the heading ("Sign in to your account.").
    'subtitle' => null,
    // split: the form beside a panel in your brand's colour from lg up, the form alone below that. centered: the form
    // on a card in the middle of a tinted page.
    'variant' => 'split',
])

@php
    // A typo fails loudly, naming the values that work, instead of quietly rendering something else.
    if (! in_array($variant, ['split', 'centered'], true)) {
        throw new \InvalidArgumentException("Unknown variant [{$variant}] for <x-widget.auth-layout>. Use one of: split, centered.");
    }
    $split = $variant === 'split';
    // The form and its heading rise into place as the page first paints (@starting-style), unless motion is reduced.
    $enter = 'transition-[opacity,translate] duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] starting:translate-y-3 starting:opacity-0 motion-reduce:transition-none';
@endphp

{{--
    A sign-in, sign-up or password page: your brand, a heading, the form (the slot) and a line under it, such as a link
    to the other page.

    split puts a panel in your brand's colour beside the form on large screens, with whatever the aside slot holds (a
    quote, a few words about the product) at its foot; on smaller screens the form has the page to itself. centered sets
    the form on a card in the middle of a tinted page.

    It fills the window (h-dvh), or its parent where that has a height of its own (max-h-full), like the examples here,
    and the form scrolls inside it when it's longer than the window. No script and no inline style.
--}}
<div
    data-auth-layout
    {{ $attributes->class([
        'text-foreground relative flex h-dvh max-h-full',
        'bg-surface' => $split,
        'flex-col overflow-y-auto bg-[color-mix(in_oklab,var(--color-field)_45%,var(--color-surface))]' => ! $split,
    ]) }}
>
    @if ($split)
        <main class="flex min-w-0 flex-1 flex-col overflow-y-auto px-6 py-6 sm:px-12">
            @isset($brand)
                <div class="flex shrink-0 items-center gap-3">{{ $brand }}</div>
            @endisset
            <div class="flex flex-1 items-center justify-center py-6">
                <div class="{{ $enter }} w-full max-w-sm">
                    @if ($title)
                        <h1 class="text-2xl font-semibold tracking-tight sm:text-3xl">{{ $title }}</h1>
                    @endif
                    @if ($subtitle)
                        <p class="text-foreground/70 mt-2 text-sm">{{ $subtitle }}</p>
                    @endif
                    <div @class(['mt-8' => $title || $subtitle])>{{ $slot }}</div>
                    @isset($footer)
                        <div class="text-foreground/70 mt-8 text-center text-sm">{{ $footer }}</div>
                    @endisset
                </div>
            </div>
        </main>

        {{-- The brand panel, from lg up. Its glow sits in the top half, clear of the text at its foot, so that text is
             always on the plain fill: readable, and measurable by contrast checks. --}}
        <aside class="bg-primary-fill text-on-primary relative m-3 hidden w-1/2 shrink-0 flex-col justify-end overflow-hidden rounded-3xl transition-opacity duration-1000 starting:opacity-0 motion-reduce:transition-none lg:flex">
            <div aria-hidden="true" class="pointer-events-none absolute inset-x-0 top-0 h-1/2">
                <div class="bg-on-primary/25 absolute -end-24 -top-28 size-96 rounded-full blur-3xl"></div>
                <div class="absolute start-1/4 -top-16 size-72 rounded-full bg-black/25 blur-3xl"></div>
                <div class="absolute inset-0 bg-[radial-gradient(color-mix(in_oklab,var(--color-on-primary)_30%,transparent)_1px,transparent_1px)] bg-size-[22px_22px] opacity-60"></div>
            </div>
            @isset($aside)
                <div class="relative p-10 xl:p-14">{{ $aside }}</div>
            @endisset
        </aside>
    @else
        <main class="flex min-h-full flex-col items-center justify-center gap-8 px-4 py-10">
            @isset($brand)
                <div class="flex items-center justify-center gap-3">{{ $brand }}</div>
            @endisset
            <div class="{{ $enter }} border-line bg-surface w-full max-w-md rounded-2xl border p-6 shadow-sm sm:p-8">
                @if ($title)
                    <h1 class="text-2xl font-semibold tracking-tight">{{ $title }}</h1>
                @endif
                @if ($subtitle)
                    <p class="text-foreground/70 mt-2 text-sm">{{ $subtitle }}</p>
                @endif
                <div @class(['mt-6' => $title || $subtitle])>{{ $slot }}</div>
            </div>
            @isset($footer)
                <div class="text-foreground/70 text-center text-sm">{{ $footer }}</div>
            @endisset
        </main>
    @endif
</div>