The ScrollArea component creates scrollable containers with optional virtualization for large lists.
gap: 16, paddingStart: 16, paddingEnd: 16) matching the theme. Override via the virtualize prop as needed.<script setup lang="ts">
defineProps<{
orientation?: 'vertical' | 'horizontal'
virtualize?: boolean
lanes?: number
gap?: number
padding?: number
}>()
const items = Array.from({ length: 50 }, (_, i) => ({
id: i + 1,
title: `Item ${i + 1}`,
description: `Description for item ${i + 1}`
}))
</script>
<template>
<UScrollArea
v-slot="{ item }"
:items="items"
:orientation="orientation"
:virtualize="virtualize ? {
lanes: lanes && lanes > 1 ? lanes : undefined,
gap,
paddingStart: padding,
paddingEnd: padding
} : false"
class="h-96 w-full border border-default rounded-lg"
>
<UCard class="h-full overflow-hidden">
<template #header>
<h3 class="font-semibold">
{{ item.title }}
</h3>
</template>
<p class="text-sm text-muted">
{{ item.description }}
</p>
</UCard>
</UScrollArea>
</template>
Use the orientation prop to change the scroll direction. Defaults to vertical.
Description for item 1
Description for item 2
Description for item 3
Description for item 4
Description for item 5
Description for item 6
Description for item 7
Description for item 8
Description for item 9
Description for item 10
Description for item 11
Description for item 12
Description for item 13
Description for item 14
Description for item 15
Description for item 16
Description for item 17
Description for item 18
Description for item 19
Description for item 20
Description for item 21
Description for item 22
Description for item 23
Description for item 24
Description for item 25
Description for item 26
Description for item 27
Description for item 28
Description for item 29
Description for item 30
<script setup lang="ts">
defineProps<{
orientation?: 'vertical' | 'horizontal'
}>()
const items = Array.from({ length: 30 }, (_, i) => ({
id: i + 1,
title: `Item ${i + 1}`,
description: `Description for item ${i + 1}`
}))
</script>
<template>
<UScrollArea
v-slot="{ item }"
:items="items"
:orientation="orientation"
:class="orientation === 'vertical' ? 'h-96 flex flex-col' : 'w-full'"
class="border border-default rounded-lg"
>
<UCard>
<template #header>
<h3 class="font-semibold">
{{ item.title }}
</h3>
</template>
<p class="text-sm text-muted">
{{ item.description }}
</p>
</UCard>
</UScrollArea>
</template>
Use the virtualize prop to render only the items currently in view, significantly boosting performance when working with large datasets.
<script setup lang="ts">
const props = defineProps<{
itemCount?: number
}>()
const items = computed(() => Array.from({ length: props.itemCount || 10000 }, (_, i) => ({
id: i + 1,
title: `Item ${i + 1}`,
description: `Description for item ${i + 1}`
})))
</script>
<template>
<UScrollArea
v-slot="{ item }"
:items="items"
virtualize
class="h-96 w-full border border-default rounded-lg p-4"
>
<UCard class="mb-4">
<template #header>
<h3 class="font-semibold">
{{ item.title }}
</h3>
</template>
<p class="text-sm text-muted">
{{ item.description }}
</p>
</UCard>
</UScrollArea>
</template>
Create masonry (waterfall) layouts with variable height items using lanes. Items are automatically measured and positioned as they render.
<script setup lang="ts">
const items = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
title: `Card ${i + 1}`,
description: i % 3 === 0
? `This is a longer description with more text to demonstrate variable height handling in virtualized lists. Item ${i + 1} has significantly more content than others.`
: `Short description for item ${i + 1}.`
}))
</script>
<template>
<UScrollArea
v-slot="{ item }"
:items="items"
:virtualize="{ estimateSize: 120, lanes: 3, gap: 16, paddingStart: 16, paddingEnd: 16 }"
class="h-96 w-full border border-default rounded-lg"
>
<UCard>
<template #header>
<h3 class="font-semibold">
{{ item.title }}
</h3>
</template>
<p class="text-sm text-muted">
{{ item.description }}
</p>
</UCard>
</UScrollArea>
</template>
estimateSize close to the average item height for better initial rendering performance. Increase overscan for smoother scrolling at the cost of rendering more off-screen items.Implement responsive column/row counts using breakpoints or container width tracking.
<script setup lang="ts">
const { width } = useWindowSize()
const lanes = computed(() => {
if (width.value < 640) return 1
if (width.value < 1024) return 2
return 3
})
</script>
<template>
<UScrollArea :items="items" :virtualize="{ lanes }">
<template #default="{ item }">
<!-- your item content -->
</template>
</UScrollArea>
</template>
For container-based responsive behavior:
<script setup lang="ts">
const scrollArea = ref()
const { width } = useElementSize(scrollArea)
const lanes = computed(() => {
// 2 lanes is the minimum, 6 lanes is the maximum, 300px is the goal width of each lane
return Math.max(2, Math.min(6, Math.floor(width.value / 300)))
})
</script>
<template>
<UScrollArea ref="scrollArea" :items="items" :virtualize="{ lanes }">
<template #default="{ item }">{{ item }}</template>
</UScrollArea>
</template>
useWindowSize for viewport-based or useElementSize for container-based responsive lanes.Use the exposed methods to programmatically control scroll position (requires virtualization):
<script setup lang="ts">
const props = defineProps<{
targetIndex?: number
itemCount?: number
}>()
const items = computed(() => Array.from({ length: props.itemCount || 1000 }, (_, i) => ({
id: i + 1,
title: `Item ${i + 1}`
})))
const scrollArea = useTemplateRef('scrollArea')
function scrollToTop() {
scrollArea.value?.scrollToIndex(0, { align: 'start', behavior: 'smooth' })
}
function scrollToBottom() {
scrollArea.value?.scrollToIndex(items.value.length - 1, { align: 'end', behavior: 'smooth' })
}
function scrollToItem(index: number) {
scrollArea.value?.scrollToIndex(index - 1, { align: 'center', behavior: 'smooth' })
}
</script>
<template>
<div class="space-y-4 w-full">
<UScrollArea
ref="scrollArea"
:items="items"
:virtualize="{ estimateSize: 58 }"
class="h-96 w-full border border-default rounded-lg p-4"
>
<template #default="{ item, index }">
<div
class="p-3 mb-2 rounded-lg border border-default"
:class="index === (targetIndex || 500) - 1 ? 'bg-primary-500/10 border-primary-500/20' : 'bg-elevated'"
>
<span class="font-medium">{{ item.title }}</span>
</div>
</template>
</UScrollArea>
<UFieldGroup size="sm">
<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>
Use @load-more to load more data as the user scrolls (requires virtualization):
<script setup lang="ts">
import { UButton } from '#components'
type Recipe = {
id: number
name: string
image: string
difficulty: string
cuisine: string
rating: number
reviewCount: number
prepTimeMinutes: number
cookTimeMinutes: number
}
type RecipeResponse = {
recipes: Recipe[]
total: number
skip: number
limit: number
}
const skip = ref(0)
const limit = 10
const { data, status, execute } = await useFetch(
'https://dummyjson.com/recipes?limit=10&select=name,image,difficulty,cuisine,rating,reviewCount,prepTimeMinutes,cookTimeMinutes',
{
key: 'scroll-area-recipes-infinite-scroll',
params: { skip, limit },
transform: (data?: RecipeResponse) => {
return data?.recipes
},
lazy: true,
immediate: false
}
)
const recipes = ref<Recipe[]>([])
watch(data, () => {
if (data.value) {
recipes.value = [...recipes.value, ...data.value]
}
})
execute()
function loadMore() {
if (status.value !== 'pending') {
skip.value += limit
}
}
</script>
<template>
<UScrollArea
:items="recipes"
:virtualize="{
estimateSize: 120,
gap: 16,
paddingStart: 16,
paddingEnd: 16,
loadMoreThreshold: 5
}"
class="h-96 w-full"
@load-more="loadMore"
>
<template #default="{ item }">
<UPageCard
:description="`${item.cuisine} • ${item.difficulty}`"
orientation="horizontal"
:ui="{ container: 'lg:flex flex-row' }"
>
<template #header>
<UUser
:name="item.name"
:description="`${item.prepTimeMinutes + item.cookTimeMinutes} min • ${item.reviewCount} reviews`"
:avatar="{ src: item.image, alt: item.name }"
/>
</template>
<UButton color="neutral" variant="subtle" size="xl" class="fit-content justify-self-end">
<UIcon name="i-lucide-star" class="size-3" />
{{ item.rating }}
</UButton>
</UPageCard>
</template>
</UScrollArea>
<UIcon
v-if="status === 'pending'"
name="i-lucide-loader-circle"
class="animate-spin size-5 absolute bottom-4 left-0 right-0 mx-auto"
/>
</template>
@load-more event fires when the user scrolls within loadMoreThreshold items from the end (default: 5). Use a loading flag to prevent multiple simultaneous requests and always use spread syntax ([...items, ...newItems]) for reactive updates.Use the default slot without items for custom scrollable content.
Custom content without using the items prop.
Any content can be placed here and it will be scrollable.
You can mix different components and layouts as needed.
<template>
<UScrollArea class="h-96 w-full border border-default rounded-lg">
<UCard>
<template #header>
<h3 class="font-semibold">
Section 1
</h3>
</template>
<p>Custom content without using the items prop.</p>
</UCard>
<UCard>
<template #header>
<h3 class="font-semibold">
Section 2
</h3>
</template>
<p>Any content can be placed here and it will be scrollable.</p>
</UCard>
<UCard>
<template #header>
<h3 class="font-semibold">
Section 3
</h3>
</template>
<p>You can mix different components and layouts as needed.</p>
</UCard>
</UScrollArea>
</template>
| Prop | Default | Type |
|---|---|---|
as |
|
The element or component this component should render as. |
orientation |
|
The scroll direction. |
items |
Array of items to render. | |
virtualize |
|
Enable virtualization for large lists.
|
ui |
|
| Slot | Type |
|---|---|
default |
|
| Event | Type |
|---|---|
scroll |
|
loadMore |
|
You can access the typed component instance using useTemplateRef.
<script setup lang="ts">
const scrollArea = useTemplateRef('scrollArea')
// Scroll to a specific item
function scrollToItem(index: number) {
scrollArea.value?.scrollToIndex(index, { align: 'center' })
}
</script>
<template>
<UScrollArea ref="scrollArea" :items="items" virtualize />
</template>
This will give you access to the following:
| Name | Type | Description |
|---|---|---|
virtualizer | ComputedRef<Virtualizer | null> | The TanStack Virtual virtualizer instance (null if virtualization is disabled) |
scrollToOffset | (offset: number, options?: ScrollToOptions) => void | Scroll to a specific pixel offset |
scrollToIndex | (index: number, options?: ScrollToOptions) => void | Scroll to a specific item index |
getTotalSize | () => number | Get the total size of all virtualized items in pixels |
measure | () => void | Reset all previously measured item sizes |
getScrollOffset | () => number | Get the current scroll offset in pixels |
isScrolling | () => boolean | Check if the list is currently being scrolled |
getScrollDirection | () => 'forward' | 'backward' | null | Get the current scroll direction |
virtualize set to false will result in a warning message.export default defineAppConfig({
ui: {
scrollArea: {
slots: {
root: 'relative',
viewport: 'relative flex gap-4 p-4',
item: ''
},
variants: {
orientation: {
vertical: {
root: 'overflow-y-auto overflow-x-hidden',
viewport: 'columns-xs flex-col',
item: ''
},
horizontal: {
root: 'overflow-x-auto overflow-y-hidden',
viewport: 'flex-row',
item: 'w-max'
}
}
}
}
}
})
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',
viewport: 'relative flex gap-4 p-4',
item: ''
},
variants: {
orientation: {
vertical: {
root: 'overflow-y-auto overflow-x-hidden',
viewport: 'columns-xs flex-col',
item: ''
},
horizontal: {
root: 'overflow-x-auto overflow-y-hidden',
viewport: 'flex-row',
item: 'w-max'
}
}
}
}
}
})
]
})