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.
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'),
],
}
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>
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>
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>
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>
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>
Component Organization
Performance Optimization
Accessibility
The combination of Tailwind CSS, Livewire, and Alpine.js provides a powerful toolkit for building modern, interactive user interfaces. Each tool serves its purpose: