ScrollArea

A flexible scroll container with virtualization support.

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:

Item 1
Description for item 1
Item 2
Description for item 2
Item 3
Description for item 3
Item 4
Description for item 4
Item 5
Description for item 5
Item 6
Description for item 6
Item 7
Description for item 7
Item 8
Description for item 8
Item 9
Description for item 9
Item 10
Description for item 10
Item 11
Description for item 11
Item 12
Description for item 12
Item 13
Description for item 13
Item 14
Description for item 14
Item 15
Description for item 15
Item 16
Description for item 16
Item 17
Description for item 17
Item 18
Description for item 18
Item 19
Description for item 19
Item 20
Description for item 20
Item 21
Description for item 21
Item 22
Description for item 22
Item 23
Description for item 23
Item 24
Description for item 24
Item 25
Description for item 25
Item 26
Description for item 26
Item 27
Description for item 27
Item 28
Description for item 28
Item 29
Description for item 29
Item 30
Description for item 30
<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>
You can also use the default slot without the items prop to render custom scrollable content directly.

Orientation

Use the orientation prop to change the scroll direction. Defaults to vertical.

Item 1
Description for item 1
Item 2
Description for item 2
Item 3
Description for item 3
Item 4
Description for item 4
Item 5
Description for item 5
Item 6
Description for item 6
Item 7
Description for item 7
Item 8
Description for item 8
Item 9
Description for item 9
Item 10
Description for item 10
Item 11
Description for item 11
Item 12
Description for item 12
Item 13
Description for item 13
Item 14
Description for item 14
Item 15
Description for item 15
Item 16
Description for item 16
Item 17
Description for item 17
Item 18
Description for item 18
Item 19
Description for item 19
Item 20
Description for item 20
Item 21
Description for item 21
Item 22
Description for item 22
Item 23
Description for item 23
Item 24
Description for item 24
Item 25
Description for item 25
Item 26
Description for item 26
Item 27
Description for item 27
Item 28
Description for item 28
Item 29
Description for item 29
Item 30
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, 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.

When virtualization is enabled, customize spacing via the virtualize prop options like gap, paddingStart, and paddingEnd. Otherwise, use the ui prop to apply classes like gap p-4 on the viewport slot.
If all your items have the same height, set 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>
For optimal performance, set 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.

Loading...
<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>
This example uses 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.

Notifications17 unread

New comment on your PR

Benjamin left a comment on #42

1h ago

Deployment succeeded

v1.4.2 deployed to production

1h ago

Review requested

Mike requested your review on #38

2h ago

Issue assigned to you

Bug: scroll area overflow on mobile

2h ago

Build failed

CI pipeline failed on main

3h ago

New comment on your PR

Benjamin left a comment on #42

3h ago

Deployment succeeded

v1.4.2 deployed to production

4h ago

Review requested

Mike requested your review on #38

4h ago

Issue assigned to you

Bug: scroll area overflow on mobile

5h ago

Build failed

CI pipeline failed on main

5h ago

New comment on your PR

Benjamin left a comment on #42

6h ago

Deployment succeeded

v1.4.2 deployed to production

6h ago

Review requested

Mike requested your review on #38

7h ago

Issue assigned to you

Bug: scroll area overflow on mobile

7h ago

Build failed

CI pipeline failed on main

8h ago

New comment on your PR

Benjamin left a comment on #42

8h ago

Deployment succeeded

v1.4.2 deployed to production

9h ago

Review requested

Mike requested your review on #38

9h ago

Issue assigned to you

Bug: scroll area overflow on mobile

10h ago

Build failed

CI pipeline failed on main

10h ago
You're all caught up
<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>
The #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.

Section 1
Custom content without using the items prop.
Section 2
Custom content without using the items prop.
Section 3
Custom content without using the items prop.
Section 4
Custom content without using the items prop.
Section 5
Custom content without using the items prop.
Section 6
Custom content without using the items prop.
<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'any

The element or component this component should render as.

orientation'vertical' "vertical" | "horizontal"

The scroll direction.

items T[]

Array of items to render.

virtualizefalseboolean | ScrollAreaVirtualizeOptions

Enable virtualization for large lists.

ui { root?: ClassNameValue; viewport?: ClassNameValue; item?: ClassNameValue; }

Slots

Slot Type
leadingany
default{ item: T; index: number; virtualItem?: VirtualItem | undefined; } | { item: T; index: 0; }
trailingany

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:

NameTypeDescription
$elHTMLElementThe root element of the component.
virtualizerRef<Virtualizer> | undefinedThe TanStack Virtual virtualizer instance (undefined if virtualization is disabled).

Theme

app.config.ts
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: ''
          }
        }
      }
    }
  }
})
vite.config.ts
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: ''
              }
            }
          }
        }
      }
    })
  ]
})

Changelog

No recent changes