Scroll Area

A flexible scroll container with virtualization support for efficiently rendering large lists of any content type.

Usage

Use the ScrollArea component to create scrollable containers with optional virtualization for large lists.

  • Non-virtualized: Gap and padding are controlled via theme (Tailwind classes)
  • Virtualized: Gap, padding, and layout options (like 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>

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
    :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>

Virtualization

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>
Use virtualization for lists with 100+ items or when items contain heavy components (images, complex UI).

Examples

Variable heights

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>

Custom content

Use the default slot without items for custom scrollable content.

Section 1

Custom content without using the items prop.

Section 2

Any content can be placed here and it will be scrollable.

Section 3

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>

Masonry layouts

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>
For responsive layouts, implement your own resize logic to update lanes based on container width:
<script setup lang="ts">
const lanes = ref(useBreakpoints({
  sm: 1,
  md: 2,
  lg: 3
}))
</script>

Virtualization options

Common options for the virtualize prop:

OptionDefaultDescription
estimateSize100Estimated item size in pixels
overscan12Items to render outside visible area
gap0Gap between items (pixels)
paddingStart0Padding at start (pixels)
paddingEnd0Padding at end (pixels)
lanes-Columns (vertical) or rows (horizontal)
loadMoreThreshold5Items from end to trigger @load-more
enabledtrueEnable/disable virtualization

See TanStack Virtual docs for all available options.

Programmatic scrolling

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>

Infinite scroll

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>
Use a loading flag to prevent multiple simultaneous requests.

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

unknown[]

Array of items to render.

virtualize

false

boolean | ScrollAreaVirtualizeOptions

Enable virtualization for large lists.

ui

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

Slots

Slot Type
default

Record<string, never> | { item: unknown; index: number; virtualItem?: VirtualItem | undefined; }

Emits

Event Type
scroll

[isScrolling: boolean]

loadMore

[lastIndex: number]

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?.scrollToIndex(index, { align: 'center' })
}
</script>

<template>
  <UScrollArea ref="scrollArea" :items="items" virtualize />
</template>

This will give you access to the following:

NameTypeDescription
rootRefRef<HTMLElement>The root element reference
virtualizerComputedRef<Virtualizer | null>The TanStack Virtual virtualizer instance (null if virtualization is disabled)
scrollToOffset(offset: number, options?: ScrollToOptions) => voidScroll to a specific pixel offset
scrollToIndex(index: number, options?: ScrollToOptions) => voidScroll to a specific item index
getTotalSize() => numberGet the total size of all virtualized items in pixels
measure() => voidReset all previously measured item sizes
getScrollOffset() => numberGet the current scroll offset in pixels
isScrolling() => booleanCheck if the list is currently being scrolled
getScrollDirection() => 'forward' | 'backward' | nullGet the current scroll direction
All scroll methods require virtualization to be enabled. They will log a warning if called when virtualize is false.

Theme

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

Non-virtualized styling

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' }
    }
  }
})

Best Practices

When to virtualize

Use virtualization for:

  • Lists with 100+ items
  • Items with heavy components or images
  • Infinite scroll implementations
  • Mobile/low-end devices

Skip virtualization for:

  • Small lists (< 50 simple items)
  • Rarely scrolled content

Performance tips

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.

Changelog

No recent changes