Overview
Learn how to build a fast, searchable selection modal using Laravel and Alpine.js. This tutorial shows the simple approach that performs well for small to medium datasets.
Tech Stack
- Laravel - Backend framework
- Alpine.js - Lightweight JavaScript reactivity
- Tailwind CSS - Utility-first styling
The Approach
1. Pre-compute Search Data
Do the heavy work once during render:
// Pre-compute search text for each item
$searchText = strtolower($item['name'] . ' ' . $item['description']);
2. Alpine.js for Search and Selection
Simple Alpine.js component:
{
search: '',
hasResults: true,
selectedValue: '',
init() {
this.$watch('search', () => this.filterItems());
},
filterItems() {
const searchLower = this.search.toLowerCase().trim();
const cards = this.$el.querySelectorAll('.item-card');
let visibleCount = 0;
cards.forEach(card => {
const text = card.dataset.searchText || '';
const isVisible = searchLower === '' || text.includes(searchLower);
card.style.display = isVisible ? '' : 'none';
if (isVisible) visibleCount++;
});
this.hasResults = visibleCount > 0;
}
}
3. Basic HTML Structure
<!-- Search input -->
<input type="search" x-model="search" placeholder="Search..." />
<!-- Items grid -->
<div class="grid gap-4">
<!-- Each item has data-search-text attribute -->
<div class="item-card" data-search-text="contact form simple">
<h3>Contact Form</h3>
<p>Simple contact form</p>
</div>
</div>
<!-- Empty state -->
<div x-show="search !== '' && !hasResults">
<p>No items found</p>
<button x-on:click="search = ''">Clear search</button>
</div>
Key Benefits
Instant Search Response
- No server requests during search
- Direct DOM manipulation for speed
- Works well for up to 50 items
Progressive Enhancement
- Works without JavaScript (graceful degradation)
- Accessible by default
- Mobile-friendly
Simple Maintenance
- No complex state management
- Easy to debug and extend
- Standard Laravel patterns
Performance Tips
Pre-compute when possible:
// Do this once during render, not during search
$searchText = strtolower($title . ' ' . $description);
Use direct DOM manipulation:
// Faster than virtual DOM for small datasets
card.style.display = isVisible ? '' : 'none';
Auto-focus for better UX:
this.$nextTick(() => this.$refs.searchInput?.focus());
When to Use This Approach
Perfect for:
- Small to medium datasets (< 50 items)
- Real-time search requirements
- Simple filtering logic
- Laravel applications
Consider alternatives for:
- Large datasets (> 100 items)
- Complex search algorithms
- Heavy data processing
Key Lessons
- Start Simple - Basic DOM manipulation often outperforms complex solutions
- Pre-compute When Possible - Do heavy work once, not repeatedly
- Progressive Enhancement - Build a working baseline first
- Alpine.js Shines - Perfect for form interactions and simple reactivity
Complete Working Example
Here's a full implementation you can copy and adapt:
{{-- Quick test component --}}
@php
$items = [
'contact' => ['name' => 'Contact Form', 'description' => 'Simple contact form', 'category' => 'Business'],
'survey' => ['name' => 'Survey Form', 'description' => 'Multi-question survey', 'category' => 'Research'],
'registration' => ['name' => 'Event Registration', 'description' => 'Event signup form', 'category' => 'Events'],
'newsletter' => ['name' => 'Newsletter Signup', 'description' => 'Email subscription form', 'category' => 'Marketing'],
'feedback' => ['name' => 'Feedback Form', 'description' => 'Customer feedback collection', 'category' => 'Support'],
];
@endphp
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Searchable Component</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="bg-gray-50 min-h-screen">
<div
x-data="{
search: '',
hasResults: true,
selectedValue: '',
init() {
this.$watch('search', () => this.filterItems());
this.$nextTick(() => this.$refs.searchInput?.focus());
},
filterItems() {
const searchLower = this.search.toLowerCase().trim();
const cards = this.$el.querySelectorAll('.item-card');
let visibleCount = 0;
cards.forEach(card => {
const text = card.dataset.searchText || '';
const isVisible = searchLower === '' || text.includes(searchLower);
card.style.display = isVisible ? '' : 'none';
if (isVisible) visibleCount++;
});
this.hasResults = visibleCount > 0;
}
}"
class="p-6 max-w-4xl mx-auto"
>
<h1 class="text-3xl font-bold mb-8 text-gray-800">Test: Real-time Search Component</h1>
{{-- Search Input --}}
<input
type="search"
x-model="search"
x-ref="searchInput"
placeholder="Search items..."
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none text-lg"
/>
{{-- Items Grid --}}
<div class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mt-8">
@foreach ($items as $value => $item)
@php
$searchText = strtolower($item['name'] . ' ' . $item['description'] . ' ' . $item['category']);
@endphp
<label
class="item-card cursor-pointer block"
data-search-text="{{ $searchText }}"
>
<input
type="radio"
name="selected_item"
value="{{ $value }}"
x-model="selectedValue"
class="sr-only"
/>
<div
class="border rounded-xl p-6 transition-all duration-200 hover:shadow-lg"
:class="selectedValue === '{{ $value }}' ? 'border-blue-600 bg-blue-50 shadow-lg ring-2 ring-blue-100' : 'border-gray-200 bg-white hover:border-gray-300'"
>
<h3 class="font-bold text-xl mb-3" :class="selectedValue === '{{ $value }}' ? 'text-blue-900' : 'text-gray-900'">{{ $item['name'] }}</h3>
<p class="text-gray-600 mb-3 leading-relaxed">{{ $item['description'] }}</p>
<span
class="inline-block px-3 py-1 text-sm rounded-full font-medium"
:class="selectedValue === '{{ $value }}' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-700'"
>{{ $item['category'] }}</span>
</div>
</label>
@endforeach
</div>
{{-- Empty State --}}
<div x-show="search !== '' && !hasResults" class="text-center py-16">
<div class="text-gray-400 mb-6">
<svg class="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<p class="text-xl font-semibold text-gray-600 mb-2">No items found</p>
<p class="text-gray-500">Try adjusting your search terms</p>
</div>
<button
type="button"
x-on:click="search = ''"
class="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Clear search
</button>
</div>
{{-- Results Info --}}
<div class="mt-8 p-4 bg-white border border-gray-200 rounded-lg">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<strong class="text-gray-700">Current search:</strong>
<span class="text-blue-600 font-mono" x-text="search || '(none)'"></span>
</div>
<div>
<strong class="text-gray-700">Has results:</strong>
<span :class="hasResults ? 'text-green-600' : 'text-red-600'" x-text="hasResults ? 'Yes' : 'No'"></span>
</div>
<div>
<strong class="text-gray-700">Selected:</strong>
<span class="text-blue-600 font-mono" x-text="selectedValue || '(none)'"></span>
</div>
</div>
</div>
</div>
</body>
</html>
How to Use
- Create the component - Save the above code as a Blade component
-
Include it - Use
<x-searchable-selector />
in your views -
Customize data - Replace the
$items
array with your data - Style it - Adjust Tailwind classes to match your design
Key Implementation Details
Pre-computed search text:
$searchText = strtolower($item['name'] . ' ' . $item['description'] . ' ' . $item['category']);
Alpine.js filtering:
cards.forEach(card => {
const text = card.dataset.searchText || '';
const isVisible = searchLower === '' || text.includes(searchLower);
card.style.display = isVisible ? '' : 'none';
if (isVisible) visibleCount++;
});
Visual selection feedback:
:class="selectedValue === '{{ $value }}' ? 'border-blue-600 bg-blue-50' : 'border-gray-300'"
This approach scales well for typical use cases and can be enhanced later if requirements grow.
This tutorial shows the approach used in FilaForms - Laravel form infrastructure for rapid development.
Top comments (0)