Scrollaire
Utilisation
Le composant ScrollArea crée des conteneurs défilables avec une virtualisation optionnelle pour les grandes listes.
<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>
Éléments
Utilisez le prop items comme un tableau et rendre chaque élément en utilisant l'emplacement par défaut:
<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 pour rendre directement le contenu défilable personnalisé.Orientation
Utilisez la prop orientation pour changer la direction du défilement. Par défaut, 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>
Virtualisation
Utilisez le prop virtualize pour afficher uniquement les éléments actuellement visibles, ce qui augmente considérablement les performances lorsque vous travaillez avec de grands ensembles de données.
virtualize telles que gap, paddingStart et paddingEnd. Sinon, utilisez la prop ui pour appliquer des classes comme gap p-4 sur l'emplacement viewport.skipMeasurement sur true dans la prop virtualize pour ignorer la mesure DOM par élément et utiliser estimateSize à la place.<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"}
Utilisez le prop shadow pour afficher des ombres de fondu sur les bords défilants, indiquant que plus de contenu est disponible dans la direction du défilement. Le fondu suit automatiquement le orientation et n'apparaît que lorsque le contenu déborde.
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 pour configurer la taille du fondu, par exemple :shadow="{ size: 48 }".Exemples
As maçonnerie layout
Utilisez le prop virtualize avec les options lanes, gap et estimateSize pour créer des mises en page de maçonnerie de style Pinterest avec des éléments de hauteur variable.
<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 près de la hauteur moyenne de votre élément. L'augmentation de overscan améliore la fluidité du défilement mais rend plus d'éléments hors écran.Avec voies réactives
Vous pouvez utiliser les composables useWindowSize (pour les fenêtres de visualisation) ou [useElementSizexph110) (pour les conteneurs) pour rendre le lanes réactif.
<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>
Avec élément de défilement externe: badge{label="4.10+" class="align-text-top"}
Passez une fonction getScrollElement dans la prop virtualize pour virtualiser sur un conteneur de défilement ancêtre au lieu de la propre fenêtre d'affichage du composant. Définissez scrollMargin sur le décalage de la liste par rapport au début de l'élément de défilement (par exemple, la hauteur du contenu au-dessus).
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 n'a aucun effet dans ce mode, puisque la racine ne possède plus le scroll.With programmatic scroll
Vous pouvez utiliser le virtualizer exposé pour contrôler la position de défilement par programmation.
<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>
Avec scroll infini
Vous pouvez utiliser le composable useInfiniteScroll pour charger plus de données au fur et à mesure que l'utilisateur fait défiler.
<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 avec server: false pour récupérer des données sur le client sans bloquer le rendu initial.L'état de chargement vérifie à la fois l'état de pending et idle pour afficher un indicateur de chargement avant et pendant la récupération. Les pages supplémentaires sont chargées au fur et à mesure que l'utilisateur fait défiler.With slot par défaut
Vous pouvez utiliser l'emplacement par défaut sans le prop items pour rendre directement le contenu défilable personnalisé.
<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; } |
Emis
| Event | Type |
|---|---|
scroll | [isScrolling: boolean] |
Expose à
Vous pouvez accéder à l'instance du composant typé à l'aide de 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>
Cela vous donnera accès à ce qui suit:
| nom | type | Description |
|---|---|---|
$elx{lang="ts-type"} | HTMLElementx{lang="ts-type"} | L'élément racine du composant. |
virtualizer | Ref<Virtualizer> | undefined | L'instance de virtualisation TanStack (undefined si la virtualisation est désactivée). |
Thème
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'
}
}
}
}
}
})
]
})