Scroll Area

A flexible scroll container with virtualization support.

Usage

The ScrollArea component creates scrollable containers with optional virtualization for large lists.

When virtualization is disabled, spacing and layout use theme configuration. When enabled, configure gap, padding, and lanes 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
    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>

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

Virtualization

Use the virtualize prop to render only the items currently in view, significantly boosting performance when working with large datasets.

Use virtualization for large lists (100+ items) or heavy components. Skip for small, simple lists (< 50 items).
<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>

Examples

Masonry layouts

Create masonry (waterfall) layouts with variable height items using lanes. Items are automatically measured and positioned 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
    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>
Provide an accurate estimateSize close to the average item height for better initial rendering performance. Increase overscan for smoother scrolling at the cost of rendering more off-screen items.

Responsive lanes

Implement responsive column/row counts using breakpoints or container width tracking.

<script setup lang="ts">
const { width } = useWindowSize()

const lanes = computed(() => {
  if (width.value < 640) return 1
  if (width.value < 1024) return 2
  return 3
})
</script>

<template>
  <UScrollArea :items="items" :virtualize="{ lanes }">
    <template #default="{ item }">
      <!-- your item content -->
    </template>
  </UScrollArea>
</template>

For container-based responsive behavior:

<script setup lang="ts">
const scrollArea = ref()
const { width } = useElementSize(scrollArea)

const lanes = computed(() => {
  // 2 lanes is the minimum, 6 lanes is the maximum, 300px is the goal width of each lane
  return Math.max(2, Math.min(6, Math.floor(width.value / 300)))
})
</script>

<template>
  <UScrollArea ref="scrollArea" :items="items" :virtualize="{ lanes }">
    <template #default="{ item }">{{ item }}</template>
  </UScrollArea>
</template>
Use useWindowSize for viewport-based or useElementSize for container-based responsive lanes.

Programmatic scrolling

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>

Infinite scroll

Use @load-more to load more data as the user scrolls (requires virtualization):

<script setup lang="ts">
import { UButton } from '#components'

type Recipe = {
  id: number
  name: string
  image: string
  difficulty: string
  cuisine: string
  rating: number
  reviewCount: number
  prepTimeMinutes: number
  cookTimeMinutes: number
}

type RecipeResponse = {
  recipes: Recipe[]
  total: number
  skip: number
  limit: number
}

const skip = ref(0)
const limit = 10

const { data, status, execute } = await useFetch(
  'https://dummyjson.com/recipes?limit=10&select=name,image,difficulty,cuisine,rating,reviewCount,prepTimeMinutes,cookTimeMinutes',
  {
    key: 'scroll-area-recipes-infinite-scroll',
    params: { skip, limit },
    transform: (data?: RecipeResponse) => {
      return data?.recipes
    },
    lazy: true,
    immediate: false
  }
)

const recipes = ref<Recipe[]>([])

watch(data, () => {
  if (data.value) {
    recipes.value = [...recipes.value, ...data.value]
  }
})

execute()

function loadMore() {
  if (status.value !== 'pending') {
    skip.value += limit
  }
}
</script>

<template>
  <UScrollArea
    :items="recipes"
    :virtualize="{
      estimateSize: 120,
      gap: 16,
      paddingStart: 16,
      paddingEnd: 16,
      loadMoreThreshold: 5
    }"
    class="h-96 w-full"
    @load-more="loadMore"
  >
    <template #default="{ item }">
      <UPageCard
        :description="`${item.cuisine}${item.difficulty}`"
        orientation="horizontal"
        :ui="{ container: 'lg:flex flex-row' }"
      >
        <template #header>
          <UUser
            :name="item.name"
            :description="`${item.prepTimeMinutes + item.cookTimeMinutes} min • ${item.reviewCount} reviews`"
            :avatar="{ src: item.image, alt: item.name }"
          />
        </template>
        <UButton color="neutral" variant="subtle" size="xl" class="fit-content justify-self-end">
          <UIcon name="i-lucide-star" class="size-3" />
          {{ item.rating }}
        </UButton>
      </UPageCard>
    </template>
  </UScrollArea>

  <UIcon
    v-if="status === 'pending'"
    name="i-lucide-loader-circle"
    class="animate-spin size-5 absolute bottom-4 left-0 right-0 mx-auto"
  />
</template>
The @load-more event fires when the user scrolls within loadMoreThreshold items from the end (default: 5). Use a loading flag to prevent multiple simultaneous requests and always use spread syntax ([...items, ...newItems]) for reactive updates.

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

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
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
Scroll methods are only available when virtualization is enabled. Calling them with virtualize set to false will result in a warning message.

Theme

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

Changelog

No recent changes