Skip to content
Bladewell
Guides

Card, avatar and empty state patterns for Laravel Blade

Copyable Blade recipes for a card, an avatar, a status badge, an empty state, a loading skeleton and a timeline, on your Tailwind theme's colours, for you or an AI agent to paste and change.

php artisan bladewell:add button icon

Uses Button and Icon. New to Bladewell? Install it first.

Recipes, not components

A card, an avatar or an empty state is markup and a few classes, with nothing to get wrong behind it: no keyboard handling, no state, nothing for Livewire to trip over. So these aren't Bladewell components to install and keep updated. They're recipes to paste and change, built on the same theme tokens as the components (bg-surface, border-line, text-muted, text-primary), so they follow your colours and your dark theme.

An AI agent writes these well. Point it at this page, which llms.txt lists, and what it makes will match the rest of the app.

Card

A bordered surface with a heading, a figure and actions. Make it a link by wrapping it in <a> instead of giving it buttons, never both.

A card
<article class="border-line bg-surface rounded-2xl border p-5 shadow-sm">
    <header class="flex items-start justify-between gap-4">
        <div>
            <h3 class="text-foreground font-semibold">Pro plan</h3>
            <p class="text-muted mt-1 text-sm">Renews on 1 November</p>
        </div>
        <x-widget.icon name="credit-card" class="text-foreground/60 size-5" />
    </header>

    <p class="text-foreground mt-4 text-2xl font-semibold tabular-nums">$29 <span class="text-muted text-sm font-normal">a month</span></p>

    <footer class="mt-5 flex gap-3">
        <x-widget.button size="sm" variant="neutral" href="/billing">Manage</x-widget.button>
        <x-widget.button size="sm" href="/billing/upgrade">Upgrade</x-widget.button>
    </footer>
</article>

Avatar

A photo when there is one, initials when there isn't. The initials stand in for a picture of the person, so they carry the person's name for screen readers. A stack overlaps them, ringed in the page colour.

An avatar, and a stack of them
@php
    // Two letters from the name, for when there's no photo: "Amelia Chen" → "AC".
    $initials = collect(explode(' ', $user->name))->map(fn (string $part): string => mb_substr($part, 0, 1))->take(2)->implode('');
@endphp

<span class="bg-primary/10 text-primary inline-grid size-10 place-items-center overflow-hidden rounded-full text-sm font-semibold">
    @if ($user->avatar_url ?? null)
        <img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="size-full object-cover">
    @else
        {{-- The initials stand in for a picture of the person, so they carry the name for screen readers. --}}
        <span role="img" aria-label="{{ $user->name }}">{{ $initials }}</span>
    @endif
</span>

{{-- A stack: each overlaps the last, ringed in the page colour so they stay apart. --}}
<div role="group" aria-label="3 people" class="flex -space-x-2">
    @foreach (['Ana Silva', 'Ben Okafor', 'Chloé Martin'] as $name)
        <span role="img" aria-label="{{ $name }}" class="bg-field text-foreground ring-surface inline-grid size-8 place-items-center rounded-full text-xs font-semibold ring-2">{{ collect(explode(' ', $name))->map(fn (string $part): string => mb_substr($part, 0, 1))->implode('') }}</span>
    @endforeach
</div>

Status badge

One tone per state, from the theme. The word and a dot carry the meaning too, as colour alone can't. Inside a table, the table's own badge does the same.

Status badges
@php
    // One look per state, from the theme's tokens, so a dark theme changes them too.
    $tones = [
        'paid' => 'bg-success/10 text-success',
        'pending' => 'bg-warning/10 text-warning',
        'failed' => 'bg-error/10 text-error',
        'draft' => 'bg-field text-foreground/70',
    ];
@endphp

@foreach ($tones as $status => $tone)
    <span class="{{ $tone }} inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium">
        {{-- A dot as well as the colour, and the word itself: colour alone can't carry the meaning. --}}
        <span class="size-1.5 rounded-full bg-current" aria-hidden="true"></span>
        {{ ucfirst($status) }}
    </span>
@endforeach

Empty state

Say what will be here and how to make the first one. The table has its own empty state; this is for everywhere else.

An empty state
<div class="border-line flex flex-col items-center rounded-2xl border border-dashed px-6 py-12 text-center">
    <span class="bg-field grid size-12 place-items-center rounded-full">
        <x-widget.icon name="inbox" class="text-foreground/60 size-6" />
    </span>
    <h3 class="text-foreground mt-4 font-semibold">No invoices yet</h3>
    <p class="text-muted mt-1 max-w-sm text-sm">Invoices you send show up here, with their status and when they were paid.</p>
    <x-widget.button class="mt-5" icon-start="plus" href="/invoices/create">New invoice</x-widget.button>
</div>

Loading skeleton

Grey blocks in the shape of what's coming. Screen readers hear that it's loading, not the blocks, and the pulse stops for people who ask for less motion.

A loading skeleton
{{-- While something loads: grey blocks in its shape. aria-busy and the hidden text tell screen readers to wait; the blocks themselves
     are hidden from them; motion-reduce stops the pulse for people who ask for less movement. --}}
<div aria-busy="true" class="grid gap-3">
    <span class="sr-only">Loading orders…</span>
    @foreach (range(1, 3) as $row)
        <div class="flex items-center gap-3" aria-hidden="true">
            <span class="bg-field size-10 shrink-0 animate-pulse rounded-full motion-reduce:animate-none"></span>
            <span class="grid flex-1 gap-2">
                <span class="bg-field h-3 w-2/5 animate-pulse rounded motion-reduce:animate-none"></span>
                <span class="bg-field h-3 w-3/5 animate-pulse rounded motion-reduce:animate-none"></span>
            </span>
        </div>
    @endforeach
</div>

Timeline

An ordered list with a line down its start edge, so it reads in order and flips for right-to-left pages.

A timeline
{{-- An ordered list, so screen readers say how many steps there are and which this is. --}}
<ol class="border-line ms-2 border-s">
    @foreach ([['Order placed', '9 Oct, 09:12'], ['Paid', '9 Oct, 09:13'], ['Shipped', '10 Oct, 14:40']] as [$event, $at])
        <li class="relative ps-6 pb-6 last:pb-0">
            <span class="bg-primary ring-surface absolute -start-[5px] top-1.5 size-2.5 rounded-full ring-4" aria-hidden="true"></span>
            <p class="text-foreground text-sm font-medium">{{ $event }}</p>
            <p class="text-muted text-xs">{{ $at }}</p>
        </li>
    @endforeach
</ol>