johncarter/filament-focal-point-picker
A Filament package that adds an interactive focal point picker to image uploads and media fields. Choose the key area of an image, preview the result, and store focal coordinates so responsive crops keep subjects in frame. Integrates with Filament forms and resources.
Install and confirm the API surface first. The package is small (44 stars, 0 dependents), so treat the source as the documentation:
composer require johncarter-/filament-focal-point-picker
After install, spend five minutes in vendor/johncarter-/filament-focal-point-picker/ before writing any form code:
find vendor/johncarter- -name "*.php" | head -50
# confirm the namespace + field class name, e.g.
grep -rn "class FocalPointPicker" vendor/johncarter-/
The class will almost certainly live under Johncarter\FilamentFocalPointPicker\... and extend Filament's Field, which means it inherits the whole form-component API (->default(), ->live(), ->afterStateHydrated(), ->rules(), etc.) — that's the leverage. If assets are published separately:
php artisan vendor:publish --tag="filament-focal-point-picker-config"
php artisan vendor:publish --tag="filament-focal-point-picker-assets"
php artisan filament:assets
Where to look first: the field's getState() / dehydrateState() behaviour. Everything downstream depends on the serialized shape (an ['x' => 50, 'y' => 50] array vs. a "50% 50%" string). Dump it once and you'll never guess again:
FocalPointPicker::make('focal_point')
->afterStateUpdated(fn ($state) => logger()->info('focal', ['state' => $state])),
First use case: a Post with a hero image. Today your editor uploads a 2400×1600 photo, the card renders it at 400×300 with object-fit: cover, and the subject's head is cropped off. Add one column and one field:
// migration
$table->json('hero_focal_point')->nullable();
// PostResource
FileUpload::make('hero_image')->image()->live()->columnSpan(1),
FocalPointPicker::make('hero_focal_point')
->image(fn (Get $get) => /* URL of the current/temporary upload */)
->columnSpan(1),
Now the editor drags a dot onto the subject and it survives every crop, at every breakpoint, forever. That is the entire value proposition — one field, one CSS declaration, and responsive images stop decapitating people.
Model accessor — never repeat the CSS string in Blade. Centralize the fallback so old records degrade to centre:
// app/Models/Post.php
protected $casts = ['hero_focal_point' => 'array'];
public function getFocalPointCssAttribute(): string
{
$point = $this->hero_focal_point;
$x = is_array($point) ? ($point['x'] ?? 50) : 50;
$y = is_array($point) ? ($point['y'] ?? 50) : 50;
return "{$x}% {$y}%";
}
<img src="{{ $post->hero_image_url }}"
alt="{{ $post->title }}"
class="h-64 w-full object-cover"
style="object-position: {{ $post->focal_point_css }}">
object-position only does anything alongside object-fit: cover (or contain). Put both in one component/utility class so the pair can't drift apart.
Spatie Media Library variant. If you're on medialibrary, keep the focal point in the media model's custom_properties rather than on the parent, so it travels with the image:
$post->addMediaFromRequest('hero')
->withCustomProperties(['focal' => $data['hero_focal_point']])
->toMediaCollection('hero');
// accessor
$focal = $post->getFirstMedia('hero')?->getCustomProperty('focal', ['x' => 50, 'y' => 50]);
Reactive preview instead of save-then-adjust. The most common friction is that the picker needs a URL, and a freshly chosen upload has no persisted URL yet. Wire the upload to ->live() and feed the field a reactive source:
FileUpload::make('hero_image')
->image()
->live()
->afterStateUpdated(fn (Set $set) => $set('hero_focal_point', ['x' => 50, 'y' => 50])),
FocalPointPicker::make('hero_focal_point')
->image(fn (Get $get) => $this->resolvePreviewUrl($get('hero_image'))),
Resetting to centre on re-upload is deliberate: a focal point is meaningless for a different picture.
Wrap it in a reusable field. Once it's used in three resources, extract a static factory so the image-resolution logic lives in one place:
// app/Filament/Fields/HeroFocalPoint.php
public static function make(string $uploadField = 'hero_image'): FocalPointPicker
{
return FocalPointPicker::make('hero_focal_point')
->image(fn (Get $get) => Filament::getCurrentPanel()
->getDisk() && $get($uploadField)
? Storage::disk('public')->url($get($uploadField))
: null)
->helperText('Drag the dot onto the part of the image that must stay visible when cropped.');
}
Consume it in a table column too. Editors want to see the crop in the list view — a small ImageColumn/TextColumn is enough, and it makes reviewing 50 records with bad focal points a two-minute job instead of a 50-click audit.
Generate real crops server-side when the CDN supports it. A focal point is metadata; if your image service accepts a gravity parameter, pass the values through so the bytes are cropped, not just the CSS box:
$url = "https://cdn.example.com/{$path}?fit=crop&w=800&h=600&fp-x={$x}&fp-y={$y}";
For local files, pair it with Intervention Image's fit() + a manual crop offset. Keep this in a service class, not a Blade template.
Verify the value shape before you cast. Whether it dehydrates to array, string, or a "50,50" string determines your migration column type and every consumer. Log it once on a real save; getting this wrong is a 500 on every record.
object-fit is not optional. If the image looks identical no matter where the dot is dragged, the CSS is missing object-fit: cover — not the package.
Unsaved uploads have no stable URL. Anything you pass to ->image() must resolve for temporary files too (livewire-tmp). Resolve through Storage::disk(...)->url() against the same disk the upload field uses, or the preview renders a broken image with no error.
Nullable column, guaranteed fallback. Focal point is an enhancement, not a requirement. null must render as 50% 50% everywhere — new posts, imported posts, posts from before you installed this. Do the fallback in an accessor and you only write it once.
Cache busting. If you bake the focal point into the image URL, changing the dot changes the URL (good). If you only change CSS, the browser is fine but a page-cached HTML fragment is not — flush the fragment/response cache on save.
Pin the version. 44 stars and zero dependents means you are effectively the QA team. "johncarter-/filament-focal-point-picker": "1.*" with a committed composer.lock; check the Filament major in its composer.json before upgrading Filament. If it lags, forking is cheap — the field is one Blade view and a few dozen lines of Alpine.
Read the Blade view when debugging. Panel asset registration and Alpine component names are the usual failure point for small Filament plugins: if the field renders as an empty box, first run php artisan filament:assets and hard-refresh, then check the view for x-data names that might collide with another plugin.
Test it headlessly. Filament's form testing helpers work because it's a normal field:
livewire(EditPost::class, ['record' => $post->id])
->fillForm(['hero_focal_point' => ['x' => 20, 'y' => 80]])
->call('save')
->assertHasNoFormErrors();
expect($post->refresh()->focal_point_css)->toBe('20% 80%');
Don't confuse it with cropping. This stores a coordinate; it does not produce derivative images. Your srcset, your thumbnails, and your og:image still need real dimensions — the focal point only fixes which part is visible once those exist.
Alt text is still your job. A focal point is a cropping hint; accessibility is a separate field. Keep alt mandatory and don't let the picker feel like the image is "done".
How can I help you explore Laravel packages today?