Scrollón
xph0000xUso
El componente ScrollArea crea contenedores desplazables con virtualización opcional para listas grandes.
<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>
Artículos
Utilice el prop items como una matriz y renderice cada elemento utilizando la ranura predeterminada:
<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 para renderizar contenido desplazable personalizado directamente.Orientación
Utilice el prop orientation para cambiar la dirección de desplazamiento. Por defecto 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>
Virtualización
Utilice el prop virtualize para renderizar solo los elementos actualmente a la vista, lo que aumenta significativamente el rendimiento cuando se trabaja con grandes conjuntos de datos.
virtualize como gap, paddingStart y paddingEnd.skipMeasurement a true en el soporte virtualize para omitir la medición DOM por elemento y confiar en estimateSize en su lugar.<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>
Sombra: badge{label="4.9+" class="align-text-top"}
Utilice el soporte shadow para mostrar sombras de desvanecimiento en los bordes desplazables, lo que indica que hay más contenido disponible en la dirección de desplazamiento. El desvanecimiento sigue automáticamente al orientation y solo aparece cuando el contenido se desborda.
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 para configurar el tamaño de desvanecimiento, por ejemplo, :shadow="{ size: 48 }".Ejemplos
As diseño de mampostería
Utilice el soporte virtualize con las opciones lanes, gap y estimateSize para crear diseños de mampostería al estilo de Pinterest con elementos de altura 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 cerca de la altura promedio del elemento. Aumentar overscan mejora la suavidad del desplazamiento pero hace que los elementos estén más fuera de la pantalla.Con carriles responsivos
Puede usar los composables useWindowSize (para visualización basada) o [useElementSizexph110) (para contenedores) para hacer que el lanes sea reactivo.
<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>
Con elemento de desplazamiento externo: badge{label="4.10+" class="align-text-top"}
Pase una función getScrollElement en el prop virtualize para virtualizar contra un contenedor de desplazamiento antepasado en lugar de la propia ventana gráfica del componente. Establezca scrollMargin en el desplazamiento de la lista desde el inicio del elemento de desplazamiento (por ejemplo, la altura del contenido por encima de él).
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 no tiene ningún efecto en este modo, ya que la raíz ya no es propietaria del desplazamiento.Con desplazamiento programático
Puede usar el virtualizer expuesto para controlar programáticamente la posición del desplazamiento.
<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>
Con desplazamiento infinito
Puede utilizar el composable useInfiniteScroll para cargar más datos a medida que el usuario se desplaza.
<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 con server: false para obtener datos del cliente sin bloquear el renderizado inicial. El estado de carga comprueba el estado de pending y idle para mostrar un indicador de carga antes y durante la extracción.Con ranura por defecto
Puede usar la ranura predeterminada sin el soporte items para renderizar contenido desplazable personalizado directamente.
<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 (accesorios)
| 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; } |
Emisiones
| Event | Type |
|---|---|
scroll | [isScrolling: boolean] |
Exposición
Puede acceder a la instancia de componente escrito utilizando 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>
Esto le dará acceso a lo siguiente:
| Nombre | Tipo | Descripción |
|---|---|---|
$elx{lang="ts-type"} (Edición española) | HTMLElementx{lang="ts-type"} (Edición española) | elemento raíz del componente. |
virtualizerx{lang="ts-type"} | Ref<Virtualizer> | undefinedx{lang="ts-type"} | La instancia del virtualizador TanStack (undefined si la virtualización está deshabilitada). |
Temas
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'
}
}
}
}
}
})
]
})