nickperkins/blade-icon-picker
Standalone Blade icon picker component for Livewire forms. Works with any blade-icons pack (Heroicons, Font Awesome, custom). Includes search, lazy-loaded icon grid, keyboard navigation, responsive layout, and Tailwind-free CSS with customizable variables.
Date: 2026-05-08
Laravel developers building Livewire forms often need to let users pick an icon — for menu items, categories, feature flags, or CMS content types. Today they have three bad options:
heroicon-o-home from memory. Error-prone and hostile.<select> with a hand-curated list. Fragile, incomplete, requires manual maintenance.guava/filament-icon-picker). Tightly coupled to Filament internals — generate_icon_html(), FilamentAsset, $wire.callSchemaComponentMethod(). Can't use it in a plain Livewire form outside Filament.There is no standalone, Livewire-compatible icon picker component for Laravel.
Primary: Laravel developers building admin panels, CMS backends, or internal tools with Livewire — who are NOT using Filament (or are using Filament but want a picker outside the admin panel, like in a user-facing settings form).
Secondary: Package developers who want to embed an icon picker in their own packages without pulling in Filament as a dependency.
<livewire:my-form>
<x-icon-picker::icon-picker wire:model="icon" />
</livewire:my-form>
The developer drops the component into any Livewire form. wire:model binds the selected icon name to a Livewire property. That's it.
| State | What the user sees |
|---|---|
| Empty (no icon selected) | A clickable field with placeholder text: "Select an icon" |
| Selected | The field shows the rendered SVG icon + its human-readable name |
| Clicked / focused | A dropdown panel opens below the field containing a search box and icon grid |
| Typing in search | Icons filter in real-time via substring token matching (no server round-trip) |
| Scrolling in grid | More icons load incrementally (lazy rendering) |
| Clicking an icon | That icon is selected, the dropdown closes, the Livewire property updates |
| Clicking the clear (×) button | Selection is cleared, field returns to empty state |
┌─────────────────────────────────────────┐
│ [Search icons...] │
│ │
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │home│ │user│ │gear│ │bell│ │star│ │
│ └────┘ └────┘ └────┘ └────┘ └────┘ │
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │mail│ │chat│ │lock│ │flag│ │book│ │
│ └────┘ └────┘ └────┘ └────┘ └────┘ │
│ ┌────┐ ┌────┐ │
│ │cake│ │moon│ ← scrolls for more │
│ └────┘ └────┘ │
└─────────────────────────────────────────┘
wire:modelx-intersect, chunk size 30)blade-ui-kit/blade-heroicons out of the boxwire:model updates still work)config/icon-picker.php) — added when there's a real config surface[@error](https://github.com/error) directive)The package uses blade-ui-kit/blade-icons as its icon backend. This means:
blade-ui-kit/blade-heroicons and the picker sees all Heroicons. Install blade-ui-kit/blade-fontawesome and those appear too. No changes to the picker.generate_icon_html(). Icons render inline via [@svg](https://github.com/svg) directive.O Home, S Home, M Home (style prefix + title-cased name).┌──────────────────────────────────────────────┐
│ Blade Component │
│ <x-icon-picker::icon-picker wire:model="x"> │
│ │
│ ┌─────────────────┐ ┌───────────────────┐ │
│ │ IconPicker.php │ │ icon-picker.js │ │
│ │ (View Component) │◄──│ (Alpine.js) │ │
│ │ │ │ │ │
│ │ - wire:model │ │ - substring │ │
│ │ - exposes icons │ │ search │ │
│ │ as JSON │ │ - lazy rendering │ │
│ │ - renders SVG │ │ - dropdown state │ │
│ │ │ │ - keyboard nav │ │
│ └────────┬─────────┘ └───────────────────┘ │
│ │ │
│ ┌────────▼─────────┐ │
│ │ IconManager.php │ │
│ │ │ │
│ │ - getAllIcons() │ │
│ │ - renderSvg($id) │ │
│ └────────┬──────────┘ │
│ │ │
│ ┌────────▼──────────┐ │
│ │ blade-icons │ │
│ │ Factory │ │
│ │ (all registered │ │
│ │ icons) │ │
│ └───────────────────┘ │
└──────────────────────────────────────────────┘
[{id, label}, ...])x-intersect → next chunk of 30 appendedNo AJAX calls during browsing or searching. The icon list is a one-time payload. On icon selection, Alpine calls $wire.set() to sync back to Livewire (a single network round-trip).
No icon packs installed: The component renders a helpful message: "No icon sets found. Install blade-ui-kit/blade-heroicons: composer require blade-ui-kit/blade-heroicons".
Icon list size: Heroicons v2 has ~292 icons × 3 styles = ~876 icons. The JSON payload for IDs + labels is ~50KB uncompressed, ~8KB gzipped. Acceptable for a one-time page load. For larger packs (FontAwesome 2,000+), a future optimization could paginate the initial load.
Search matching behavior: The query is split into tokens. An icon matches if EVERY token appears as a substring of the icon's label OR raw ID (case-insensitive). E.g., typing "ar le" matches heroicon-o-arrow-left ("O Arrow Left").
| Decision | Rationale |
|---|---|
| Substring token matching (no Fuse.js) | Simple, zero-dependency, handles 90%+ of real-world searches; add Fuse.js later if needed |
| Full icon list in page payload | One-time cost is small (8KB gzipped); eliminates AJAX complexity |
| Lazy DOM rendering via x-intersect (chunk size 30) | Rendering 800+ SVGs upfront kills performance; chunk-append is simple and sufficient for v1 |
| blade-icons as backend | Already a standard Laravel dependency; supports any icon pack; users already have it |
| No Filament dependency | The whole point of this package |
| esbuild for JS bundling | Minification and future-proofing; no heavy webpack/vite pipeline |
| Standalone CSS (no Tailwind dependency) | Package must work without Tailwind; ~150 lines of self-contained CSS |
| No set filtering in v1 | Simpler UI; variant labels ("O Home" vs "S Home") differentiate icons |
| Optimistic close on selection | Dropdown closes immediately; $wire.set() fires in background for instant UX |
| Constructor injection for IconManager | Standard Laravel pattern; explicit dependencies; testable |
// App\Livewire\CreateMenu.php
class CreateMenu extends Component
{
public string $icon = '';
public function rules()
{
return ['icon' => ['required', 'string']];
}
public function render()
{
return view('livewire.create-menu');
}
}
{{-- livewire/create-menu.blade.php --}}
<form wire:submit="save">
<x-icon-picker::icon-picker
wire:model="icon"
placeholder="Choose a menu icon"
/>
[@error](https://github.com/error)('icon')
<p class="text-red-500">{{ $message }}</p>
[@enderror](https://github.com/enderror)
<button type="submit">Save</button>
</form>
<x-icon-picker::icon-picker
wire:model="icon"
placeholder="Choose a menu icon"
/>
<x-icon-picker::icon-picker
wire:model="icon"
disabled
/>
blade-icon-picker/
├── AGENTS.md
├── README.md
├── composer.json ← requires blade-icons ^1.0, livewire ^3.0
├── package.json ← esbuild only (dev dependency)
├── bin/
│ └── build.js ← esbuild config (minifies JS + CSS)
├── resources/
│ ├── css/
│ │ └── icon-picker.css ← standalone styles (~150 lines, no framework)
│ ├── js/
│ │ └── components/
│ │ └── icon-picker.js ← Alpine.js component (substring search, lazy load, keyboard nav)
│ ├── views/
│ │ └── components/
│ │ └── icon-picker.blade.php ← main Blade component + inline icon grid
│ └── dist/ ← bundled JS + CSS output (gitignored)
├── src/
│ ├── IconPickerServiceProvider.php ← handwritten, no spatie dependency
│ ├── Icons/
│ │ ├── Icon.php ← value object: id, label, toArray()
│ │ └── IconManager.php ← wraps blade-icons Factory; constructor-injected
│ └── View/
│ └── Components/
│ └── IconPicker.php ← Blade component class
└── tests/
├── IconManagerTest.php ← Pest + Testbench
└── IconPickerComponentTest.php
All open questions resolved:
suggest in composer.json. Component handles empty state gracefully with a helpful message.[@svg](https://github.com/svg) directive.O Home, S Home, M Home.How can I help you explore Laravel packages today?