When an image is rendered into a slot whose aspect ratio doesn't match the image's own — a wide hero crop of a portrait headshot, a square card thumbnail of a landscape photo — the browser has to decide which part of the image to keep and which to crop. By default it keeps the centre. That works for landscapes; it cuts the head off people.
The focal point feature lets editors mark the important spot on an image — typically a face — once. Every time that image is rendered with object-fit: cover and a smaller aspect ratio than the original, the crop anchors to the marked point instead of the centre. One setting, one image, every render path respects it automatically.
The problem
CMSes that auto-crop images for cards and thumbnails almost always crop to centre. For most photos that's fine — but for any photo where the subject is off-centre (a person standing on the left of a landscape shot, a product on the right of a banner), centre-crop deletes the subject.
Workarounds editors typically reach for:
- Re-crop the source image in Photoshop and re-upload it for every aspect ratio the site uses. Now the same photo lives in three places and any future change has to be done three times.
- Add a per-spot image override so each card on each page can pick a separately-uploaded version. Multiplies the editor workload and breaks the "one image, one alt" invariant.
- Switch from
object-covertoobject-contain, which keeps the whole image but introduces letterboxing and ruins the visual rhythm of the grid.
None of these scale. The right answer is a single piece of metadata on the image — "the important point is here" — that every render path reads automatically.
The fix
Two integer columns on media_items (focal_x, focal_y, both 0–100, both nullable) record the focal point as a percentage offset from the top-left of the image. The model exposes a focalStyle() accessor that returns a style="object-position: X% Y%" string when set, or an empty string when unset. Every component and view that renders a media item with object-cover reads that style.
When the focal point is unset, nothing changes — the rendered HTML has no style attribute and the browser uses the default object-position: 50% 50%. The feature is opt-in per image; the default behaviour is identical to a system without focal point support.
Editors set the focal point by clicking on the image preview in two places:
- The media library detail panel
- The media library picker (used inside every entity edit form and the page editor)
A reset button clears it back to centre. The feature is per-image — once set, every consumer of that image picks it up automatically. There are no per-spot overrides.
How it works
The two columns
A migration adds focal_x and focal_y to media_items as nullable unsignedTinyInteger. The application layer caps them at 0–100 (percentage), well within tinyint's 0–255 range. Null in both columns means "no focal point set" — the rendered output drops the style attribute entirely.
$table->unsignedTinyInteger('focal_x')->nullable()->after('height');
$table->unsignedTinyInteger('focal_y')->nullable()->after('focal_x');
The accessor
MediaItem::focalStyle() returns the rendered CSS or an empty string:
public function focalStyle(): string
{
return self::focalStyleFromXY($this->focal_x, $this->focal_y);
}
private static function focalStyleFromXY(?int $x, ?int $y): string
{
if ($x === null && $y === null) {
return '';
}
$x = max(0, min(100, (int) ($x ?? 50)));
$y = max(0, min(100, (int) ($y ?? 50)));
return 'object-position: '.$x.'% '.$y.'%';
}
For paths that haven't been hydrated into a model — typical of design-library row image fields where the value stored in content_overrides is just a string path — there's a path-keyed equivalent that reads from the cached metadata map:
MediaItem::focalStyleForPath($path)
Both clamp out-of-range values defensively.
Path-keyed cache: zero queries on the public render path
MediaItem::metaByPathCached() is a forever-cached array keyed by storage path, holding width, updated_at_ts, focal_x, and focal_y. The cache is populated lazily by a single SELECT on first access and held until any media_items row is saved or deleted (the existing booted hooks already forget the key).
The public render path goes through GlideUrl::focalStyleFromResolved() which extracts the storage path from a resolved URL (handling the ?v= cache buster suffix) and hands off to MediaItem::focalStyleForPath(). End result: rendering a page with focal-pointed images costs zero DB queries beyond what the page already issued.
The picker UI
The picker is a small Alpine component, registered in resources/js/app.js as Alpine.data('focalPicker', ...). It reads its initial state from a data-state JSON attribute (per the project rule against <script> blocks in Volt components) and exposes:
setFocal(event)— converts a click on the image into 0–100 X/Y, calls$wire.call(wireSave, id, x, y)reset()— calls$wire.call(wireClear, id)position()— Alpine-side computed style for the marker overlaymarkerStyle()— same, for live preview
The Alpine instance lives inside a wire:ignore container so morphdom never resets the marker position during Livewire round-trips.
The shared partial resources/views/partials/media-focal-picker.blade.php takes the image, the URL, and the names of the two wire methods to call. Both consumers — the library index detail panel and the picker modal — @include it.
Two pairs of wire methods
The detail panel and the picker each define their own pair, identical in shape and clamping:
| Component | Save | Clear |
|---|---|---|
pages::dashboard.media-library.index |
updateFocal(id, x, y) |
clearFocal(id) |
pages::dashboard.media-library.picker |
saveFocal(id, x, y) |
saveFocalClear(id) |
Both clamp X and Y to 0–100 server-side, persist via MediaItem::query()->update(), and unset($this->images) to refresh the rendered grid.
Render-path coverage
Every render path that places a media item into a fixed-aspect slot reads the focal style:
| Path | Source |
|---|---|
x-dl.image |
GlideUrl::focalStyleFromResolved($imgSrc) (path lookup) |
x-dl.media |
Same |
x-dl.gallery |
$item->focalStyle() (model access) |
x-dl.slider |
Same |
Blog and locations are content types, so their show/index pages render through the same x-dl.image / x-dl.media / x-dl.gallery components above — no separate code path.
When the focal style is empty (the default), the component's @if($style) style="..." @endif guard drops the attribute entirely — the rendered HTML is byte-identical to a system without focal point support.
Cache invalidation
Three layers stay coherent automatically:
- The path-keyed metadata cache is forgotten by the existing
saved/deletedhooks onMediaItemwhenever any focal column changes. - The Spatie response cache is cleared by the same hooks, so any cached HTML containing the old focal style is invalidated.
- Browser-level caching rides on
MediaItem::url()'s?v={updated_at}cache buster — saving a focal change bumpsupdated_at, so the next render emits a different URL and browsers fetch fresh.
There is no manual cache step the editor has to take. Setting a focal point and refreshing the page is enough.
What lives where
| Path | Purpose |
|---|---|
database/migrations/2026_05_09_173445_add_focal_point_to_media_items_table.php |
Adds focal_x / focal_y columns. |
app/Models/MediaItem.php |
focalStyle(), focalStyleForPath(), cached metadata map. |
app/Support/Media/GlideUrl.php |
focalStyleFromResolved() — for design library row image fields stored as paths. |
resources/js/app.js |
Alpine.data('focalPicker', ...) registration. |
resources/views/partials/media-focal-picker.blade.php |
Shared picker UI partial. |
resources/views/pages/dashboard/media-library/⚡index.blade.php |
updateFocal / clearFocal wire methods + partial include. |
resources/views/pages/dashboard/media-library/⚡picker.blade.php |
saveFocal / saveFocalClear wire methods + partial include. |
resources/views/components/dl/image.blade.php, media.blade.php, gallery.blade.php, slider.blade.php |
Focal style applied to <img> rendering. |
Notes
- Per-image, not per-spot. A focal point is metadata on the image, not on the spot it's placed in. Setting the face of a headshot once means every card, banner, and gallery on the site that crops it crops to the face. There is no per-instance override and no plan to add one — the media library doc explains the same invariant for alt text.
- Focal point vs. cropping — when to use which. A focal point is non-destructive: it keeps the full original and lets the same image crop differently into each aspect ratio (wide hero, square card, portrait thumb) without losing pixels. The media library Crop tool is destructive: it permanently trims the original and applies that one framing everywhere. Reach for the focal point first; crop only when the image genuinely needs to lose the trimmed area site-wide. (Cropping resets the focal point, since the stored 0–100 coords no longer map onto the recropped frame.)
- Default is unchanged. A system with focal point support and zero focal points set is byte-identical to a system without the feature. The
style="object-position: …"attribute is only emitted when the editor has explicitly set one. - Click to set, click to reset. The editor clicks anywhere on the image preview to place the marker; one button (Reset) clears it. There's no draggable marker, no slider widget, no modal step.
- Server-side clamping is defensive. The Alpine UI already produces 0–100, but every wire method clamps independently — protocols do drift, and a
tinyintoverflow at the DB level would surface as a confusing 500 response rather than a clean accept-and-clamp. - Works with Glide variants out of the box.
object-positionis applied to the<img>element after the browser has loaded whichever Glide-resized variant matches the slot. The focal point applies equally to a 552px mobile crop and a 1104px hi-DPI desktop crop — the rule is in CSS percentages, not absolute pixels.