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 Source: PRD
This package has three layers, all running inside the host Laravel application:
| Layer | Runs Where | Responsibility |
|---|---|---|
PHP backend (src/) |
Server | Icon resolution via blade-icons, rendering the Blade component, serializing the icon catalog (including SVG content) into the page payload |
Blade view (resources/views/) |
Server-side render | HTML scaffold: trigger field, dropdown panel, icon grid template, and the Alpine.js x-data with the icon list |
Alpine.js frontend (resources/js/) |
Browser | Search, lazy DOM rendering via x-intersect, keyboard navigation, dropdown open/close, $wire.set() for Livewire sync |
Key constraint: Zero server round-trips during browsing or searching. The full icon catalog including SVGs is embedded as JSON in the page. The only network request is $wire.set() on selection — a single round-trip to update the Livewire-bound property.
┌────────────────────────────────────────────────────────────┐
│ Page Load (Server) │
│ │
│ IconPicker.php ──► IconManager.php ──► blade-icons │
│ (view component) (getAllIcons()) Factory │
│ │ renderSvg() │
│ ▼ │
│ icon-picker.blade.php │
│ ┌──────────────────────────────────────────────────┐ │
│ │ <div x-data="iconPicker({ icons: [...], ... })"> │ │
│ │ <!-- each icon: {id, label, svg} --> │ │
│ │ <!-- trigger field --> │ │
│ │ <!-- dropdown panel with icon grid --> │ │
│ │ </div> │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ The JSON payload ships to the browser. │
│ SVGs are raw strings; DOM elements are created lazily │
│ only when the chunk becomes visible. │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ Browser (Alpine.js) │
│ │
│ iconPicker() manages state: │
│ - isOpen (boolean) │
│ - selectedId │
│ - query │
│ - chunkCount │
│ - activeIconIndex (for keyboard nav) │
│ │
│ User clicks icon ──► $wire.set(propertyName, id) │
│ User types query ──► substring token match in-memory │
│ User scrolls ──► x-intersect fires → next chunk │
└────────────────────────────────────────────────────────────┘
IconPicker\Icons\Icon — Value ObjectA single icon entry. Simple, immutable, serializable.
namespace IconPicker\Icons;
final readonly class Icon
{
public string $id; // e.g. "heroicon-o-home"
public string $label; // e.g. "O Home"
public string $svg; // inline SVG markup string
public function __construct(string $id, string $label, string $svg) {}
/** [@return](https://github.com/return) array{id: string, label: string, svg: string} */
public function toArray(): array;
}
IconPicker\Icons\IconManager — Icon ResolutionWraps the blade-icons Factory. Responsible for discovering, labeling, and rendering all registered icons.
namespace IconPicker\Icons;
use BladeUI\Icons\Factory;
class IconManager
{
private const CHUNK_SIZE = 30;
public function __construct(private Factory $factory) {}
/** [@return](https://github.com/return) Icon[] */
public function getAllIcons(): array;
/** Render an icon as inline SVG, or throw if not found. */
public function renderSvg(string $id): string;
}
getAllIcons() algorithm:
for each icon set registered in Factory:
for each icon name in set:
prefix = set prefix (e.g. "heroicon-o")
rawName = icon name without prefix (e.g. "home", "arrow-left")
styleLabel = derive from prefix: last segment after final "-", uppercased
(e.g. "heroicon-o" → "O", "heroicon-s" → "S", "heroicon-m" → "M")
label = styleLabel + " " + titleCase(rawName)
id = prefix . '-' . rawName (e.g. "heroicon-o-home")
svg = $this->renderSvg($id) (inline SVG markup string)
yield Icon(id, label, svg)
ID convention matters. Blade-icons stores icons using - as the separator (e.g. heroicon-o-home), not :. The [@svg](https://github.com/svg) Blade directive, Factory::svg(), and all blade-icons consumers expect this format. The id field stored in the database via $wire.set() must match this convention so that the value works with any blade-icons consumer in the application.
renderSvg():
Delegates to the blade-icons Factory::svg() method. Wrapped in try/catch — if the icon doesn't exist, throws a custom IconNotFoundException with the ID in the message.
IconPicker\View\Components\IconPicker — Blade Component ClassExtends Illuminate\View\Component. This is the server-side entry point for each <x-icon-picker::icon-picker> tag.
namespace IconPicker\View\Components;
use IconPicker\Icons\IconManager;
use Illuminate\Contracts\View\View;
class IconPicker extends Component
{
private const CHUNK_SIZE = 30;
public function __construct(
private IconManager $manager,
public string $placeholder = 'Select an icon',
public bool $disabled = false,
public ?string $value = null,
) {}
/** [@return](https://github.com/return) array{id: string, label: string, svg: string}[] */
public function icons(): array; // calls $this->manager->getAllIcons()
public function render(): View
{
return view('icon-picker::components.icon-picker', [
'icons' => $this->icons(),
'placeholder' => $this->placeholder,
'disabled' => $this->disabled,
'value' => $this->value,
'chunkSize' => self::CHUNK_SIZE,
]);
}
}
$value prop for Livewire binding. The component is a Blade component, not a Livewire component. It cannot read the parent Livewire component's property value from wire:model alone. Instead, the developer passes the current value explicitly:
<x-icon-picker::icon-picker wire:model="icon" :value="$icon" />
The $value prop is passed to the Alpine component as currentValue, seeding the initial selection state. When Livewire re-renders the component after a $wire.set() callback, the new $value prop is provided and Alpine re-initializes with the correct selected icon.
Constructor injection of IconManager is standard Laravel: the container resolves it automatically since IconManager is bound in the service provider and its own dependency (Factory) is available via blade-icons.
chunkSize is hardcoded as a private constant (30). Not a public constructor prop — configurable chunk size is P2 and the PRD defers it. The constant is passed to the view for Alpine's use.
render() explicitly passes data to the view including $icons, $placeholder, $disabled, $value, and $chunkSize. Public methods on the component are not automatically available as $variable in the view.
IconPicker\IconPickerServiceProvider — Service Providernamespace IconPicker;
use IconPicker\Icons\IconManager;
use IconPicker\View\Components\IconPicker;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
use BladeUI\Icons\Factory;
class IconPickerServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(IconManager::class, fn ($app) =>
new IconManager($app->make(Factory::class))
);
}
public function boot(): void
{
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'icon-picker');
$this->publishes([
__DIR__ . '/../resources/dist' => public_path('vendor/icon-picker'),
], 'icon-picker-assets');
Blade::component('icon-picker::icon-picker', IconPicker::class);
}
}
Thin provider. It binds IconManager as a singleton (icons computed once per request), loads views, publishes assets, and registers the Blade component under the icon-picker::icon-picker tag — matching the <x-icon-picker::icon-picker> usage throughout the PRD.
No spatie/laravel-package-tools. PRD section 7 says "handwritten, no spatie dependency." The provider is ~25 lines — simple enough to write by hand.
resources/views/components/icon-picker.blade.php
[@props](https://github.com/props)(['disabled' => false, 'placeholder' => 'Select an icon', 'value' => null])
{{-- Empty state: no icon packs installed --}}
[@if](https://github.com/if)(empty($icons))
<div class="ip-empty">
No icon sets found. Install blade-ui-kit/blade-heroicons:
<code>composer require blade-ui-kit/blade-heroicons</code>
</div>
[@else](https://github.com/else)
<div
class="ip-root"
x-data="iconPicker({
icons: {{ \Illuminate\Support\Js::from($icons) }},
currentValue: {{ \Illuminate\Support\Js::from($value) }},
placeholder: {{ \Illuminate\Support\Js::from($placeholder) }},
disabled: {{ $disabled ? 'true' : 'false' }},
chunkSize: {{ $chunkSize }}
})"
x-on:keydown.escape.window="close()"
{{ $attributes->except(['placeholder', 'disabled', 'value']) }}
>
{{-- TRIGGER: wrapping div; trigger button + clear button are siblings --}}
<div class="ip-trigger-wrapper">
<button
type="button"
class="ip-trigger"
:disabled="disabled"
x-bind:aria-expanded="isOpen"
aria-haspopup="listbox"
x-ref="trigger"
x-on:click="toggle()"
>
<span x-show="!selectedId" x-text="placeholder"></span>
<template x-if="selectedIcon">
<span class="ip-trigger-selected">
<span x-html="selectedIcon.svg"></span>
<span x-text="selectedIcon.label"></span>
</span>
</template>
<span class="ip-chevron" aria-hidden="true">▼</span>
</button>
<button
x-show="selectedId"
type="button"
class="ip-clear"
x-on:click.stop="clear()"
aria-label="Clear selection"
>×</button>
</div>
{{-- DROPDOWN PANEL --}}
<div
x-show="isOpen"
x-trap="isOpen"
class="ip-dropdown"
x-on:click.outside="close()"
>
{{-- SEARCH INPUT --}}
<input
type="text"
class="ip-search"
x-model="query"
x-ref="searchInput"
placeholder="Search icons..."
x-on:input="onSearch()"
/>
{{-- ICON GRID --}}
<div class="ip-grid" x-ref="grid" role="listbox">
<template x-for="(icon, index) in visibleIcons" :key="icon.id">
<button
type="button"
class="ip-icon-btn"
x-bind:class="{
'ip-icon-btn--selected': icon.id === selectedId,
'ip-icon-btn--active': index === activeIconIndex
}"
role="option"
x-bind:aria-selected="(icon.id === selectedId).toString()"
x-on:click="select(icon)"
>
<span x-html="icon.svg"></span>
<span class="ip-icon-label" x-text="icon.label"></span>
</button>
</template>
{{-- SENTINEL for infinite scroll --}}
<div
x-show="hasMore"
x-intersect="loadNextChunk()"
></div>
</div>
{{-- EMPTY SEARCH STATE --}}
<div x-show="visibleIcons.length === 0" class="ip-empty">
No icons match your search.
</div>
</div>
</div>
[@endif](https://github.com/endif)
The component accepts standard wire:model via $attributes. Laravel's Blade component system merges attributes — so wire:model="icon" flows through to the root <div>.
Usage pattern:
<x-icon-picker::icon-picker wire:model="icon" :value="$icon" />
The developer passes BOTH wire:model (for Alpine to know which Livewire property to update) and :value="$icon" (to seed the initial selection). This is the standard pattern for non-Livewire Blade components that need to reflect Livewire state.
On selection, Alpine extracts the property name from the wire:model attribute and calls $wire.set(propertyName, iconId). Livewire picks up the change, re-renders the parent component, and the new $value reaches the Blade component — Alpine re-initializes with selectedId matching the new value, showing the selected icon in the trigger field.
wire:model ModifiersLivewire 3 modifiers (.live, .blur, .debounce.300ms) produce DOM attribute names like wire:model.live, not wire:model. The Alpine component searches for any attribute starting with wire:model to extract the property name (see §4.5). This ensures modifiers work correctly.
If IconManager::getAllIcons() returns an empty array, the Blade view renders a static message instead of the interactive picker:
[@if](https://github.com/if)(empty($icons))
<div class="ip-empty">
No icon sets found. Install blade-ui-kit/blade-heroicons:
<code>composer require blade-ui-kit/blade-heroicons</code>
</div>
[@else](https://github.com/else)
{{-- normal picker markup --}}
[@endif](https://github.com/endif)
Blade short-circuits: Alpine and the JS bundle are never loaded.
resources/js/components/icon-picker.js
export function iconPicker(config) {
return {
// --- static config ---
allIcons: config.icons, // [{id, label, svg}, ...] — SVGs are strings, not parsed DOM
placeholder: config.placeholder,
disabled: config.disabled,
chunkSize: config.chunkSize,
// --- reactive state ---
isOpen: false,
selectedId: config.currentValue || '',
query: '',
chunkCount: 1,
activeIconIndex: -1,
// --- computed (via getters) ---
get selectedIcon() { /* §4.5 */ },
get filteredIcons() { /* §4.3 */ },
get visibleIcons() { /* §4.4 */ },
get hasMore() { /* §4.4 */ },
// --- methods ---
toggle() { /* §4.5 */ },
close() { /* §4.5 */ },
select(icon) { /* §4.5 */ },
clear() { /* §4.5 */ },
onSearch() { /* §4.5 */ },
loadNextChunk() { /* §4.4 */ },
onKeydown(event) { /* §4.6 */ },
};
}
No open / open() collision. The boolean state is named isOpen. There is no open() method — toggle() handles opening, and there's no standalone open action needed beyond toggle() (clicking the field) and the x-trap directive setting focus automatically. The PRD does not require a programmatic open() — the user opens the dropdown by clicking the trigger.
Each icon in the JSON payload carries THREE fields: {id, label, svg}. The svg field contains the full inline SVG markup string rendered by blade-icons at page-load time.
selectedIcon.svg is bound via x-html — shows the selected icon's SVG instantly.icon.svg from the chunk is bound via x-html — each visible button gets its SVG.SVGs are raw strings in JSON, not parsed DOM elements. They only become DOM elements when Alpine renders them via x-html — which only happens for icons in the currently visible chunk. This means lazy rendering via x-intersect truly defers DOM creation.
Payload size trade-off (acknowledged deviation from PRD criterion #3):
| Uncompressed | Gzipped | |
|---|---|---|
| IDs + labels (~876 icons) | ~50 KB | ~8 KB |
| SVG strings (~876 icons) | ~200 KB | ~18 KB |
| Total | ~250 KB | ~26 KB |
The PRD's success criterion #3 targets ≤15KB gzipped for the "icon list JSON." Adding SVG content pushes the total to ~26KB gzipped. This is a deliberate deviation. The alternatives considered and rejected:
<template> bank in the DOM: SVG markup is parsed at page load (defeating lazy render). DOM parse time increases. Still gzipped ~20KB on top of the JSON payload.get filteredIcons() {
if (!this.query.trim()) return this.allIcons;
const tokens = this.query.trim().toLowerCase().split(/\s+/);
return this.allIcons.filter(icon => {
const haystack = (icon.id + ' ' + icon.label).toLowerCase();
return tokens.every(token => haystack.includes(token));
});
}
Behavior matches PRD §5.4: Split query into tokens by whitespace. An icon matches if EVERY token is a substring of the combined id + label (case-insensitive). Example: "ar le" matches heroicon-o-arrow-left.
No debounce needed. The filter is synchronous in-memory over ~1,000 items. It completes in well under 1ms.
get visibleIcons() {
return this.filteredIcons.slice(0, this.chunkCount * this.chunkSize);
}
get hasMore() {
return this.visibleIcons.length < this.filteredIcons.length;
}
loadNextChunk() {
this.chunkCount++;
}
x-intersect fires when the sentinel <div> enters the viewport. loadNextChunk() increments chunkCount, which expands visibleIcons. Alpine's x-for reacts, appending the next 30 icon buttons to the DOM. Each button's SVG string is now parsed for the first time — no SVG DOM elements existed before this chunk.
Resetting on search: onSearch() resets chunkCount to 1, so filtered results restart from chunk 1.
toggle() {
if (this.disabled) return;
this.isOpen = !this.isOpen;
if (this.isOpen) {
this.chunkCount = 1;
this.activeIconIndex = -1;
this.$nextTick(() => {
this.$refs.searchInput?.focus();
});
}
}
close() {
this.isOpen = false;
this.query = '';
this.chunkCount = 1;
this.activeIconIndex = -1;
}
select(icon) {
this.selectedId = icon.id;
// Optimistic close per PRD §5.5:
this.close();
// Livewire sync in background:
const modelName = this.resolveWireModel();
if (this.$wire && modelName) {
this.$wire.set(modelName, icon.id);
}
}
clear() {
this.selectedId = '';
const modelName = this.resolveWireModel();
if (this.$wire && modelName) {
this.$wire.set(modelName, '');
}
}
resolveWireModel() {
// Handles wire:model and wire:model.live / wire:model.blur / wire:model.debounce.300ms
const attr = Array.from(this.$el.attributes).find(a =>
a.name.startsWith('wire:model')
);
return attr?.value ?? null;
}
get selectedIcon() {
return this.allIcons.find(i => i.id === this.selectedId) ?? null;
}
onSearch() {
this.chunkCount = 1;
this.activeIconIndex = -1;
}
Null guard on $wire.set(): If the component is placed without wire:model, or $wire is unavailable (not inside a Livewire component), the .set() call is skipped gracefully with no JS exception.
wire:model modifier support: resolveWireModel() searches for any attribute starting with wire:model — handles .live, .blur, .debounce.* modifiers correctly.
Simple, linear keyboard navigation per PRD P0 requirement. No fragile DOM measurement.
| Key | Action |
|---|---|
| Tab | Moves focus naturally (browser behavior; constrained by x-trap) |
| Arrow Down / Arrow Right | Move highlight to next icon in visibleIcons (wrap at end) |
| Arrow Up / Arrow Left | Move highlight to previous icon in visibleIcons (wrap at start) |
| Enter | Select highlighted icon |
| Escape | Close dropdown, return focus to trigger |
Implementation:
onKeydown(event) {
if (!this.isOpen) return;
const total = this.visibleIcons.length;
if (total === 0) return;
switch (event.key) {
case 'ArrowDown':
case 'ArrowRight':
event.preventDefault();
this.activeIconIndex = (this.activeIconIndex + 1) % total;
this.scrollActiveIntoView();
break;
case 'ArrowUp':
case 'ArrowLeft':
event.preventDefault();
this.activeIconIndex = (this.activeIconIndex - 1 + total) % total;
this.scrollActiveIntoView();
break;
case 'Enter':
event.preventDefault();......
How can I help you explore Laravel packages today?