Use the ScrollArea component to create scrollable containers with optional virtualization for large lists.
lanes) are configured via the virtualize prop<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
: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"
>
<template #default="{ item }">
<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>
</template>
</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
:items="items"
:orientation="orientation"
:class="orientation === 'vertical' ? 'h-96 flex flex-col' : 'w-full'"
class="border border-default rounded-lg p-4"
:ui="{ viewport: 'p-0' }"
>
<template #default="{ item }">
<UCard>
<template #header>
<h3 class="font-semibold">
{{ item.title }}
</h3>
</template>
<p class="text-sm text-muted">
{{ item.description }}
</p>
</UCard>
</template>
</UScrollArea>
</template>
Enable virtualization to render only visible items, dramatically improving performance with large lists.
<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
:items="items"
virtualize
class="h-96 w-full border border-default rounded-lg p-4"
>
<template #default="{ item }">
<UCard class="mb-4">
<template #header>
<h3 class="font-semibold">
{{ item.title }}
</h3>
</template>
<p class="text-sm text-muted">
{{ item.description }}
</p>
</UCard>
</template>
</UScrollArea>
</template>
Provide estimateSize (average height) for better initial rendering. Items are automatically measured 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
:items="items"
:virtualize="{ estimateSize: 120, lanes: 3, gap: 12, paddingStart: 12, paddingEnd: 12 }"
class="h-96 w-full border border-default rounded-lg"
>
<template #default="{ item }">
<UCard>
<template #header>
<h3 class="font-semibold">
{{ item.title }}
</h3>
</template>
<p class="text-sm text-muted">
{{ item.description }}
</p>
</UCard>
</template>
</UScrollArea>
</template>
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-64 border border-default rounded-lg p-4">
<div class="space-y-4">
<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>
</div>
</UScrollArea>
</template>
Use lanes for multi-column (vertical) or multi-row (horizontal) layouts.
<UScrollArea
:items="items"
:virtualize="{
lanes: 3,
gap: 12,
estimateSize: 200
}"
class="h-96"
>
<template #default="{ item }">
<img :src="item.url" :alt="item.title" class="w-full" />
</template>
</UScrollArea>
lanes based on container width:<script setup lang="ts">
const lanes = ref(useBreakpoints({
sm: 1,
md: 2,
lg: 3
}))
</script>
Common options for the virtualize prop:
| Option | Default | Description |
|---|---|---|
estimateSize | 100 | Estimated item size in pixels |
overscan | 12 | Items to render outside visible area |
gap | 0 | Gap between items (pixels) |
paddingStart | 0 | Padding at start (pixels) |
paddingEnd | 0 | Padding at end (pixels) |
lanes | - | Columns (vertical) or rows (horizontal) |
loadMoreThreshold | 5 | Items from end to trigger @load-more |
enabled | true | Enable/disable virtualization |
See TanStack Virtual docs for all available options.
Use exposed methods to control scrolling:
<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>
<div class="flex items-center gap-2">
<UButton icon="i-lucide-arrow-up-to-line" size="sm" @click="scrollToTop">
Top
</UButton>
<UButton icon="i-lucide-arrow-down-to-line" size="sm" @click="scrollToBottom">
Bottom
</UButton>
<UButton icon="i-lucide-navigation" size="sm" @click="scrollToItem(targetIndex || 500)">
Go to {{ targetIndex || 500 }}
</UButton>
</div>
</div>
</template>
Use @load-more to load more data as the user scrolls (requires virtualization):
<script setup lang="ts">
const posts = ref([...initialPosts])
async function loadMore() {
const morePosts = await fetchMorePosts()
posts.value = [...posts.value, ...morePosts] // Immutable update
}
</script>
<template>
<UScrollArea
:items="posts"
:virtualize="{ loadMoreThreshold: 5 }"
@load-more="loadMore"
>
<template #default="{ item }">
<UCard>{{ item.title }}</UCard>
</template>
</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 |
|---|---|---|
rootRef | Ref<HTMLElement> | The root element reference |
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 is false.export default defineAppConfig({
ui: {
scrollArea: {
slots: {
root: 'relative',
viewport: 'relative gap-3 p-3',
item: ''
},
variants: {
orientation: {
vertical: {
root: 'overflow-y-auto overflow-x-hidden',
viewport: 'columns-xs inline-flex flex-col gap-3',
item: ''
},
horizontal: {
root: 'overflow-x-auto overflow-y-hidden',
viewport: 'inline-flex flex-row gap-3',
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 gap-3 p-3',
item: ''
},
variants: {
orientation: {
vertical: {
root: 'overflow-y-auto overflow-x-hidden',
viewport: 'columns-xs inline-flex flex-col gap-3',
item: ''
},
horizontal: {
root: 'overflow-x-auto overflow-y-hidden',
viewport: 'inline-flex flex-row gap-3',
item: 'w-max'
}
}
}
}
}
})
]
})
Customize gap and padding via theme or ui prop:
<UScrollArea :items="items" :ui="{ viewport: 'gap-6 p-6' }">
<template #default="{ item }">{{ item }}</template>
</UScrollArea>
Or globally in app.config.ts:
export default defineAppConfig({
ui: {
scrollArea: {
slots: { viewport: 'gap-4 p-4' }
}
}
})
Use virtualization for:
Skip virtualization for:
Accurate estimates
Provide estimateSize close to average item height for better initial rendering.
Overscan
Increase overscan for smoother scrolling at the cost of rendering more off-screen items.
Immutable updates
Use spread syntax for reactive updates:
// Recommended
items.value = [...items.value, ...newItems]
// Avoid - may not trigger reactivity
items.value.push(...newItems)
Responsive lanes
Implement your own resize logic to update lanes based on viewport width.
Loading states
Use flags to prevent multiple simultaneous @load-more calls.