From f895b553b65891f4d7fbad0d76f8b7ceac1edb8a Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 05:58:50 +0200 Subject: [PATCH] Leave carousel items unmasked under reduced motion Plan step 12, containment.md C-05. Only the parallax half of M3's rule was honoured: the mask was still written every frame, so items kept growing and shrinking between the keylines. Under reduced motion the inset, the shift and the label fade are now all zero, so every item stays at the strategy's large size and the keylines only decide where the row snaps. `--material-carousel-pin` went with it: with no mask there is nothing to pin the content to. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/js/carousel.js | 26 +++++++++++-------- .../views/components/carousel-item.blade.php | 2 +- resources/views/components/carousel.blade.php | 6 +++-- tests/Browser/CarouselTest.php | 7 +++-- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/resources/js/carousel.js b/resources/js/carousel.js index f02e0fb2..734fdf37 100644 --- a/resources/js/carousel.js +++ b/resources/js/carousel.js @@ -23,9 +23,10 @@ * records. A ResizeObserver does the same for a new width. RTL is read when measuring: * scroll offsets are negative there and shifts mirror, as Compose's `translationX` does. * - * Under reduced motion the buttons and keys scroll instantly, and the content stays pinned to - * the mask's leading edge instead of sliding inside it: the frame still opens and closes with - * the scroll the user makes, but nothing moves on its own. + * Under reduced motion the buttons and keys scroll instantly and nothing is masked at all: M3 + * says the parallax goes and items "should no longer expand as they come into view — all items + * are the same size", so every item stays at the strategy's large size and the keylines only + * decide where the row snaps. * * --------------------------------------------------------------------------------------- * Keyline maths ported from androidx (https://github.com/androidx/androidx), commit @@ -872,7 +873,6 @@ document.addEventListener('alpine:init', () => { .map((element) => ({ element, surface: element.querySelector('[data-material-carousel-surface]'), - content: element.querySelector('[data-material-carousel-content]'), label: element.querySelector('[data-material-carousel-label]'), labelWidth: 0, written: '', @@ -963,7 +963,13 @@ document.addEventListener('alpine:init', () => { const scroll = this.scrollOffset() const keylines = keylinesForScrollOffset(strategy, scroll, state.maxScroll) const size = strategy.itemSize - const pinned = state.reducedMotion.matches + + // M3: "When reduced motion settings are turned on, the parallax effect should be + // removed and carousel items should no longer expand as they come into view. All + // items are the same size" (docs/reference/m3/styles.md § Motion → Accessibility + // requirements). The keylines still decide where the row snaps; nothing is masked, + // moved or faded, so every item stays at strategy.itemSize. + const still = state.reducedMotion.matches state.items.forEach((item, index) => { const center = index * (size + strategy.itemSpacing) + size / 2 - scroll @@ -978,11 +984,10 @@ document.addEventListener('alpine:init', () => { translation += (center - keyline.unadjustedOffset) / keyline.size } - const inset = clamp((size - keyline.size) / 2, 0, size / 2) - const shift = state.rtl ? -translation : translation - const pin = pinned ? (state.rtl ? -inset : inset) : 0 - const opacity = item.labelWidth > 0 ? clamp((item.labelWidth - size + keyline.size) / item.labelWidth, 0, 1) : 1 - const written = `${inset.toFixed(2)}|${shift.toFixed(2)}|${pin.toFixed(2)}|${opacity.toFixed(3)}` + const inset = still ? 0 : clamp((size - keyline.size) / 2, 0, size / 2) + const shift = still ? 0 : state.rtl ? -translation : translation + const opacity = still || item.labelWidth === 0 ? 1 : clamp((item.labelWidth - size + keyline.size) / item.labelWidth, 0, 1) + const written = `${inset.toFixed(2)}|${shift.toFixed(2)}|${opacity.toFixed(3)}` if (written === item.written) { return @@ -993,7 +998,6 @@ document.addEventListener('alpine:init', () => { item.written = written item.surface.style.setProperty('--material-carousel-inset', `${inset.toFixed(2)}px`) item.surface.style.setProperty('--material-carousel-shift', `${shift.toFixed(2)}px`) - item.surface.style.setProperty('--material-carousel-pin', `${pin.toFixed(2)}px`) item.surface.style.setProperty('--material-carousel-label-shift', `${(state.rtl ? -inset : inset).toFixed(2)}px`) item.surface.style.setProperty('--material-carousel-label', opacity.toFixed(3)) }) diff --git a/resources/views/components/carousel-item.blade.php b/resources/views/components/carousel-item.blade.php index 81b2f05d..3d5eb0b8 100644 --- a/resources/views/components/carousel-item.blade.php +++ b/resources/views/components/carousel-item.blade.php @@ -34,7 +34,7 @@ data-material-carousel-surface class="relative size-full overflow-hidden rounded-corner-xl bg-surface-container-highest text-on-surface translate-x-(--material-carousel-shift) [clip-path:inset(0_var(--material-carousel-inset,0px)_round_var(--md-sys-shape-corner-xl))]" > -