ScrollArea
Usage
The ScrollArea component creates scrollable containers with optional virtualization for large lists.
<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>
Items
Use the items prop as an array and render each item using the default slot:
<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 prop to render custom scrollable content directly.Orientation
Use the orientation prop to change the scroll direction. Defaults to 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>
Virtualize
Use the virtualize prop to render only the items currently in view, significantly boosting performance when working with large datasets.
virtualize prop options like gap, paddingStart, and paddingEnd. Otherwise, use the ui prop to apply classes like gap p-4 on the viewport slot.skipMeasurement to true in the virtualize prop to skip per-item DOM measurement and rely on estimateSize instead. This significantly improves performance for large uniform lists.<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>
Examples
As masonry layout
Use the virtualize prop with lanes, gap, and estimateSize options to create Pinterest-style masonry layouts with variable height items.
<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 close to your average item height. Increasing overscan improves scrolling smoothness but renders more off-screen items.With responsive lanes
You can use the useWindowSize (for viewport-based) or useElementSize (for container-based) composables to make the lanes reactive.
<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>
With programmatic scroll
You can use the exposed virtualizer to programmatically control scroll position.
<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>
With infinite scroll
You can use the useInfiniteScroll composable to load more data as the user scrolls.
<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 isLoading = computed(() => status.value === 'pending' || status.value === 'idle')
const scrollArea = useTemplateRef('scrollArea')
onMounted(() => {
useInfiniteScroll(
scrollArea.value?.$el,
() => {
skip.value += 10
},
{
distance: 200,
canLoadMore: () => {
return status.value !== 'pending'
}
}
)
})
</script>
<template>
<UScrollArea
ref="scrollArea"
:items="users"
:virtualize="{
estimateSize: 88,
skipMeasurement: true
}"
class="h-96 w-full"
>
<template #default="{ item }">
<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>
</template>
<template #trailing>
<div v-if="isLoading" class="flex items-center justify-center p-4">
<UChatShimmer text="Loading..." />
</div>
</template>
</UScrollArea>
</template>
useLazyFetch with server: false to fetch data on the client without blocking the initial render. The #trailing slot displays a loading shimmer at the bottom of the list while fetching. Additional pages are loaded as the user scrolls.With leading/trailing
Use the #leading and #trailing slots to render content before and after the scroll items. This is useful for headers, footers, or navigation within the scroll area.
New comment on your PR
Benjamin left a comment on #42
Deployment succeeded
v1.4.2 deployed to production
Review requested
Mike requested your review on #38
Issue assigned to you
Bug: scroll area overflow on mobile
Build failed
CI pipeline failed on main
New comment on your PR
Benjamin left a comment on #42
Deployment succeeded
v1.4.2 deployed to production
Review requested
Mike requested your review on #38
Issue assigned to you
Bug: scroll area overflow on mobile
Build failed
CI pipeline failed on main
New comment on your PR
Benjamin left a comment on #42
Deployment succeeded
v1.4.2 deployed to production
Review requested
Mike requested your review on #38
Issue assigned to you
Bug: scroll area overflow on mobile
Build failed
CI pipeline failed on main
New comment on your PR
Benjamin left a comment on #42
Deployment succeeded
v1.4.2 deployed to production
Review requested
Mike requested your review on #38
Issue assigned to you
Bug: scroll area overflow on mobile
Build failed
CI pipeline failed on main
<script setup lang="ts">
const notifications = Array.from({ length: 20 }, (_, i) => ({
id: i + 1,
title: ['New comment on your PR', 'Deployment succeeded', 'Review requested', 'Issue assigned to you', 'Build failed'][i % 5]!,
description: ['Benjamin left a comment on #42', 'v1.4.2 deployed to production', 'Mike requested your review on #38', 'Bug: scroll area overflow on mobile', 'CI pipeline failed on main'][i % 5]!,
time: `${Math.floor(i / 2) + 1}h ago`,
icon: ['i-lucide-message-square', 'i-lucide-rocket', 'i-lucide-eye', 'i-lucide-circle-dot', 'i-lucide-circle-x'][i % 5]!,
read: i < 3
}))
const unreadCount = computed(() => notifications.filter(n => !n.read).length)
</script>
<template>
<UScrollArea
:items="notifications"
class="w-full h-96"
>
<template #leading>
<div class="flex items-center justify-between px-4 py-3 border-b border-default">
<span class="text-sm font-medium text-highlighted">Notifications</span>
<UBadge :label="`${unreadCount} unread`" variant="subtle" size="sm" />
</div>
</template>
<template #default="{ item }">
<div class="flex gap-3 px-4 py-3 border-b border-default" :class="[!item.read && 'bg-elevated/50']">
<UIcon :name="item.icon" class="size-5 text-muted shrink-0 mt-0.5" />
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-highlighted truncate">
{{ item.title }}
</p>
<p class="text-sm text-muted truncate">
{{ item.description }}
</p>
</div>
<span class="text-xs text-dimmed shrink-0">{{ item.time }}</span>
</div>
</template>
<template #trailing>
<div class="flex items-center justify-center gap-2 p-6 text-sm text-muted">
<UIcon name="i-lucide-check-circle" class="size-4" />
You're all caught up
</div>
</template>
</UScrollArea>
</template>
#leading and #trailing slots render inside the scroll container, so they scroll with the content. Use sticky positioning if you want them to stay fixed.With default slot
You can use the default slot without the items prop to render custom scrollable content directly.
<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. |
ui | { root?: ClassNameValue; viewport?: ClassNameValue; item?: ClassNameValue; } |
Slots
| Slot | Type |
|---|---|
leading | any |
default | { item: T; index: number; virtualItem?: VirtualItem | undefined; } | { item: T; index: 0; } |
trailing | any |
Emits
| Event | Type |
|---|---|
scroll | [isScrolling: boolean] |
Expose
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?.virtualizer?.scrollToIndex(index, { align: 'center' })
}
</script>
<template>
<UScrollArea ref="scrollArea" :items="items" virtualize />
</template>
This will give you access to the following:
| Name | Type | Description |
|---|---|---|
$el | HTMLElement | The root element of the component. |
virtualizer | Ref<Virtualizer> | undefined | The TanStack Virtual virtualizer instance (undefined if virtualization is disabled). |
Theme
export default defineAppConfig({
ui: {
scrollArea: {
slots: {
root: 'relative',
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: ''
}
}
}
}
}
})
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',
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: ''
}
}
}
}
}
})
]
})