2024-11-2013 min readFrontend

Creating Dynamic UIs with Tailwind CSS, Livewire, and Alpine.js

Learn how to combine the power of Tailwind CSS, Livewire, and Alpine.js to create responsive and interactive user interfaces.

The TALL stack (Tailwind CSS, Alpine.js, Laravel, and Livewire) has become increasingly popular for building modern web applications. In this guide, we'll explore how to create dynamic user interfaces using these powerful tools.

Prerequisites

  • Laravel 10+
  • Livewire 3+
  • Alpine.js 3+
  • Tailwind CSS 3+

Setting Up the Environment

First, let's install all necessary packages:

composer require livewire/livewire
npm install -D tailwindcss alpinejs @tailwindcss/forms
npx tailwindcss init

Configure your tailwind.config.js:

module.exports = {
    content: [
        './resources/**/*.blade.php',
        './resources/**/*.js',
        './vendor/wire-elements/**/*.blade.php',
    ],
    theme: {
        extend: {
            // Custom theme extensions
            colors: {
                primary: {
                    50: '#f0f9ff',
                    500: '#0ea5e9',
                    900: '#0c4a6e',
                }
            }
        },
    },
    plugins: [
        require('@tailwindcss/forms'),
    ],
}

1. Building a Dynamic Search Component

Let's create a real-time search component using all three technologies:

// app/Livewire/SearchUsers.php
namespace App\Livewire;

use Livewire\Component;
use App\Models\User;

class SearchUsers extends Component
{
    public $search = '';
    public $users = [];

    public function updatedSearch()
    {
        $this->users = User::where('name', 'like', "%{$this->search}%")
            ->orWhere('email', 'like', "%{$this->search}%")
            ->take(5)
            ->get();
    }

    public function render()
    {
        return view('livewire.search-users');
    }
}

Create the corresponding Blade view:

<!-- resources/views/livewire/search-users.blade.php -->
<div class="relative" x-data="{ open: false }">
    <div class="relative">
        <input
            type="text"
            wire:model.live="search"
            @focus="open = true"
            @click.away="open = false"
            class="w-full px-4 py-2 border rounded-lg shadow-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
            placeholder="Search users..."
        >
        <div class="absolute inset-y-0 right-0 flex items-center pr-3">
            <svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
            </svg>
        </div>
    </div>

    <div
        x-show="open"
        x-transition:enter="transition ease-out duration-200"
        x-transition:enter-start="opacity-0 transform scale-95"
        x-transition:enter-end="opacity-100 transform scale-100"
        x-transition:leave="transition ease-in duration-100"
        x-transition:leave-start="opacity-100 transform scale-100"
        x-transition:leave-end="opacity-0 transform scale-95"
        class="absolute w-full mt-2 bg-white rounded-lg shadow-lg"
    >
        <ul class="py-1">
            @foreach($users as $user)
            <li class="px-4 py-2 hover:bg-gray-100 cursor-pointer">
                <div class="flex items-center">
                    <img src="{{ $user->avatar }}" class="w-8 h-8 rounded-full">
                    <div class="ml-3">
                        <p class="text-sm font-medium text-gray-900">{{ $user->name }}</p>
                        <p class="text-sm text-gray-500">{{ $user->email }}</p>
                    </div>
                </div>
            </li>
            @endforeach
        </ul>
    </div>
</div>

2. Creating a Modal Component

Let's build a reusable modal component:

<!-- resources/views/components/modal.blade.php -->
<div
    x-data="{ open: false }"
    x-on:open-modal.window="if ($event.detail.id === '{{ $id }}') open = true"
    x-on:close-modal.window="open = false"
    x-show="open"
    class="fixed inset-0 z-50 overflow-y-auto"
    style="display: none;"
>
    <div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
        <div
            x-show="open"
            x-transition:enter="ease-out duration-300"
            x-transition:enter-start="opacity-0"
            x-transition:enter-end="opacity-100"
            x-transition:leave="ease-in duration-200"
            x-transition:leave-start="opacity-100"
            x-transition:leave-end="opacity-0"
            class="fixed inset-0 transition-opacity"
        >
            <div class="absolute inset-0 bg-gray-500 opacity-75"></div>
        </div>

        <div
            x-show="open"
            x-transition:enter="ease-out duration-300"
            x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
            x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
            x-transition:leave="ease-in duration-200"
            x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
            x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
            class="inline-block w-full max-w-lg px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:p-6"
        >
            {{ $slot }}
        </div>
    </div>
</div>

3. Implementing a Dynamic Form

Create a form with real-time validation:

// app/Livewire/CreatePost.php
namespace App\Livewire;

use Livewire\Component;
use App\Models\Post;

class CreatePost extends Component
{
    public $title = '';
    public $content = '';
    public $category = '';

    protected $rules = [
        'title' => 'required|min:5',
        'content' => 'required|min:10',
        'category' => 'required',
    ];

    public function updated($propertyName)
    {
        $this->validateOnly($propertyName);
    }

    public function save()
    {
        $this->validate();

        Post::create([
            'title' => $this->title,
            'content' => $this->content,
            'category' => $this->category,
        ]);

        $this->reset();
        $this->dispatch('post-created');
    }

    public function render()
    {
        return view('livewire.create-post');
    }
}

The corresponding Blade view:

<!-- resources/views/livewire/create-post.blade.php -->
<form wire:submit="save" class="space-y-6">
    <div>
        <label class="block text-sm font-medium text-gray-700">
            Title
        </label>
        <input
            type="text"
            wire:model.live="title"
            class="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
        >
        @error('title')
            <p class="mt-1 text-sm text-red-600">{{ $message }}</p>
        @enderror
    </div>

    <div>
        <label class="block text-sm font-medium text-gray-700">
            Content
        </label>
        <textarea
            wire:model.live="content"
            rows="4"
            class="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
        ></textarea>
        @error('content')
            <p class="mt-1 text-sm text-red-600">{{ $message }}</p>
        @enderror
    </div>

    <div>
        <label class="block text-sm font-medium text-gray-700">
            Category
        </label>
        <select
            wire:model.live="category"
            class="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
        >
            <option value="">Select a category</option>
            <option value="technology">Technology</option>
            <option value="lifestyle">Lifestyle</option>
            <option value="business">Business</option>
        </select>
        @error('category')
            <p class="mt-1 text-sm text-red-600">{{ $message }}</p>
        @enderror
    </div>

    <div class="flex justify-end">
        <button
            type="submit"
            class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-primary-500 border border-transparent rounded-md shadow-sm hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
        >
            Create Post
        </button>
    </div>
</form>

4. Adding Loading States

Create a reusable loading spinner component:

<!-- resources/views/components/spinner.blade.php -->
<div
    wire:loading
    class="flex items-center justify-center"
>
    <svg class="w-5 h-5 text-primary-500 animate-spin" fill="none" viewBox="0 0 24 24">
        <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
        <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
    </svg>
</div>

5. Implementing Toast Notifications

Create a toast notification system:

// resources/js/toast.js
window.Toast = {
    init() {
        return {
            toasts: [],
            add(message, type = 'success') {
                const id = Date.now()
                this.toasts.push({ id, message, type })
                setTimeout(() => {
                    this.remove(id)
                }, 3000)
            },
            remove(id) {
                this.toasts = this.toasts.filter(toast => toast.id !== id)
            }
        }
    }
}

Add the toast component to your layout:

<!-- In your layout file -->
<div
    x-data="Toast.init()"
    class="fixed inset-x-0 bottom-0 px-4 pb-4"
    @notify.window="add($event.detail.message, $event.detail.type)"
>
    <template x-for="toast in toasts" :key="toast.id">
        <div
            x-show="true"
            x-transition:enter="transform ease-out duration-300 transition"
            x-transition:enter-start="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
            x-transition:enter-end="translate-y-0 opacity-100 sm:translate-x-0"
            class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto mb-4"
        >
            <div class="rounded-lg shadow-xs overflow-hidden">
                <div class="p-4">
                    <div class="flex items-start">
                        <div class="ml-3 w-0 flex-1 pt-0.5">
                            <p x-text="toast.message" class="text-sm leading-5 font-medium text-gray-900"></p>
                        </div>
                        <div class="ml-4 flex-shrink-0 flex">
                            <button @click="remove(toast.id)" class="inline-flex text-gray-400 hover:text-gray-500">
                                <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
                                    <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
                                </svg>
                            </button>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </template>
</div>

Best Practices

  1. Component Organization

    • Keep components small and focused
    • Use Blade components for UI elements
    • Use Livewire components for interactive features
  2. Performance Optimization

    • Use wire:model.live sparingly
    • Implement debouncing for search inputs
    • Lazy load components when possible
  3. Accessibility

    • Add proper ARIA labels
    • Ensure keyboard navigation works
    • Test with screen readers

Conclusion

The combination of Tailwind CSS, Livewire, and Alpine.js provides a powerful toolkit for building modern, interactive user interfaces. Each tool serves its purpose:

  • Tailwind CSS for rapid styling
  • Livewire for server-side reactivity
  • Alpine.js for client-side interactivity

Additional Resources