Why Roll Your Own Filament Field?
Filament v3 ships with a rich field library, but product teams inevitably hit requirements that fall outside it — a star-rating input, a drag-rank list, or in our case a colour-swatch picker where users choose from a curated brand palette rather than an arbitrary hex string. Building this as a proper Field subclass (not a raw Blade hack) means you get schema-level validation, ->disabled(), ->live(), ->hint(), and every other Filament modifier for free.
Scaffolding the Field Class
Filament custom fields extend Filament\Forms\Components\Field. The minimum surface area is:
<?php
namespace App\Forms\Components;
use Filament\Forms\Components\Field;
class ColourSwatchPicker extends Field
{
protected string $view = 'forms.components.colour-swatch-picker';
/** @var array<string, string> label => hex */
protected array $swatches = [];
public function swatches(array $swatches): static
{
$this->swatches = $swatches;
return $this;
}
public function getSwatches(): array
{
return $this->swatches;
}
}
Keep the class thin. Business logic lives in the view component; the field class is just a typed configuration bag.
The Blade View
Filament field views receive $field (the component instance) and $state (current wire model value). Use Alpine for the click interaction — no extra Livewire round-trips needed for UI feedback.
<x-dynamic-component :component="$field->getFieldWrapperView()" :field="$field">
<div
x-data="{ selected: @entangle($attributes->wire('model')) }"
class="flex flex-wrap gap-3"
>
@foreach ($field->getSwatches() as $label => $hex)
<button
type="button"
:class="selected === '{{ $hex }}'
? 'ring-2 ring-offset-2 ring-primary-500 scale-110'
: 'opacity-70 hover:opacity-100'"
class="w-8 h-8 rounded-full transition-all"
style="background-color: {{ $hex }}"
x-on:click="selected = '{{ $hex }}'"
title="{{ $label }}"
></button>
@endforeach
</div>
</x-dynamic-component>
@entangle binds the Alpine selected variable directly to the Livewire wire model, so Filament's state management — including ->live() reactive updates — works without any custom JavaScript.
Registering and Using the Field
use App\Forms\Components\ColourSwatchPicker;
ColourSwatchPicker::make('brand_colour')
->label('Brand Colour')
->swatches([
'Midnight' => '#1a1a2e',
'Crimson' => '#e63946',
'Sage' => '#606c38',
'Sand' => '#dda15e',
])
->required()
->live()
->afterStateUpdated(fn ($state, Set $set) =>
$set('preview_hex', $state)
),
Because we extend Field, ->required() wires straight into Filament's validation pipeline — no custom rules() override needed for basic presence checks.
Adding a Custom Validation Rule
If you want to restrict values to the declared palette:
public function rules(mixed $component): array
{
return [
function (string $attribute, mixed $value, Closure $fail) use ($component) {
if (! in_array($value, $component->getSwatches(), strict: true)) {
$fail('Please select a colour from the palette.');
}
},
];
}
Override rules() on the field class and return an array of Laravel rule objects or closures — Filament merges them with its own validation pass automatically.
Dark-Mode Consideration
The ring-primary-500 Tailwind class already respects Filament's dark-mode token system. If your swatches include very light colours, add a conditional border:
:class="selected === '{{ $hex }}'
? 'ring-2 ring-offset-2 ring-primary-500'
: 'border border-gray-200 dark:border-gray-700'"
Takeaways
- Extend
Filament\Forms\Components\Field— not a raw Livewire component — to inherit the full modifier API. - Use
@entanglein the Blade view to bind Alpine state to Livewire without custom JS. - Keep the PHP class as a typed config bag; put rendering logic in the view.
- Override
rules()to add palette-restriction validation that integrates with Filament's pipeline. - Dark-mode support is free when you use Filament's Tailwind design tokens.