滚动区域
用法
ScrollArea组件为大型列表创建可滚动的容器,并提供可选的虚拟化。
<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 prop作为数组,并使用默认插槽渲染每个项目:
<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>
方向
使用orientation属性更改滚动方向。将滚动方向改为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属性仅呈现当前视图中的项目,在处理大型数据集时显著提高性能。
virtualize prop选项(如gap、paddingStart和paddingEnd)自定义间距。否则,使用ui prop在viewport插槽上应用gap p-4等类。virtualize属性中将skipMeasurement设置为true,以跳过每个项目的DOM测量,而依赖于estimateSize。这显著提高了大型统一列表的性能。<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>
阴影:badge{label="4.9+" class="align-text-top"}
使用shadow道具在可滚动边缘显示淡入淡出阴影,表示滚动方向上有更多内容可用。淡入淡出自动跟随orientation,仅在内容溢出时出现。
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来配置渐变大小,例如:shadow="{ size: 48 }"。示例
As砌体布置图
将virtualize道具与lanes、gap和estimateSize选项一起使用,创建具有可变高度项目的Pinterest风格砖石布局。
<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设置为接近平均项目高度。增加overscan可以提高滚动平滑度,但会呈现更多的屏幕外项目。带响应通道
您可以使用useWindowSize(对于基于视口的)或useElementSize(对于基于容器的)组合来使lanes成为响应式的。
<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>
带有外部滚动元素:badge{label="4.10+" class="align-text-top"}
在virtualize prop中传递一个getScrollElement函数,以针对祖先滚动容器而不是组件自己的viewport进行虚拟化。将scrollMargin设置为列表从滚动元素开始的偏移量(例如,其上方内容的高度)。
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道具在此模式下无效,因为根不再拥有卷轴。请对卷轴容器应用您自己的淡入淡出。带有可编程滚动
您可以使用暴露的virtualizer以编程方式控制滚动位置。
<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>
无限滚动
您可以使用useInfiniteScroll组合文件在用户滚动时加载更多数据。
<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和server: false在客户端上获取数据,而不会阻塞初始呈现。加载状态检查pending和idle的状态,以在获取之前和期间显示加载指示符。当用户滚动时,会加载其他页面。带默认插槽
您可以使用不带items属性的默认插槽来直接呈现自定义的可滚动内容。
<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' | 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
| Slot | Type |
|---|---|
default | { item: T; index: number; virtualItem?: VirtualItem | undefined; } | { item: T; index: 0; } |
Emits
| Event | Type |
|---|---|
scroll | [isScrolling: boolean] |
曝光
您可以使用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>
这将使您能够访问以下内容:
| 名称 | 类型 | 描述 |
|---|---|---|
$el | HTMLElement | 组件的根元素。 |
virtualizer | Ref<Virtualizer> | undefined | TanStack Virtual虚拟器实例(如果禁用虚拟化,则为undefined)。 |
Theme
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'
}
}
}
}
}
})
]
})