The ScrollArea component creates scrollable containers with optional virtualization for large lists.
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
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>
Set an estimateSize (average item height) to improve initial rendering performance. The actual size of each item is measured automatically during rendering.
<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. You can also increase overscan for smoother scrolling at the cost of rendering more off-screen items.Use the lanes prop for multi-column (vertical) or multi-row (horizontal) layouts.
<UScrollArea
v-slot="{ item }"
:items="items"
:virtualize="{
lanes: 3,
gap: 16,
estimateSize: 200
}"
class="h-96"
>
<img :src="item.url" :alt="item.title" class="w-full" />
</UScrollArea>
lanes based on container width:<script setup lang="ts">
const lanes = ref(useBreakpoints({
sm: 1,
md: 2,
lg: 3
}))
</script>
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">
const posts = ref([...initialPosts])
const loading = ref(false)
async function loadMore() {
if (loading.value) return
loading.value = true
const morePosts = await fetchMorePosts()
posts.value = [...posts.value, ...morePosts] // Use spread for immutable update
loading.value = false
}
</script>
<template>
<UScrollArea
v-slot="{ item }"
:items="posts"
:virtualize="{ loadMoreThreshold: 5 }"
@load-more="loadMore"
>
<UCard>{{ item.title }}</UCard>
</UScrollArea>
</template>
[...items, ...newItems]) for reactive updates instead of .push(), and use a loading flag to prevent multiple simultaneous requests.Use the default slot without the items prop for custom scrollable content that doesn't require virtualization.
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'
}
}
}
}
}
})
]
})