ScrollArea

Een flexibele scroll container met virtualisatie ondersteuning.

Gebruik

De ScrollArea-component maakt schuifbare containers met optionele virtualisatie voor grote lijsten.

<script setup lang="ts">
const heights = [320, 480, 640, 800]

// Pseudo-random height selection with longer cycle to avoid alignment patterns
function getHeight(index: number) {
  const seed = (index * 11 + 7) % 17
  return heights[seed % heights.length]!
}

const items = Array.from({ length: 1000 }).map((_, index) => {
  const height = getHeight(index)

  return {
    id: index,
    title: `Item ${index + 1}`,
    src: `https://picsum.photos/640/${height}?v=${index}`,
    width: 640,
    height
  }
})
</script>

<template>
  <UScrollArea
    v-slot="{ item, index }"
    :items="items"
    orientation="vertical"
    :virtualize="{
      gap: 16,
      lanes: 3,
      estimateSize: 480
    }"
    class="w-full h-128 p-4"
  >
    <img
      :src="item.src"
      :alt="item.title"
      :width="item.width"
      :height="item.height"
      :loading="index > 8 ? 'lazy' : 'eager'"
      class="rounded-md size-full object-cover"
    >
  </UScrollArea>
</template>

Items

Gebruik de items-prop als een array en rendeer elk item met behulp van de standaardsleuf:

Item 1
Description for item 1
Item 2
Description for item 2
Item 3
Description for item 3
Item 4
Description for item 4
Item 5
Description for item 5
Item 6
Description for item 6
Item 7
Description for item 7
Item 8
Description for item 8
Item 9
Description for item 9
Item 10
Description for item 10
Item 11
Description for item 11
Item 12
Description for item 12
Item 13
Description for item 13
Item 14
Description for item 14
Item 15
Description for item 15
Item 16
Description for item 16
Item 17
Description for item 17
Item 18
Description for item 18
Item 19
Description for item 19
Item 20
Description for item 20
Item 21
Description for item 21
Item 22
Description for item 22
Item 23
Description for item 23
Item 24
Description for item 24
Item 25
Description for item 25
Item 26
Description for item 26
Item 27
Description for item 27
Item 28
Description for item 28
Item 29
Description for item 29
Item 30
Description for item 30
<script setup lang="ts">
const items = Array.from({ length: 30 }, (_, i) => ({
  id: i + 1,
  title: `Item ${i + 1}`,
  description: `Description for item ${i + 1}`
}))
</script>

<template>
  <UScrollArea
    v-slot="{ item, index }"
    :items="items"
    class="w-full h-96"
  >
    <UPageCard
      v-bind="item"
      :variant="index % 2 === 0 ? 'soft' : 'outline'"
      class="rounded-none"
    />
  </UScrollArea>
</template>
U kunt ook de standaardsleuf gebruiken zonder de items-prop om aangepaste schuifbare inhoud rechtstreeks weer te geven.

Oriëntatie

Gebruik de orientation prop om de schuifrichting te wijzigen. Standaard is vertical.

Item 1
Description for item 1
Item 2
Description for item 2
Item 3
Description for item 3
Item 4
Description for item 4
Item 5
Description for item 5
Item 6
Description for item 6
Item 7
Description for item 7
Item 8
Description for item 8
Item 9
Description for item 9
Item 10
Description for item 10
Item 11
Description for item 11
Item 12
Description for item 12
Item 13
Description for item 13
Item 14
Description for item 14
Item 15
Description for item 15
Item 16
Description for item 16
Item 17
Description for item 17
Item 18
Description for item 18
Item 19
Description for item 19
Item 20
Description for item 20
Item 21
Description for item 21
Item 22
Description for item 22
Item 23
Description for item 23
Item 24
Description for item 24
Item 25
Description for item 25
Item 26
Description for item 26
Item 27
Description for item 27
Item 28
Description for item 28
Item 29
Description for item 29
Item 30
Description for item 30
<script setup lang="ts">
defineProps<{
  orientation?: 'vertical' | 'horizontal'
}>()

const items = Array.from({ length: 30 }, (_, i) => ({
  id: i + 1,
  title: `Item ${i + 1}`,
  description: `Description for item ${i + 1}`
}))
</script>

<template>
  <UScrollArea
    v-slot="{ item, index }"
    :items="items"
    :orientation="orientation"
    class="w-full data-[orientation=vertical]:h-96"
  >
    <UPageCard
      v-bind="item"
      :variant="index % 2 === 0 ? 'soft' : 'outline'"
      class="rounded-none"
    />
  </UScrollArea>
</template>

Virtualiseren

Gebruik de virtualize-prop om alleen de items weer te geven die momenteel in beeld zijn, wat de prestaties aanzienlijk verbetert bij het werken met grote datasets.

Wanneer virtualisatie enabled is, past u de spatiëring aan via de virtualize prop-opties zoals gap, paddingStart en paddingEnd. Gebruik anders de ui prop om klassen zoals gap p-4 toe te passen op de viewport-sleuf.
Als al uw items de same height hebben, stel dan skipMeasurement in op true in de virtualize prop om de DOM-meting per item over te slaan en vertrouw in plaats daarvan op estimateSize. Dit verbetert de prestaties voor grote uniforme lijsten aanzienlijk.
<script setup lang="ts">
defineProps<{
  orientation?: 'vertical' | 'horizontal'
}>()

const items = computed(() => Array.from({ length: 1000 }, (_, i) => ({
  id: i + 1,
  title: `Item ${i + 1}`,
  description: `Description for item ${i + 1}`
})))
</script>

<template>
  <UScrollArea
    v-slot="{ item, index }"
    :items="items"
    :orientation="orientation"
    virtualize
    class="w-full data-[orientation=vertical]:h-96 data-[orientation=horizontal]:h-24.5"
  >
    <UPageCard
      v-bind="item"
      :variant="index % 2 === 0 ? 'soft' : 'outline'"
      class="rounded-none"
    />
  </UScrollArea>
</template>
<script setup lang="ts">
import { computed } from 'vue'

defineProps<{
  orientation?: 'vertical' | 'horizontal'
}>()

const items = computed(() => Array.from({ length: 1000 }, (_, i) => ({
  id: i + 1,
  title: `Item ${i + 1}`,
  description: `Description for item ${i + 1}`
})))
</script>

<template>
  <UScrollArea
    v-slot="{ item, index }"
    :items="items"
    :orientation="orientation"
    virtualize
    class="w-full data-[orientation=vertical]:h-96 data-[orientation=horizontal]:h-24.5"
  >
    <UPageCard
      v-bind="item"
      :variant="index % 2 === 0 ? 'soft' : 'outline'"
      class="rounded-none"
    />
  </UScrollArea>
</template>

Schaduw: badge{label="4.9+" class="align-text-top"}

Gebruik de shadow-prop om vervagende schaduwen op de schuifbare randen weer te geven, wat aangeeft dat er meer inhoud beschikbaar is in de schuifrichting. De fade volgt automatisch de orientation en verschijnt alleen wanneer de inhoud overloopt.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam pulvinar risus non risus hendrerit venenatis. Pellentesque sit amet hendrerit risus, sed porttitor quam. Morbi accumsan cursus enim, sed ultricies sapien.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam pulvinar risus non risus hendrerit venenatis. Pellentesque sit amet hendrerit risus, sed porttitor quam. Morbi accumsan cursus enim, sed ultricies sapien.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam pulvinar risus non risus hendrerit venenatis. Pellentesque sit amet hendrerit risus, sed porttitor quam. Morbi accumsan cursus enim, sed ultricies sapien.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam pulvinar risus non risus hendrerit venenatis. Pellentesque sit amet hendrerit risus, sed porttitor quam. Morbi accumsan cursus enim, sed ultricies sapien.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam pulvinar risus non risus hendrerit venenatis. Pellentesque sit amet hendrerit risus, sed porttitor quam. Morbi accumsan cursus enim, sed ultricies sapien.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam pulvinar risus non risus hendrerit venenatis. Pellentesque sit amet hendrerit risus, sed porttitor quam. Morbi accumsan cursus enim, sed ultricies sapien.

<template>
  <div class="max-w-sm bg-elevated/50 rounded-lg">
    <UScrollArea
      shadow
      class="p-4 h-72"
      :ui="{ viewport: 'gap-4' }"
    >
      <p v-for="i in 6" :key="i">
        Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam pulvinar risus non risus hendrerit venenatis. Pellentesque sit amet hendrerit risus, sed porttitor quam. Morbi accumsan cursus enim, sed ultricies sapien.
      </p>
    </UScrollArea>
  </div>
</template>
Geef een object door aan de shadow prop om de fade size te configureren, bijv. :shadow="{ size: 48 }".

Voorbeelden

As metselwerk indeling

Gebruik de virtualize-prop met de opties lanes, gap en estimateSize om metselwerklay-outs in Pinterest-stijl te maken met items met variabele hoogte.

<script setup lang="ts">
withDefaults(defineProps<{
  orientation?: 'vertical' | 'horizontal'
  lanes?: number
  gap?: number
}>(), {
  orientation: 'vertical',
  lanes: 3,
  gap: 16
})

const heights = [320, 480, 640, 800]

function getHeight(index: number) {
  const seed = (index * 11 + 7) % 17
  return heights[seed % heights.length]!
}

const items = Array.from({ length: 1000 }).map((_, index) => {
  const height = getHeight(index)

  return {
    id: index,
    title: `Item ${index + 1}`,
    src: `https://picsum.photos/640/${height}?v=${index}`,
    width: 640,
    height
  }
})
</script>

<template>
  <UScrollArea
    v-slot="{ item }"
    :items="items"
    :orientation="orientation"
    :virtualize="{
      gap,
      lanes,
      estimateSize: 480
    }"
    class="w-full h-128 p-4"
  >
    <img
      :src="item.src"
      :alt="item.title"
      :width="item.width"
      :height="item.height"
      loading="lazy"
      class="rounded-md size-full object-cover"
    >
  </UScrollArea>
</template>
Voor optimale prestaties, zet estimateSize dicht bij uw gemiddelde itemhoogte. Het verhogen van overscan verbetert de soepelheid van het scrollen, maar geeft meer items buiten het scherm weer.

Met responsieve rijstroken

U kunt de useWindowSize (voor viewport-gebaseerde) of useElementSize (voor container-gebaseerde) composables gebruiken om de lanes reactief te maken.

<script setup lang="ts">
const items = Array.from({ length: 1000 }).map((_, index) => ({
  id: index,
  title: `Item ${index + 1}`,
  src: `https://picsum.photos/640/480?v=${index}`,
  width: 640,
  height: 480
}))

const gap = 16
const scrollArea = useTemplateRef('scrollArea')
const { width } = useElementSize(() => scrollArea.value?.$el)

const lanes = computed(() => Math.max(1, Math.min(4, Math.floor(width.value / 200))))
const laneWidth = computed(() => (width.value - (lanes.value - 1) * gap) / lanes.value)
const estimateSize = computed(() => laneWidth.value * (480 / 640))
</script>

<template>
  <UScrollArea
    ref="scrollArea"
    v-slot="{ item }"
    :items="items"
    :virtualize="{
      gap,
      lanes,
      estimateSize,
      skipMeasurement: true
    }"
    class="w-full h-96 p-4"
  >
    <img
      :src="item.src"
      :alt="item.title"
      :width="item.width"
      :height="item.height"
      loading="lazy"
      class="rounded-md size-full object-cover"
    >
  </UScrollArea>
</template>
<script setup lang="ts">
import { computed, useTemplateRef } from 'vue'
import { useElementSize } from '@vueuse/core'

const items = Array.from({ length: 1000 }).map((_, index) => ({
  id: index,
  title: `Item ${index + 1}`,
  src: `https://picsum.photos/640/480?v=${index}`,
  width: 640,
  height: 480
}))

const gap = 16
const scrollArea = useTemplateRef('scrollArea')
const { width } = useElementSize(() => scrollArea.value?.$el)

const lanes = computed(() => Math.max(1, Math.min(4, Math.floor(width.value / 200))))
const laneWidth = computed(() => (width.value - (lanes.value - 1) * gap) / lanes.value)
const estimateSize = computed(() => laneWidth.value * (480 / 640))
</script>

<template>
  <UScrollArea
    ref="scrollArea"
    v-slot="{ item }"
    :items="items"
    :virtualize="{
      gap,
      lanes,
      estimateSize,
      skipMeasurement: true
    }"
    class="w-full h-96 p-4"
  >
    <img
      :src="item.src"
      :alt="item.title"
      :width="item.width"
      :height="item.height"
      loading="lazy"
      class="rounded-md size-full object-cover"
    >
  </UScrollArea>
</template>

Met extern scroll element: badge{label="4.10+" class="align-text-top"}

Geef een getScrollElement-functie door in de virtualize-prop om te virtualiseren tegen een voorouderscrollcontainer in plaats van de eigen viewport van de component. Stel scrollMargin in op de offset van de lijst vanaf het begin van het scroll-element (bijv. de hoogte van de inhoud erboven).

Members

This header scrolls away with the cards, sharing one scrollbar.

0 members
0/0
<script setup lang="ts">
const props = withDefaults(
  defineProps<{
    orientation?: 'vertical' | 'horizontal'
  }>(),
  {
    orientation: 'vertical'
  }
)

type User = {
  id: number
  firstName: string
  lastName: string
  email: string
  image: string
}

const { data: users } = useLazyFetch(
  'https://dummyjson.com/users?limit=100&select=firstName,lastName,email,image',
  {
    key: 'scroll-area-external-scroll-users',
    transform: (data?: { users: User[] }) => data?.users ?? [],
    default: () => [] as User[],
    server: false
  }
)

const isHorizontal = computed(() => props.orientation === 'horizontal')

// The container owns the scroll; the list virtualizes against it so the header and cards share one scrollbar.
const container = useTemplateRef('container')
const title = useTemplateRef('title')
const toolbar = useTemplateRef('toolbar')
const scrollArea = useTemplateRef('scrollArea')

// Item size along the scroll axis: card width when horizontal, row height when vertical.
const itemSize = computed(() => (isHorizontal.value ? 256 : 88))
const getScrollElement = () => container.value

// `scrollMargin` is the title's offset along the scroll axis: its width when it sits left of the cards, its height when it sits above them.
const { width: titleWidth, height: titleHeight } = useElementSize(title, undefined, {
  box: 'border-box'
})
const { height: toolbarHeight } = useElementSize(toolbar, undefined, { box: 'border-box' })
const scrollMargin = computed(() =>
  isHorizontal.value ? titleWidth.value : toolbarHeight.value + titleHeight.value
)

// Find: jump through the items whose name matches the query (like a find toolbar).
const query = ref('')
const matches = computed(() => {
  const q = query.value.trim().toLowerCase()
  if (!q) return []
  return users.value.reduce<number[]>((acc, user, index) => {
    if (`${user.firstName} ${user.lastName}`.toLowerCase().includes(q)) acc.push(index)
    return acc
  }, [])
})
const cursor = ref(0)
const currentMatch = computed(() => matches.value[cursor.value] ?? -1)

function scrollToMatch() {
  if (currentMatch.value < 0) return
  scrollArea.value?.virtualizer?.scrollToIndex(currentMatch.value, {
    align: 'center',
    behavior: 'smooth'
  })
}

function step(delta: number) {
  if (!matches.value.length) return
  cursor.value = (cursor.value + delta + matches.value.length) % matches.value.length
  scrollToMatch()
}

function scrollToStart() {
  container.value?.scrollTo(
    isHorizontal.value ? { left: 0, behavior: 'smooth' } : { top: 0, behavior: 'smooth' }
  )
}

// Re-runs on a new query and when `users` resolves, so the first match centers as soon as results arrive.
watch(matches, () => {
  cursor.value = 0
  scrollToMatch()
})
</script>

<template>
  <div
    ref="container"
    :class="isHorizontal ? 'w-full overflow-x-auto' : 'w-full h-128 overflow-y-auto'"
  >
    <!-- Vertical: the header sits above the toolbar and scrolls away as you scroll down. -->
    <div
      v-if="!isHorizontal"
      ref="title"
      class="flex items-end justify-between gap-4 p-6 bg-elevated/50"
    >
      <div>
        <h2 class="text-2xl font-bold text-highlighted">Members</h2>
        <p class="text-muted">This header scrolls away with the cards, sharing one scrollbar.</p>
      </div>
      <UBadge color="neutral" variant="subtle" :label="`${users.length} members`" />
    </div>

    <div
      ref="toolbar"
      class="z-10 flex items-center px-6 py-3 border-y border-default bg-elevated/50 backdrop-blur"
      :class="isHorizontal ? 'sticky left-0' : 'sticky top-0'"
    >
      <UFieldGroup>
        <UInput
          v-model="query"
          placeholder="Find a member..."
          icon="i-lucide-search"
          aria-describedby="scroll-area-find-count"
          class="w-64"
          :ui="{ trailing: 'pointer-events-none' }"
        >
          <template #trailing>
            <span
              id="scroll-area-find-count"
              class="text-xs text-muted tabular-nums"
              aria-live="polite"
              role="status"
            >
              {{ matches.length ? cursor + 1 : 0 }}/{{ matches.length }}
            </span>
          </template>
        </UInput>
        <UButton
          :icon="isHorizontal ? 'i-lucide-chevron-left' : 'i-lucide-chevron-up'"
          color="neutral"
          variant="outline"
          aria-label="Previous match"
          :disabled="!matches.length"
          @click="step(-1)"
        />
        <UButton
          :icon="isHorizontal ? 'i-lucide-chevron-right' : 'i-lucide-chevron-down'"
          color="neutral"
          variant="outline"
          aria-label="Next match"
          :disabled="!matches.length"
          @click="step(1)"
        />
      </UFieldGroup>

      <UButton
        :icon="isHorizontal ? 'i-lucide-arrow-left-to-line' : 'i-lucide-arrow-up-to-line'"
        color="neutral"
        variant="outline"
        class="ms-auto"
        :label="isHorizontal ? 'Start' : 'Top'"
        @click="scrollToStart"
      />
    </div>

    <!-- Horizontal: the header sits left of the cards (in the row) so it scrolls away with them. -->
    <div :class="isHorizontal && 'flex'">
      <div
        v-if="isHorizontal"
        ref="title"
        class="w-72 shrink-0 flex flex-col justify-center gap-4 p-6 bg-elevated/50 border-r border-default"
      >
        <div>
          <h2 class="text-2xl font-bold text-highlighted">Members</h2>
          <p class="text-muted">This header scrolls away with the cards, sharing one scrollbar.</p>
        </div>
        <UBadge
          color="neutral"
          variant="subtle"
          class="self-start"
          :label="`${users.length} members`"
        />
      </div>

      <UScrollArea
        ref="scrollArea"
        v-slot="{ item, index }"
        :orientation="orientation"
        :items="users"
        :class="isHorizontal && 'h-48 shrink-0'"
        :virtualize="{
          scrollMargin,
          getScrollElement,
          estimateSize: itemSize,
          skipMeasurement: isHorizontal
        }"
      >
        <UPageCard
          class="rounded-none h-full"
          :class="[isHorizontal && 'w-64', index === currentMatch && 'bg-primary/10']"
        >
          <div
            class="flex gap-3 h-full min-w-0"
            :class="
              isHorizontal ? 'flex-col items-center justify-center text-center' : 'items-center'
            "
          >
            <UAvatar
              :src="item.image"
              :alt="item.firstName"
              :size="isHorizontal ? '2xl' : 'lg'"
              loading="lazy"
            />
            <div class="min-w-0">
              <p class="font-medium text-highlighted truncate">
                {{ item.firstName }} {{ item.lastName }}
              </p>
              <p class="text-sm text-muted truncate">
                {{ item.email }}
              </p>
            </div>
          </div>
        </UPageCard>
      </UScrollArea>
    </div>
  </div>
</template>
<script setup lang="ts">
import { ref, computed, watch, useTemplateRef } from 'vue'
import { useElementSize } from '@vueuse/core'

const props = withDefaults(
  defineProps<{
    orientation?: 'vertical' | 'horizontal'
  }>(),
  {
    orientation: 'vertical'
  }
)

type User = {
  id: number
  firstName: string
  lastName: string
  email: string
  image: string
}

const { data: users } = useLazyFetch(
  'https://dummyjson.com/users?limit=100&select=firstName,lastName,email,image',
  {
    key: 'scroll-area-external-scroll-users',
    transform: (data?: { users: User[] }) => data?.users ?? [],
    default: () => [] as User[],
    server: false
  }
)

const isHorizontal = computed(() => props.orientation === 'horizontal')

// The container owns the scroll; the list virtualizes against it so the header and cards share one scrollbar.
const container = useTemplateRef('container')
const title = useTemplateRef('title')
const toolbar = useTemplateRef('toolbar')
const scrollArea = useTemplateRef('scrollArea')

// Item size along the scroll axis: card width when horizontal, row height when vertical.
const itemSize = computed(() => (isHorizontal.value ? 256 : 88))
const getScrollElement = () => container.value

// `scrollMargin` is the title's offset along the scroll axis: its width when it sits left of the cards, its height when it sits above them.
const { width: titleWidth, height: titleHeight } = useElementSize(title, undefined, {
  box: 'border-box'
})
const { height: toolbarHeight } = useElementSize(toolbar, undefined, { box: 'border-box' })
const scrollMargin = computed(() =>
  isHorizontal.value ? titleWidth.value : toolbarHeight.value + titleHeight.value
)

// Find: jump through the items whose name matches the query (like a find toolbar).
const query = ref('')
const matches = computed(() => {
  const q = query.value.trim().toLowerCase()
  if (!q) return []
  return users.value.reduce<number[]>((acc, user, index) => {
    if (`${user.firstName} ${user.lastName}`.toLowerCase().includes(q)) acc.push(index)
    return acc
  }, [])
})
const cursor = ref(0)
const currentMatch = computed(() => matches.value[cursor.value] ?? -1)

function scrollToMatch() {
  if (currentMatch.value < 0) return
  scrollArea.value?.virtualizer?.scrollToIndex(currentMatch.value, {
    align: 'center',
    behavior: 'smooth'
  })
}

function step(delta: number) {
  if (!matches.value.length) return
  cursor.value = (cursor.value + delta + matches.value.length) % matches.value.length
  scrollToMatch()
}

function scrollToStart() {
  container.value?.scrollTo(
    isHorizontal.value ? { left: 0, behavior: 'smooth' } : { top: 0, behavior: 'smooth' }
  )
}

// Re-runs on a new query and when `users` resolves, so the first match centers as soon as results arrive.
watch(matches, () => {
  cursor.value = 0
  scrollToMatch()
})
</script>

<template>
  <div
    ref="container"
    :class="isHorizontal ? 'w-full overflow-x-auto' : 'w-full h-128 overflow-y-auto'"
  >
    <!-- Vertical: the header sits above the toolbar and scrolls away as you scroll down. -->
    <div
      v-if="!isHorizontal"
      ref="title"
      class="flex items-end justify-between gap-4 p-6 bg-elevated/50"
    >
      <div>
        <h2 class="text-2xl font-bold text-highlighted">Members</h2>
        <p class="text-muted">This header scrolls away with the cards, sharing one scrollbar.</p>
      </div>
      <UBadge color="neutral" variant="subtle" :label="`${users.length} members`" />
    </div>

    <div
      ref="toolbar"
      class="z-10 flex items-center px-6 py-3 border-y border-default bg-elevated/50 backdrop-blur"
      :class="isHorizontal ? 'sticky left-0' : 'sticky top-0'"
    >
      <UFieldGroup>
        <UInput
          v-model="query"
          placeholder="Find a member..."
          icon="i-lucide-search"
          aria-describedby="scroll-area-find-count"
          class="w-64"
          :ui="{ trailing: 'pointer-events-none' }"
        >
          <template #trailing>
            <span
              id="scroll-area-find-count"
              class="text-xs text-muted tabular-nums"
              aria-live="polite"
              role="status"
            >
              {{ matches.length ? cursor + 1 : 0 }}/{{ matches.length }}
            </span>
          </template>
        </UInput>
        <UButton
          :icon="isHorizontal ? 'i-lucide-chevron-left' : 'i-lucide-chevron-up'"
          color="neutral"
          variant="outline"
          aria-label="Previous match"
          :disabled="!matches.length"
          @click="step(-1)"
        />
        <UButton
          :icon="isHorizontal ? 'i-lucide-chevron-right' : 'i-lucide-chevron-down'"
          color="neutral"
          variant="outline"
          aria-label="Next match"
          :disabled="!matches.length"
          @click="step(1)"
        />
      </UFieldGroup>

      <UButton
        :icon="isHorizontal ? 'i-lucide-arrow-left-to-line' : 'i-lucide-arrow-up-to-line'"
        color="neutral"
        variant="outline"
        class="ms-auto"
        :label="isHorizontal ? 'Start' : 'Top'"
        @click="scrollToStart"
      />
    </div>

    <!-- Horizontal: the header sits left of the cards (in the row) so it scrolls away with them. -->
    <div :class="isHorizontal && 'flex'">
      <div
        v-if="isHorizontal"
        ref="title"
        class="w-72 shrink-0 flex flex-col justify-center gap-4 p-6 bg-elevated/50 border-r border-default"
      >
        <div>
          <h2 class="text-2xl font-bold text-highlighted">Members</h2>
          <p class="text-muted">This header scrolls away with the cards, sharing one scrollbar.</p>
        </div>
        <UBadge
          color="neutral"
          variant="subtle"
          class="self-start"
          :label="`${users.length} members`"
        />
      </div>

      <UScrollArea
        ref="scrollArea"
        v-slot="{ item, index }"
        :orientation="orientation"
        :items="users"
        :class="isHorizontal && 'h-48 shrink-0'"
        :virtualize="{
          scrollMargin,
          getScrollElement,
          estimateSize: itemSize,
          skipMeasurement: isHorizontal
        }"
      >
        <UPageCard
          class="rounded-none h-full"
          :class="[isHorizontal && 'w-64', index === currentMatch && 'bg-primary/10']"
        >
          <div
            class="flex gap-3 h-full min-w-0"
            :class="
              isHorizontal ? 'flex-col items-center justify-center text-center' : 'items-center'
            "
          >
            <UAvatar
              :src="item.image"
              :alt="item.firstName"
              :size="isHorizontal ? '2xl' : 'lg'"
              loading="lazy"
            />
            <div class="min-w-0">
              <p class="font-medium text-highlighted truncate">
                {{ item.firstName }} {{ item.lastName }}
              </p>
              <p class="text-sm text-muted truncate">
                {{ item.email }}
              </p>
            </div>
          </div>
        </UPageCard>
      </UScrollArea>
    </div>
  </div>
</template>
Omdat de container eigenaar is van de scroll, scrollen de zoek- en "Top" -knoppen van de werkbalk deze rechtstreeks met container.scrollTo.
De shadow prop heeft geen effect in deze modus, omdat de root niet langer de scroll bezit. Pas in plaats daarvan je eigen fade toe op de scroll-container.

Met programmatic scroll

U kunt de blootgestelde virtualizer gebruiken om de scrollpositie programmatisch te regelen.

<script setup lang="ts">
const items = computed(() => Array.from({ length: 1000 }, (_, i) => ({
  id: i + 1,
  title: `Item ${i + 1}`
})))

const scrollArea = useTemplateRef('scrollArea')

const targetIndex = ref(500)

function scrollToTop() {
  scrollArea.value?.virtualizer?.scrollToIndex(0, { align: 'start', behavior: 'smooth' })
}

function scrollToBottom() {
  scrollArea.value?.virtualizer?.scrollToIndex(items.value.length - 1, { align: 'end', behavior: 'smooth' })
}

function scrollToItem(index: number) {
  scrollArea.value?.virtualizer?.scrollToIndex(index - 1, { align: 'center', behavior: 'smooth' })
}
</script>

<template>
  <div class="w-full">
    <UScrollArea
      v-slot="{ item, index }"
      ref="scrollArea"
      :items="items"
      :virtualize="{
        estimateSize: 72,
        skipMeasurement: true
      }"
      class="h-96 w-full"
    >
      <UPageCard
        v-bind="item"
        :variant="index % 2 === 0 ? 'soft' : 'outline'"
        class="rounded-none isolate"
        :class="[index === (targetIndex - 1) && 'bg-primary']"
      />
    </UScrollArea>

    <UFieldGroup size="sm" class="px-4 py-3 border-t border-muted w-full">
      <UButton icon="i-lucide-arrow-up-to-line" color="neutral" variant="outline" @click="scrollToTop">
        Top
      </UButton>
      <UButton icon="i-lucide-arrow-down-to-line" color="neutral" variant="outline" @click="scrollToBottom">
        Bottom
      </UButton>
      <UButton icon="i-lucide-navigation" color="neutral" variant="outline" @click="scrollToItem(targetIndex || 500)">
        Go to {{ targetIndex || 500 }}
      </UButton>
    </UFieldGroup>
  </div>
</template>
<script setup lang="ts">
import { ref, computed, useTemplateRef } from 'vue'

const items = computed(() => Array.from({ length: 1000 }, (_, i) => ({
  id: i + 1,
  title: `Item ${i + 1}`
})))

const scrollArea = useTemplateRef('scrollArea')

const targetIndex = ref(500)

function scrollToTop() {
  scrollArea.value?.virtualizer?.scrollToIndex(0, { align: 'start', behavior: 'smooth' })
}

function scrollToBottom() {
  scrollArea.value?.virtualizer?.scrollToIndex(items.value.length - 1, { align: 'end', behavior: 'smooth' })
}

function scrollToItem(index: number) {
  scrollArea.value?.virtualizer?.scrollToIndex(index - 1, { align: 'center', behavior: 'smooth' })
}
</script>

<template>
  <div class="w-full">
    <UScrollArea
      v-slot="{ item, index }"
      ref="scrollArea"
      :items="items"
      :virtualize="{
        estimateSize: 72,
        skipMeasurement: true
      }"
      class="h-96 w-full"
    >
      <UPageCard
        v-bind="item"
        :variant="index % 2 === 0 ? 'soft' : 'outline'"
        class="rounded-none isolate"
        :class="[index === (targetIndex - 1) && 'bg-primary']"
      />
    </UScrollArea>

    <UFieldGroup size="sm" class="px-4 py-3 border-t border-muted w-full">
      <UButton icon="i-lucide-arrow-up-to-line" color="neutral" variant="outline" @click="scrollToTop">
        Top
      </UButton>
      <UButton icon="i-lucide-arrow-down-to-line" color="neutral" variant="outline" @click="scrollToBottom">
        Bottom
      </UButton>
      <UButton icon="i-lucide-navigation" color="neutral" variant="outline" @click="scrollToItem(targetIndex || 500)">
        Go to {{ targetIndex || 500 }}
      </UButton>
    </UFieldGroup>
  </div>
</template>

Met oneindig scrollen

U kunt de useInfiniteScroll composable gebruiken om meer gegevens te laden terwijl de gebruiker scrolt.

<script setup lang="ts">
import { useInfiniteScroll } from '@vueuse/core'

type User = {
  id: number
  firstName: string
  lastName: string
  username: string
  email: string
  image: string
}

type UserResponse = {
  users: User[]
  total: number
  skip: number
  limit: number
}

const skip = ref(0)

const { data, status } = useLazyFetch(
  'https://dummyjson.com/users?limit=10&select=firstName,lastName,username,email,image',
  {
    key: 'scroll-area-users-infinite-scroll',
    params: { skip },
    transform: (data?: UserResponse) => {
      return data?.users
    },
    server: false
  }
)

const users = ref<User[]>([])

watch(data, () => {
  users.value = [...users.value, ...(data.value || [])]
})

const scrollArea = useTemplateRef('scrollArea')

onMounted(() => {
  useInfiniteScroll(
    scrollArea.value?.$el,
    () => {
      skip.value += 10
    },
    {
      distance: 200,
      canLoadMore: () => {
        return status.value !== 'pending'
      }
    }
  )
})
</script>

<template>
  <UScrollArea
    ref="scrollArea"
    v-slot="{ item }"
    :items="users"
    :virtualize="{
      estimateSize: 88,
      skipMeasurement: true
    }"
    class="h-96 w-full"
  >
    <UPageCard orientation="horizontal" class="rounded-none">
      <UUser
        :name="`${item.firstName} ${item.lastName}`"
        :description="item.email"
        :avatar="{ src: item.image, alt: item.firstName, loading: 'lazy' as const }"
        size="lg"
      />
    </UPageCard>
  </UScrollArea>

  <UProgress
    v-if="status === 'pending' || status === 'idle'"
    indeterminate
    size="xs"
    class="absolute top-0 inset-x-0 z-1"
    :ui="{ base: 'bg-default' }"
  />
</template>
<script setup lang="ts">
import { ref, watch, onMounted, useTemplateRef } from 'vue'
import { useInfiniteScroll } from '@vueuse/core'

type User = {
  id: number
  firstName: string
  lastName: string
  username: string
  email: string
  image: string
}

type UserResponse = {
  users: User[]
  total: number
  skip: number
  limit: number
}

const skip = ref(0)

const { data, status } = useLazyFetch(
  'https://dummyjson.com/users?limit=10&select=firstName,lastName,username,email,image',
  {
    key: 'scroll-area-users-infinite-scroll',
    params: { skip },
    transform: (data?: UserResponse) => {
      return data?.users
    },
    server: false
  }
)

const users = ref<User[]>([])

watch(data, () => {
  users.value = [...users.value, ...(data.value || [])]
})

const scrollArea = useTemplateRef('scrollArea')

onMounted(() => {
  useInfiniteScroll(
    scrollArea.value?.$el,
    () => {
      skip.value += 10
    },
    {
      distance: 200,
      canLoadMore: () => {
        return status.value !== 'pending'
      }
    }
  )
})
</script>

<template>
  <UScrollArea
    ref="scrollArea"
    v-slot="{ item }"
    :items="users"
    :virtualize="{
      estimateSize: 88,
      skipMeasurement: true
    }"
    class="h-96 w-full"
  >
    <UPageCard orientation="horizontal" class="rounded-none">
      <UUser
        :name="`${item.firstName} ${item.lastName}`"
        :description="item.email"
        :avatar="{ src: item.image, alt: item.firstName, loading: 'lazy' as const }"
        size="lg"
      />
    </UPageCard>
  </UScrollArea>

  <UProgress
    v-if="status === 'pending' || status === 'idle'"
    indeterminate
    size="xs"
    class="absolute top-0 inset-x-0 z-1"
    :ui="{ base: 'bg-default' }"
  />
</template>
Dit voorbeeld gebruikt useLazyFetch met server: false om gegevens over de client op te halen zonder de initiële weergave te blokkeren. De laadstatus controleert op zowel de pending- als de idle-status om een laadindicator voor en tijdens het ophalen weer te geven. Extra pagina 's worden geladen terwijl de gebruiker scrolt.

Met standaard slot

U kunt de standaardsleuf zonder de items-prop gebruiken om aangepaste schuifbare inhoud rechtstreeks weer te geven.

Section 1
Custom content without using the items prop.
Section 2
Custom content without using the items prop.
Section 3
Custom content without using the items prop.
Section 4
Custom content without using the items prop.
Section 5
Custom content without using the items prop.
Section 6
Custom content without using the items prop.
<template>
  <UScrollArea class="h-96 w-full" :ui="{ viewport: 'gap-4 p-4' }">
    <UPageCard title="Section 1" description="Custom content without using the items prop." />
    <UPageCard title="Section 2" description="Custom content without using the items prop." />
    <UPageCard title="Section 3" description="Custom content without using the items prop." />
    <UPageCard title="Section 4" description="Custom content without using the items prop." />
    <UPageCard title="Section 5" description="Custom content without using the items prop." />
    <UPageCard title="Section 6" description="Custom content without using the items prop." />
  </UScrollArea>
</template>

API

Props

Prop Default Type
as
'div'
any

The element or component this component should render as.

orientation
'vertical'
"vertical" | "horizontal"

The scroll direction.

items
T[]

Array of items to render.

virtualize
false
boolean | ScrollAreaVirtualizeOptions

Enable virtualization for large lists.

shadow
false
boolean | { size?: number ; } | undefined

Display fade shadows on the scrollable edges to indicate more content. Pass an object to configure the shadow size (in px).

ui
{ root?: SlotClass; viewport?: SlotClass; item?: SlotClass; }

Slots

Slot Type
default
{ item: T; index: number; virtualItem?: VirtualItem | undefined; } | { item: T; index: 0; }

Uitzendt

Event Type
scroll
[isScrolling: boolean]

Expose

U hebt toegang tot de getypte componentinstantie met useTemplateRef.

<script setup lang="ts">
const scrollArea = useTemplateRef('scrollArea')

// Scroll to a specific item
function scrollToItem(index: number) {
  scrollArea.value?.virtualizer?.scrollToIndex(index, { align: 'center' })
}
</script>

<template>
  <UScrollArea ref="scrollArea" :items="items" virtualize />
</template>

Dit geeft u toegang tot het volgende:

NaamTypeOmschrijving
$elHTMLElementHet wortelelement van de component.
virtualizerRef<Virtualizer> | undefinedDe TanStack Virtual virtualizer-instantie (undefined als virtualisatie is uitgeschakeld).

Thema

app.config.ts
export default defineAppConfig({
  ui: {
    scrollArea: {
      slots: {
        root: 'relative outline-primary/25 focus-visible:outline-3',
        viewport: 'relative flex',
        item: ''
      },
      variants: {
        orientation: {
          vertical: {
            root: 'overflow-y-auto overflow-x-hidden',
            viewport: 'flex-col',
            item: ''
          },
          horizontal: {
            root: 'overflow-x-auto overflow-y-hidden',
            viewport: 'flex-row',
            item: ''
          }
        },
        externalScroll: {
          true: {
            root: 'overflow-visible'
          }
        }
      }
    }
  }
})
vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from '@nuxt/ui/vite'

export default defineConfig({
  plugins: [
    vue(),
    ui({
      ui: {
        scrollArea: {
          slots: {
            root: 'relative outline-primary/25 focus-visible:outline-3',
            viewport: 'relative flex',
            item: ''
          },
          variants: {
            orientation: {
              vertical: {
                root: 'overflow-y-auto overflow-x-hidden',
                viewport: 'flex-col',
                item: ''
              },
              horizontal: {
                root: 'overflow-x-auto overflow-y-hidden',
                viewport: 'flex-row',
                item: ''
              }
            },
            externalScroll: {
              true: {
                root: 'overflow-visible'
              }
            }
          }
        }
      }
    })
  ]
})

Changelog

No recent changes