Skip to content

fix(Carousel): auto-mirror viewport padding to scroll-padding#201

Merged
johnleider merged 6 commits into
masterfrom
feat/carousel-padding
Apr 22, 2026
Merged

fix(Carousel): auto-mirror viewport padding to scroll-padding#201
johnleider merged 6 commits into
masterfrom
feat/carousel-padding

Conversation

@J-Sek
Copy link
Copy Markdown
Contributor

@J-Sek J-Sek commented Apr 21, 2026

fixes #200


Note: lmk if we should add convertToUnit at this point


Playground

<script setup lang="ts">
  import { Carousel, Switch } from '@vuetify/v0'

  const slides = [
    { id: 1, label: 'Mountains', color: 'bg-sky-800 text-white' },
    { id: 2, label: 'Desert', color: 'bg-amber-700 text-white' },
    { id: 3, label: 'Forest', color: 'bg-emerald-800 text-white' },
    { id: 4, label: 'Ocean', color: 'bg-blue-900 text-white' },
  ]

  const items = [
    { id: 1, label: 'Design' },
    { id: 2, label: 'Develop' },
    { id: 3, label: 'Test' },
    { id: 4, label: 'Deploy' },
    { id: 5, label: 'Monitor' },
    { id: 6, label: 'Iterate' },
  ]
</script>

<template>
  <div class="dark bg-neutral-900 text-neutral-100 min-h-screen -m-4 p-4">
    <!-- peek demo with padding="48", and px-12 removed -->
    <section>
      <Carousel.Root :per-view="1" padding="48">
        <Carousel.Viewport class="rounded-lg gap-4 cursor-grab data-[dragging]:cursor-grabbing">
          <Carousel.Item
            v-for="slide in slides"
            :key="slide.id"
            class="flex items-center justify-center h-40 rounded-lg text-lg font-medium flex-[0_0_100%]"
            :class="slide.color"
            :value="slide.id"
          >
            {{ slide.label }}
          </Carousel.Item>
        </Carousel.Viewport>
      </Carousel.Root>
    </section>

    <section>
      <!-- multi-slide demo with padding="48" -->
      <Carousel.Root v-slot="{ isAutoplay, play, stop }" :autoplay="3000" circular :per-view="3" padding="48">
        <Carousel.Viewport class="rounded-lg gap-3 cursor-grab data-[dragging]:cursor-grabbing">
          <Carousel.Item
            v-for="item in items"
            :key="item.id"
            class="flex items-center justify-center h-32 rounded-lg text-sm font-medium bg-stone-800 text-on-surface-variant flex-[0_0_calc((100%-1.5rem)/3)]"
            :value="item.id"
          >
            {{ item.label }}
          </Carousel.Item>
        </Carousel.Viewport>

        <div class="flex items-center justify-center gap-2 mt-3">
          <Carousel.Previous class="px-3 py-1.5 rounded-lg border border-divider text-sm hover:bg-stone-800 disabled:opacity-40">Previous</Carousel.Previous>
          <Carousel.Next class="px-3 py-1.5 rounded-lg border border-divider text-sm hover:bg-stone-800 disabled:opacity-40">Next</Carousel.Next>
        </div>

        <label class="flex items-center justify-center gap-2 mt-3 cursor-pointer">
          <Switch.Root
            class="inline-flex items-center border-none bg-transparent p-0 outline-none"
            :model-value="isAutoplay"
            @update:model-value="$event ? play() : stop()"
          >
            <Switch.Track class="relative inline-flex items-center w-11 h-6 rounded-full bg-stone-800 transition-colors data-[state=checked]:bg-primary">
              <Switch.Thumb class="![visibility:visible] block size-4 rounded-full bg-white shadow-sm transition-transform translate-x-1 data-[state=checked]:translate-x-6"/>
            </Switch.Track>
          </Switch.Root>
          <span class="text-sm">Autoplay</span>
        </label>
      </Carousel.Root>
    </section>

    <section>
      <Carousel.Root :per-view="1" orientation="vertical" padding="48">
        <Carousel.Viewport class="rounded-lg gap-4 cursor-grab data-[dragging]:cursor-grabbing h-[300px]">
          <Carousel.Item
            v-for="slide in slides"
            :key="slide.id"
            class="flex items-center justify-center h-40 rounded-lg text-lg font-medium flex-[0_0_100%]"
            :class="slide.color"
            :value="slide.id"
          >
            {{ slide.label }}
          </Carousel.Item>
        </Carousel.Viewport>
      </Carousel.Root>
    </section>

    <section>
      <!-- padding only on the right/end -->
      <Carousel.Root :per-view="1" padding="0 48px">
        <Carousel.Viewport class="rounded-lg gap-4 cursor-grab data-[dragging]:cursor-grabbing">
          <Carousel.Item
            v-for="slide in slides"
            :key="slide.id"
            class="flex items-center justify-center h-40 rounded-lg text-lg font-medium flex-[0_0_100%]"
            :class="slide.color"
            :value="slide.id"
          >
            {{ slide.label }}
          </Carousel.Item>
        </Carousel.Viewport>
      </Carousel.Root>
    </section>
  </div>
</template>

<style>
section {
  max-width: 1000px;
  margin: 16px auto;
  padding: 16px;
  outline: dashed white;
  border-radius: 12px;
}
</style>

@J-Sek J-Sek self-assigned this Apr 21, 2026
@J-Sek J-Sek added bug Something isn't working C: Carousel labels Apr 21, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 21, 2026

Open in StackBlitz

commit: 74726b1

@J-Sek J-Sek requested a review from johnleider April 22, 2026 11:39
@johnleider
Copy link
Copy Markdown
Member

johnleider commented Apr 22, 2026

Hey @J-Sek — thanks for digging into #200. After some back-and-forth on the approach, I'd like to pivot this PR to an auto-mirror pattern instead of a new prop. Reasoning + the new code below.

Why drop the padding prop

v0's headless contract is strict — see PHILOSOPHY §2.1 "Headless contract is absolute" and §5.3 "Components". Components own logic/ARIA/structure; consumers own all visual styling. Padding falls squarely in the consumer column — we don't expose width, height, gap, margin, or any other CSS-length prop anywhere in the library. Accepting padding on Carousel.Root would be the first break in that wall, and it'd set precedent for the next dimension prop request.

The real problem #200 surfaces isn't "we need a peek API" — it's "scroll-snap math desyncs from visible padding unless scroll-padding-* mirrors padding-*". That's a footgun the component can fix internally without taking on any visual API.

New approach

CarouselViewport now reads its own computed padding-inline-{start,end} (or padding-block-* when vertical) via useResizeObserver + getComputedStyle, and mirrors those values onto scroll-padding-* in its inline style. Consumers write whatever padding they want — UnoCSS class, inline style, stylesheet — and the scroll-snap math follows along automatically. Zero API added, #200 fixed.

The key diff is small (~28 lines in CarouselViewport.vue):

const scrollPaddingStart = shallowRef<string | undefined>()
const scrollPaddingEnd = shallowRef<string | undefined>()

function syncScrollPadding () {
  const element = el.value
  if (!element) {
    scrollPaddingStart.value = undefined
    scrollPaddingEnd.value = undefined
    return
  }
  const cs = getComputedStyle(element)
  const start = isVertical.value ? cs.paddingBlockStart : cs.paddingInlineStart
  const end = isVertical.value ? cs.paddingBlockEnd : cs.paddingInlineEnd
  scrollPaddingStart.value = start && start !== '0px' ? start : undefined
  scrollPaddingEnd.value = end && end !== '0px' ? end : undefined
}

if (IN_BROWSER) {
  useResizeObserver(el, syncScrollPadding, { immediate: true })
  watch(isVertical, syncScrollPadding)
}

And in viewportStyle:

...(scrollPaddingStart.value ? { [`scroll-padding-${axis}-start`]: scrollPaddingStart.value } : {}),
...(scrollPaddingEnd.value ? { [`scroll-padding-${axis}-end`]: scrollPaddingEnd.value } : {}),

CarouselRoot.vue is unchanged from master. The peek.vue docs example is also back to its original px-12 form — the bug is fixed at the component level, so the example just works.

Benefits over the original prop approach

  • No new API surface on Carousel.Root
  • Preserves the headless contract — no precedent-setting visual prop (see §2.1)
  • Asymmetric padding works natively (pr-12, ps-6 pe-12, etc.) without a prop format to debate
  • Any styling mechanism works: UnoCSS classes, inline :style, stylesheets, CSS variables
  • Orientation changes re-read correctly

Playground to verify

Here's a dev/src/Playground.vue covering all four axes/asymmetries so you can confirm the fix still addresses #200:

<script setup lang="ts">
  import { Carousel } from '@vuetify/v0'

  const slides = [
    { id: 1, label: 'Mountains', color: 'bg-sky-800 text-white' },
    { id: 2, label: 'Desert', color: 'bg-amber-700 text-white' },
    { id: 3, label: 'Forest', color: 'bg-emerald-800 text-white' },
    { id: 4, label: 'Ocean', color: 'bg-blue-900 text-white' },
  ]
</script>

<template>
  <div class="dark bg-neutral-900 text-neutral-100 min-h-screen -m-4 p-4">
    <!-- Symmetric peek — padding auto-mirrors to scroll-padding -->
    <section>
      <h2 class="text-sm font-medium mb-2 text-neutral-400">Symmetric peek (px-12)</h2>
      <Carousel.Root :per-view="1">
        <Carousel.Viewport class="rounded-lg gap-4 px-12 cursor-grab data-[dragging]:cursor-grabbing">
          <Carousel.Item
            v-for="slide in slides"
            :key="slide.id"
            class="flex items-center justify-center h-40 rounded-lg text-lg font-medium flex-[0_0_100%]"
            :class="slide.color"
            :value="slide.id"
          >
            {{ slide.label }}
          </Carousel.Item>
        </Carousel.Viewport>
      </Carousel.Root>
    </section>

    <!-- Asymmetric peek — only right side, scroll-padding-inline-end mirrors -->
    <section>
      <h2 class="text-sm font-medium mb-2 text-neutral-400">Asymmetric peek (pr-12)</h2>
      <Carousel.Root :per-view="1">
        <Carousel.Viewport class="rounded-lg gap-4 pr-12 cursor-grab data-[dragging]:cursor-grabbing">
          <Carousel.Item
            v-for="slide in slides"
            :key="slide.id"
            class="flex items-center justify-center h-40 rounded-lg text-lg font-medium flex-[0_0_100%]"
            :class="slide.color"
            :value="slide.id"
          >
            {{ slide.label }}
          </Carousel.Item>
        </Carousel.Viewport>
      </Carousel.Root>
    </section>

    <!-- Vertical peek — padding-block mirrors to scroll-padding-block -->
    <section>
      <h2 class="text-sm font-medium mb-2 text-neutral-400">Vertical peek (py-12)</h2>
      <Carousel.Root :per-view="1" orientation="vertical">
        <Carousel.Viewport class="rounded-lg gap-4 py-12 cursor-grab data-[dragging]:cursor-grabbing h-[300px]">
          <Carousel.Item
            v-for="slide in slides"
            :key="slide.id"
            class="flex items-center justify-center h-40 rounded-lg text-lg font-medium flex-[0_0_100%]"
            :class="slide.color"
            :value="slide.id"
          >
            {{ slide.label }}
          </Carousel.Item>
        </Carousel.Viewport>
      </Carousel.Root>
    </section>

    <!-- No padding — control: should behave exactly as master -->
    <section>
      <h2 class="text-sm font-medium mb-2 text-neutral-400">No padding (control)</h2>
      <Carousel.Root :per-view="1">
        <Carousel.Viewport class="rounded-lg gap-4 cursor-grab data-[dragging]:cursor-grabbing">
          <Carousel.Item
            v-for="slide in slides"
            :key="slide.id"
            class="flex items-center justify-center h-40 rounded-lg text-lg font-medium flex-[0_0_100%]"
            :class="slide.color"
            :value="slide.id"
          >
            {{ slide.label }}
          </Carousel.Item>
        </Carousel.Viewport>
      </Carousel.Root>
    </section>
  </div>
</template>

<style>
section {
  max-width: 1000px;
  margin: 16px auto;
  padding: 16px;
  outline: dashed white;
  border-radius: 12px;
}
</style>

Drop that in, run pnpm dev, and verify sections 1–3 snap cleanly into their respective gutters while section 4 matches master behavior.

Happy to discuss if you'd rather keep the prop — but my read is the auto-mirror is a cleaner fit for v0's constraints. Title should probably change from feat(Carousel): add padding prop to something like fix(Carousel): auto-mirror viewport padding to scroll-padding if we go this route.

Replace the previously-proposed `padding` prop on Carousel.Root with an
internal auto-mirror in CarouselViewport: on resize, read the viewport's
computed padding-{inline,block}-{start,end} via getComputedStyle and
apply matching scroll-padding on the viewport's inline style so CSS
scroll-snap targets align with the visible offset.

Consumers write padding any way they want (UnoCSS class, inline style,
stylesheet, CSS vars) and snap math stays correct without a dedicated
prop. This preserves v0's headless contract — PHILOSOPHY §2.1 — by not
adding a visual-styling prop to a Root component.

Closes #200.
@J-Sek
Copy link
Copy Markdown
Contributor Author

J-Sek commented Apr 22, 2026

Happy to discuss if you'd rather keep the prop

I am OK with mirror. Not something that I am used to, but feels like just fine for v0.

useResizeObserver already gates on SUPPORTS_OBSERVER and isHydrated, and
the sync callback bails on a null element, so the outer IN_BROWSER block
was never load-bearing.
@johnleider johnleider changed the title feat(Carousel): add padding prop and pass it to scroll-padding fix(Carousel): auto-mirror viewport padding to scroll-padding Apr 22, 2026
@J-Sek
Copy link
Copy Markdown
Contributor Author

J-Sek commented Apr 22, 2026

Verified 👍🏼 I cannot approve, because GitHub thinks I am an author. Feel free to merge

@johnleider johnleider merged commit bdc1b02 into master Apr 22, 2026
16 of 17 checks passed
@johnleider johnleider deleted the feat/carousel-padding branch April 22, 2026 17:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working C: Carousel

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Carousel: when trying to set padding on both sides most interactions align slides to the left

2 participants