Scrollarea
Bearbeiten
Die ScrollArea-Komponente erstellt scrollbare Container mit optionaler Virtualisierung für große Listen.
<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>
Einträge
Verwenden Sie die items-Prop als Array und rendern Sie jedes Element mit dem Standard-Slot:
<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>
items-Prop verwenden, um benutzerdefinierte scrollbare Inhalte direkt zu rendern.Ausrichtung
Verwenden Sie die orientation-prop, um die Scrollrichtung zu ändern. Standardmäßig ist vertical.
<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>
Virtualize Bearbeiten
Verwenden Sie die virtualize-Prop, um nur die aktuell angezeigten Elemente zu rendern, wodurch die Leistung bei der Arbeit mit großen Datensätzen erheblich gesteigert wird.
virtualize-Prop-Optionen wie gap, paddingStart und paddingEnd an.skipMeasurement auf true in der virtualize-Prop, um die DOM-Messung pro Element zu überspringen und sich stattdessen auf estimateSize zu verlassen.<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>
Shadow: badge{label="4.9+" class="align-text-top"} (englisch).
Verwenden Sie die shadow prop, um Fade-Schatten an den scrollbaren Kanten anzuzeigen, was darauf hinweist, dass mehr Inhalt in der Scroll-Richtung verfügbar ist. Die Fade folgt automatisch der orientation und erscheint nur, wenn der Inhalt überläuft.
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>
shadow-Prop, um die Fade-Größe zu konfigurieren, z. B. :shadow="{ size: 48 }".Examples Bearbeiten
As Maurer-Layout
Verwenden Sie die virtualize-Stütze mit den Optionen lanes, gap und estimateSize, um Pinterest-Stil-Mauerwerkslayouts mit Elementen mit variabler Höhe zu erstellen.
<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>
estimateSize nahe an Ihre durchschnittliche Artikelhöhe. overscan zu erhöhen verbessert die Laufruhe beim Scrollen, rendert aber mehr Elemente außerhalb des Bildschirms.With responsive lanes (Antwortspuren)
Sie können die useWindowSize (für Viewport-basierte) oder [useElementSize](https://vueuse.org/core/useElementSize/xph12x (für Container-basierte) Composites verwenden, um die lanes reaktiv zu machen.
<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>
Mit externem Scroll-Element: badge{label="4.10+" class="align-text-top"}
Übergeben Sie eine getScrollElement-Funktion in der virtualize-Prop, um gegen einen Vorgänger-Scroll-Container anstelle des eigenen Viewports der Komponente zu virtualisieren. Setzen Sie scrollMargin auf den Offset der Liste vom Start des Scroll-Elements (z. B. die Höhe des Inhalts darüber).
Members
This header scrolls away with the cards, sharing one scrollbar.
<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>container.scrollTo.shadow-Prop hat in diesem Modus keine Wirkung, da der Root nicht mehr den Scroll besitzt.Mit Programmatic Scroll
Sie können die exponierte virtualizer verwenden, um die Bildlaufposition programmgesteuert zu steuern.
<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>
Mit unendlichem Scrollen
Sie können das Composable useInfiniteScroll verwenden, um mehr Daten zu laden, während der Benutzer scrollt.
<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>useLazyFetch mit server: false verwendet, um Daten auf dem Client abzurufen, ohne das anfängliche Rendern zu blockieren. Der Ladezustand überprüft sowohl den pending-als auch den idle-Status, um eine Ladeanzeige vor und während des Abrufs anzuzeigen. Zusätzliche Seiten werden geladen, wenn der Benutzer scrollt.Mit Default Slot
Sie können den Standard-Slot ohne die items-Prop verwenden, um benutzerdefinierte scrollbare Inhalte direkt zu rendern.
<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 (englisch)
Props Bearbeiten
| Prop | Default | Type |
|---|---|---|
as | 'div' | anyThe 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 ; } | undefinedDisplay 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 (englisch)
| Slot | Type |
|---|---|
default | { item: T; index: number; virtualItem?: VirtualItem | undefined; } | { item: T; index: 0; } |
Emits (englisch)
| Event | Type |
|---|---|
scroll | [isScrolling: boolean] |
Expose (englisch)
Sie können auf die typisierte Komponenteninstanz mit useTemplateRef zugreifen.
<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>
Dies gibt Ihnen Zugang zu den folgenden:
| Vorname | Typ | Description |
|---|---|---|
$el (nicht) | HTMLElement (nicht) | Die Wurzel der Komponente. |
virtualizer nicht | Ref<Virtualizer> | undefined (nicht) | Die Virtualizer-Instanz TanStack Virtual (undefined, wenn die Virtualisierung deaktiviert ist). |
Theme Bearbeiten
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'
}
}
}
}
}
})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'
}
}
}
}
}
})
]
})