Weave Code
Code Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Blade Icon Picker Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Blade Icon Picker — Solution Design

Date: 2026-05-08 Source: PRD


1. Architecture Overview

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     │
└────────────────────────────────────────────────────────────┘

2. PHP Classes

2.1 IconPicker\Icons\Icon — Value Object

A 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;
}

2.2 IconPicker\Icons\IconManager — Icon Resolution

Wraps 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.

2.3 IconPicker\View\Components\IconPicker — Blade Component Class

Extends 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.

2.4 IconPicker\IconPickerServiceProvider — Service Provider

namespace 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.


3. Blade View

resources/views/components/icon-picker.blade.php

3.1 Template Structure

[@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"
            >&times;</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)

3.2 Wire:model Integration

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.

3.3 Handled wire:model Modifiers

Livewire 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.

3.4 "No Icon Sets Installed" Empty State

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.


4. Alpine.js Component

resources/js/components/icon-picker.js

4.1 State

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.

4.2 SVG Rendering Strategy

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.

  • Trigger preview: selectedIcon.svg is bound via x-html — shows the selected icon's SVG instantly.
  • Grid icons: 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:

  • Hidden <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.
  • AJAX SVG fetch on scroll: Violates the zero-round-trip constraint. Adds complexity.
  • Embed in JSON payload (chosen): Gzipped SVG strings are compact due to repetition. Truly lazy DOM creation. Zero additional requests. The 26KB total is acceptable for a one-time page load on modern connections.

4.3 Search: Substring Token Matching

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.

4.4 Lazy DOM Rendering

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.

4.5 Core Methods

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.

4.6 Keyboard Navigation

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();......
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
nasirkhan/laravel-sharekit
directorytree/privacy-filter-classifier
directorytree/privacy-filter
datacore/hub-sdk
develia/commons
cuci/prototurk-sdk
cuci/prototurk-sdk-symfony
develia/geo-bundle
dreamzy/livewire-charts
touchestate-sdk/php-sdk
22h/doctrine-garbage-collection-bundle
agtp/agtp-php
agtp/mod-php
splash/sonata-admin
splash/metadata
splash/openapi
splash/scopes
splash/toolkit
testo/output-teamcity
testo/bridge-symfony