Add M3's uncontained multi-aspect-ratio carousel

Plan step 23, containment.md § Missing ("Carousel: the uncontained
multi-aspect-ratio layout"). `<x-carousel layout="multi-aspect">` is the
layout M3 added in November 2025: each `<x-carousel-item aspect="…">`
keeps its own ratio at the row's fixed height, held inside M3's 9:16 to
16:9 range (a square by default), with 16dp leading padding, 8dp gaps,
the extra-large corner and uncontained (default) scrolling.

The keyline machinery does not fit it: an Arrangement counts items of
one size each, and every snap position and mask follows from that size.
So the layout is a plain flex row, unmasked, and carousel.js only
measures each item's resting position off the DOM, which keeps the
previous/next buttons, the arrow keys, Home/End and bring-into-view
working. The header and SKILL.md say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 07:14:29 +02:00
co-authored by Claude Opus 5
parent 944431668d
commit bb9473b0b2
6 changed files with 163 additions and 19 deletions
@@ -21,40 +21,67 @@
extra-large corner (28px, CarouselDefaults' item shape) and moved by
`--material-carousel-shift`, both written by resources/js/carousel.js. Without script the
item shows unmasked, at its `item-width`. In a `full-screen` carousel it is none of that: the
item fills the row, edge to edge, with no corner and no mask. Only inside `<x-carousel>`. --}}
item fills the row, edge to edge, with no corner and no mask.
In a `multi-aspect` carousel the item is as wide as its own `aspect` makes it at the row's
height, and nothing masks it: M3's uncontained multi-aspect-ratio layout is for items whose
widths "vary… ranging from a 9:16 minimum to a 16:9 maximum aspect ratio", so `aspect` is
held inside that range (`16/9`, `16:9` or a plain number; a square by default, and ignored in
every other layout, where the keylines size the items)
(docs/reference/m3/components-actions-communication-containment.md § Carousel → Variants,
Specs). Only inside `<x-carousel>`. --}}
@props([
'label' => null,
'aspect' => null,
])
{{-- The layout of the `<x-carousel>` around it: the full-screen one is a vertical row of
edge-to-edge items, which M3 gives no corner and no mask. --}}
edge-to-edge items, which M3 gives no corner and no mask, and the multi-aspect one sizes each
item by its own aspect ratio and masks none of them. --}}
@aware([
'layout' => 'multi-browse',
])
@php
$vertical = $layout === 'full-screen';
$multiAspect = $layout === 'multi-aspect';
// M3's 9:16 minimum and 16:9 maximum. `16/9`, `16:9` and `1.78` all say the same thing.
$ratio = null;
if ($multiAspect) {
$value = $aspect ?? 1;
if (is_string($value) && preg_match('/^\s*([\d.]+)\s*[\/:]\s*([\d.]+)\s*$/', $value, $parts) && (float) $parts[2] > 0) {
$value = (float) $parts[1] / (float) $parts[2];
}
$ratio = round(min(max((float) $value, 9 / 16), 16 / 9), 4);
}
@endphp
<div {{ $attributes
->class([
'focus-ring relative h-full shrink-0 snap-start snap-always focus-visible:-outline-offset-3',
'w-full' => $vertical,
'w-(--material-carousel-slot) max-w-full rounded-corner-xl' => ! $vertical,
'w-auto rounded-corner-xl' => $multiAspect,
'w-(--material-carousel-slot) max-w-full rounded-corner-xl' => ! $vertical && ! $multiAspect,
])
->merge([
->merge(array_filter([
'role' => 'group',
'aria-roledescription' => __('slide'),
'aria-label' => '[material-carousel-position]',
'data-material-carousel-item' => true,
'tabindex' => '0',
]) }}>
'style' => $ratio === null ? null : "aspect-ratio: {$ratio}",
], fn ($value): bool => $value !== null)) }}>
<div
data-material-carousel-surface
@class([
'relative size-full overflow-hidden bg-surface-container-highest text-on-surface',
'rounded-corner-xl translate-x-(--material-carousel-shift) [clip-path:inset(0_var(--material-carousel-inset,0px)_round_var(--md-sys-shape-corner-xl))]' => ! $vertical,
'rounded-corner-xl' => $multiAspect,
'rounded-corner-xl translate-x-(--material-carousel-shift) [clip-path:inset(0_var(--material-carousel-inset,0px)_round_var(--md-sys-shape-corner-xl))]' => ! $vertical && ! $multiAspect,
])
>
<div data-material-carousel-content class="size-full [&>img]:size-full [&>img]:object-cover">
+29 -6
View File
@@ -19,6 +19,12 @@
does.
- `uncontained`: items keep `item-width`; the one cut off at the end narrows as it leaves
HorizontalUncontainedCarousel. No snapping, as Compose's uncontained fling.
- `multi-aspect`: M3's **uncontained multi-aspect-ratio** layout, added November 2025
"same as Uncontained but items vary in width, ranging from a 9:16 minimum to a 16:9 maximum
aspect ratio", and "only use this layout if the items have various widths". Each
`<x-carousel-item aspect="16/9">` keeps its own ratio at the row's fixed `height`, so the
widths follow from the art rather than from a keyline. Uncontained scrolling, 16px of
leading padding, 8px between items, the extra-large corner.
- `full-screen`: one edge-to-edge item at a time, scrolled **vertically** — M3: "this layout
works best with content that is taller than it is wide, and scrolls vertically. It only
works in portrait orientation in compact and medium breakpoints. Don't use this layout in
@@ -26,6 +32,15 @@
the row is never wider than the 840px medium window it is meant for. `item-width` and
`padding` do not apply; `height` is the height of each item, so give it the room a
portrait image wants.
The keyline machinery fits every layout but `multi-aspect`: an Arrangement is a count of
large, medium and small items of **one** size each, and the snap positions and masks it
produces all follow from that one size, so a row of items of different widths has no
arrangement to fit. That layout is therefore a plain flex row — the browser scrolls it and
each item is laid out at its own aspect ratio, unmasked — and only the parts of
resources/js/carousel.js that need no Strategy work on it: the previous and next buttons, the
arrow keys, Home and End, and bringing a clipped item into view, all from resting positions
measured off the DOM rather than computed from keylines.
`item-width` takes pixels or any CSS length. `height` is the items' height (205px, Compose's
sample). `padding` is Compose's `contentPadding` in pixels: the first and last items rest
that far in from the edges while items in between scroll to them. M3's specs table gives
@@ -70,8 +85,9 @@
])
@php
$layout = in_array($layout, ['multi-browse', 'hero', 'uncontained', 'full-screen'], true) ? $layout : 'multi-browse';
$layout = in_array($layout, ['multi-browse', 'hero', 'uncontained', 'multi-aspect', 'full-screen'], true) ? $layout : 'multi-browse';
$vertical = $layout === 'full-screen';
$multiAspect = $layout === 'multi-aspect';
$controls = $controls === null ? null : filter_var($controls, FILTER_VALIDATE_BOOL);
$label ??= __('Carousel');
$scrollerId = 'material-carousel-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
@@ -86,11 +102,12 @@
$preferredWidth = match ($layout) {
'multi-browse', 'uncontained' => $cssLength($itemWidth) ?? '186px',
'hero' => $cssLength($itemWidth),
'full-screen' => null,
'multi-aspect', 'full-screen' => null,
};
// The full-screen item is `h-full w-full`: it takes the row, not a slot.
// The full-screen item is `h-full w-full` and a multi-aspect one is as wide as its own ratio
// makes it: neither takes a slot.
$slotWidth = match (true) {
$vertical => null,
$vertical, $multiAspect => null,
$preferredWidth === null => 'calc(100% - 64px)',
default => $preferredWidth,
};
@@ -114,7 +131,7 @@
// only for uncontained, none for full-screen, which is edge to edge.
$padding = max(0, (float) $padding);
$paddingStart = $vertical ? 0.0 : $padding;
$paddingEnd = $vertical || $layout === 'uncontained' ? 0.0 : $padding;
$paddingEnd = $vertical || $layout === 'uncontained' || $multiAspect ? 0.0 : $padding;
$attributes = $attributes
->class('relative')
@@ -126,6 +143,9 @@
'style' => implode('; ', array_filter([
$preferredWidth ? "--material-carousel-item-width: {$preferredWidth}" : null,
$slotWidth ? "--material-carousel-slot: {$slotWidth}" : null,
// Without keylines there is nothing to anchor the first item in from the edge, so
// the multi-aspect row carries the specs table's leading padding itself.
$multiAspect ? "--material-carousel-pad: {$paddingStart}px" : null,
'--material-carousel-height: '.($cssLength($height) ?? '205px'),
])),
], fn ($value): bool => $value !== null));
@@ -148,7 +168,10 @@
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
'mx-auto h-(--material-carousel-height) max-w-210 snap-y snap-mandatory flex-col gap-4 overflow-x-hidden overflow-y-auto overscroll-y-contain' => $vertical,
'h-[calc(var(--material-carousel-height)+1rem)] gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain py-2' => ! $vertical,
'snap-x snap-mandatory' => ! $vertical && $layout !== 'uncontained',
'ps-(--material-carousel-pad)' => $multiAspect,
// M3's two scrolling modes: snap-scrolling everywhere but the two uncontained
// layouts, which it gives default scrolling.
'snap-x snap-mandatory' => ! $vertical && $layout !== 'uncontained' && ! $multiAspect,
])
>
{!! $slides !!}