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
@@ -510,7 +510,9 @@ An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page,
</x-carousel>
```
A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). `<x-carousel>`: `layout` (`multi-browse` default, `hero`, `uncontained`, `full-screen` — one edge-to-edge item at a time scrolled **vertically**, which M3 gives to compact and medium windows in portrait only, and never to landscape), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, **16** — M3's specs table; leading only for `uncontained`, none for `full-screen`), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). `<x-carousel-item>`: slot is an `<img>` (fills and crops) or an element sized `size-full`; `label` overlays a line of text. A `region` of `slide` groups named "n of m", each item a tab stop and the row itself not one, as M3 asks; from a focused item the arrow keys move one item, Home/End go to the ends and Space/Enter opens one that is not fully in view. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop.
A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). `<x-carousel>`: `layout` (`multi-browse` default, `hero`, `uncontained`, `multi-aspect`, `full-screen` — one edge-to-edge item at a time scrolled **vertically**, which M3 gives to compact and medium windows in portrait only, and never to landscape), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, **16** — M3's specs table; leading only for `uncontained`, none for `full-screen`), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). `<x-carousel-item>`: slot is an `<img>` (fills and crops) or an element sized `size-full`; `label` overlays a line of text; `aspect` is its ratio in a `multi-aspect` carousel. A `region` of `slide` groups named "n of m", each item a tab stop and the row itself not one, as M3 asks; from a focused item the arrow keys move one item, Home/End go to the ends and Space/Enter opens one that is not fully in view. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop.
`layout="multi-aspect"` is M3's uncontained multi-aspect-ratio layout (November 2025): each `<x-carousel-item aspect="16/9">` keeps its own ratio at the row's `height`, held inside M3's 9:16-to-16:9 range, so the widths come from the art. Only use it when the items really do have various widths. It is a plain flex row with uncontained scrolling — no keylines and no masks, since an arrangement of one item size cannot describe it — while the buttons, the arrow keys, Home/End and bring-into-view still work, from resting positions measured off the DOM.
### `<x-chip>`
+48 -6
View File
@@ -44,6 +44,13 @@
* vertically, so the browser's own scroll snap is the whole of it and this script only works out
* where each item comes to rest, for the buttons and the keys.
*
* The uncontained multi-aspect-ratio layout uses none of it either, and for a reason that is in
* the maths: an Arrangement counts large, medium and small items of one size each, and every
* keyline, snap position and mask follows from that one size, so items that each keep their own
* aspect ratio have no arrangement to fit. That layout is a plain flex row the browser scrolls,
* every item laid out at its own ratio and none of them masked; the resting positions the buttons,
* the keys and bring-into-view need are measured off the DOM instead.
*
* Copyright 2023-2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
@@ -803,6 +810,7 @@ document.addEventListener('alpine:init', () => {
maxScroll: 0,
rtl: false,
vertical: false,
measured: false,
frame: null,
target: null,
targetAt: 0,
@@ -862,6 +870,7 @@ document.addEventListener('alpine:init', () => {
state.rtl = style.direction === 'rtl'
state.vertical = root.dataset.materialCarousel === 'full-screen'
state.measured = root.dataset.materialCarousel === 'multi-aspect'
state.items = [...scroller.children]
.filter((element) => element.matches(ITEM))
.map((element) => ({
@@ -888,6 +897,28 @@ document.addEventListener('alpine:init', () => {
return
}
// The uncontained multi-aspect-ratio layout: no strategy, no mask, and each item
// resting where its own width puts it. `scrollLeft` is negative in RTL, so an
// offset is taken from whichever edge the row starts at.
if (state.measured) {
const width = scroller.clientWidth
const leading = parseFloat(style.paddingInlineStart) || 0
const box = scroller.getBoundingClientRect()
const scroll = Math.abs(scroller.scrollLeft)
state.strategy = null
state.maxScroll = Math.max(0, scroller.scrollWidth - width)
state.snaps = state.items.map(({ element }) => {
const item = element.getBoundingClientRect()
return clamp(scroll + (state.rtl ? box.right - item.right : item.left - box.left) - leading, 0, state.maxScroll)
})
this.render()
return
}
const space = scroller.clientWidth
const itemSpacing = parseFloat(style.columnGap) || 0
const padding = Number(root.dataset.padding) || 0
@@ -970,8 +1001,9 @@ document.addEventListener('alpine:init', () => {
cancelAnimationFrame(state.frame)
state.frame = null
// Nothing is masked in the vertical full-screen layout; only the buttons change.
if (state.vertical) {
// Nothing is masked in the full-screen or multi-aspect layouts; only the buttons
// change.
if (state.vertical || state.measured) {
this.buttons()
state.mutations?.takeRecords()
@@ -1092,7 +1124,7 @@ document.addEventListener('alpine:init', () => {
},
scrollToItem(index) {
if (state.snaps[index] === undefined || (!state.vertical && !state.strategy?.valid)) {
if (state.snaps[index] === undefined || (!state.vertical && !state.measured && !state.strategy?.valid)) {
return
}
@@ -1175,9 +1207,19 @@ document.addEventListener('alpine:init', () => {
/** Whether item `index` is not fully in focus, so a press or focus should bring it there. */
isMasked(index) {
return state.vertical
? Math.abs(state.snaps[index] - this.scrollOffset()) > 1
: parseFloat(state.items[index].surface.style.getPropertyValue('--material-carousel-inset')) > 0.5
if (state.vertical) {
return Math.abs(state.snaps[index] - this.scrollOffset()) > 1
}
// Nothing masks a multi-aspect item, so "not fully open" is "cut off by the row".
if (state.measured) {
const row = this.$refs.scroller.getBoundingClientRect()
const item = state.items[index].element.getBoundingClientRect()
return item.left < row.left - 1 || item.right > row.right + 1
}
return parseFloat(state.items[index].surface.style.getPropertyValue('--material-carousel-inset')) > 0.5
},
destroy() {
@@ -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 !!}
@@ -55,6 +55,24 @@
@endforeach
</x-carousel>
BLADE,
'Uncontained, multi-aspect ratio: every item keeps its own shape' => <<<'BLADE'
<x-carousel layout="multi-aspect" label="Clips" height="200">
@foreach ([
['16/9', 'pill', 'bg-primary-container text-on-primary-container'],
['1/1', 'cookie-12', 'bg-tertiary-container text-on-tertiary-container'],
['9/16', 'arch', 'bg-secondary-container text-on-secondary-container'],
['4/3', 'slanted', 'bg-surface-container-highest text-primary'],
['3/4', 'bun', 'bg-primary text-on-primary'],
['16/9', 'very-sunny', 'bg-secondary text-on-secondary'],
] as [$aspect, $shape, $colours])
<x-carousel-item :aspect="$aspect" :label="$aspect">
<div class="grid size-full place-items-center {{ $colours }}">
<x-shape :name="$shape" class="size-20" />
</div>
</x-carousel-item>
@endforeach
</x-carousel>
BLADE,
'Full-screen: one item at a time, scrolled down' => <<<'BLADE'
<x-carousel layout="full-screen" label="Wallpapers" height="320" :controls="true">
@foreach ([