useRecycleScroller (Headless)
useRecycleScroller is the low-level composable for building fixed-size or pre-sized virtual lists with your own markup.
Use it when you want full control over markup, styling, and rendering logic while keeping the virtualization engine separate from the rendered UI.
When to use it
- You need a custom DOM structure that does not fit the component slot API.
- You need a custom DOM structure and direct control over the rendered output.
- You want to integrate virtualization into an existing design system component.
- You want to control rendering/pooling behavior directly (for example with custom item wrappers).
- Item size is already known, fixed, or available in the data before render.
Mental model
useRecycleScrolleris fully headless. It gives you virtualization state, but you still own the wrapper markup, item markup, and positioning styles.- The scroll container is your element ref (
scrollerEl). It still needs real scrollable sizing such as a fixedheightandoverflow. totalSizeis the virtual size of the full list. Apply it to an inner wrapper, usually withminHeightorminWidth.- Render from
pool, notvisiblePool, when you want the same recycling behavior asRecycleScroller. - Inactive pooled views stay mounted. Hide them with
visibility: hiddenandpointer-events: noneinstead of removing them from the DOM.
TypeScript generics
Pass the item type as the generic parameter when you want typed pool entries and compile-time validation for object-item fields:
const recycleScroller = useRecycleScroller<User>({
items: users.value,
keyField: 'id',
direction: 'vertical',
itemSize: null,
minItemSize: 32,
sizeField: 'size',
typeField: 'type',
buffer: 200,
pageMode: false,
prerender: 0,
emitUpdate: false,
updateInterval: 0,
}, scrollerEl)
recycleScroller.pool.value[0]?.item.nameWhen TItem is an object type, keyField can be either a string key on that type or a resolver function with the signature (item, index) => string | number. itemSize can be a fixed number, null plus sizeField, or a resolver function with the signature (item, index) => number. sizeField must still be a numeric field when itemSize is null.
Required options
useRecycleScroller expects the same core options used internally by RecycleScroller:
itemskeyFielddirectionitemSizeminItemSizesizeFieldtypeFieldbufferpageModeprerenderemitUpdateupdateInterval
Optional grid options:
gridItemsitemSecondarySize
Additional scroll-system options:
shiftcachedisableTransformflowMode
keyField can also be a resolver function when your data needs a derived key. The callback always receives (item, index):
const compositeKey = (item: Message, index: number) => `${item.threadId}:${item.id}:${index}`itemSize also accepts a resolver function for variable-size mode when sizes are already known in memory:
const itemSize = (item: Message, index: number) => item.size || (index % 2 === 0 ? 48 : 32)gridItems still requires a numeric fixed itemSize. Function-based sizes are not supported in grid mode.
Return values you will use most
pool: the render-ready set of pooled views. This is the main render source when you want the smoothest recycling behavior.visiblePool:poolfiltered to active views and sorted by visible index order. Useful for readouts, debugging, or simple derived UI.totalSize: full virtual size (wrapper min-height/min-width).startSpacerSize: spacer size before the active pooled window inflowMode.endSpacerSize: spacer size after the active pooled window inflowMode.scrollToItem(index, options?): programmatic navigation withalign,smooth, andoffset.scrollToPosition(px, options?): absolute scroll positioning.getScroll(): current viewport range in pixels.findItemIndex(offset): resolve a pixel offset back to an item index.getItemOffset(index): read the starting pixel offset for an item.getItemSize(index): read the known size for an item.getViewStyle(view): build the same pooled wrapper positioning styles used by the component path, including optionaldisableTransformorflowMode.cacheSnapshot: current serializable size snapshot.restoreCache(snapshot): restore a previous snapshot when the item sequence matches.updateVisibleItems(itemsChanged, checkPositionDiff?): force recalculation.
Render checklist
- Give the outer scroller a fixed size and overflow behavior.
- Add an inner wrapper with
position: relativeandminHeight/minWidthfromtotalSize. - Render every entry in
pool. - Apply
getViewStyle(view)to each rendered pooled wrapper. - Hide inactive views instead of filtering them out.
If you enable flowMode instead:
- keep the scroller vertical and single-axis
- render start and end spacer elements from
startSpacerSizeandendSpacerSize - keep rendering from
pool - let inactive pooled views stay mounted and hidden
Example flow-mode spacer pattern:
<div ref="scrollerEl" class="scroller">
<div>
<div v-if="startSpacerSize > 0" :style="{ height: `${startSpacerSize}px` }" />
<article
v-for="view in pool"
:key="view.nr.id"
:style="getViewStyle(view)"
>
{{ view.item.title }}
</article>
<div v-if="endSpacerSize > 0" :style="{ height: `${endSpacerSize}px` }" />
</div>
</div>Common pitfalls
- You must provide scrollable sizing styles yourself (
heightorwidth+ overflow). - Use a stable key field for object items (default:
id). - The composable manages pooling and index mapping, but does not provide built-in markup or CSS.
disableTransformonly changes positioning strategy. You still own the surrounding layout and dimensions.flowModeonly supports vertical single-axis layouts in v1.gridItems, horizontal mode, andhiddenPositionfall back to standard positioning.- Render from
pooland hide inactive views instead of filtering them out if you want to preserve DOM reuse. - If you render a semantic table, pair it with
useTableColumnWidthsso native auto layout does not shift between pooled rows. - If item size has to be measured from the DOM after render, use
useDynamicScrollerinstead. - If the browser window owns scrolling, use
useWindowScrollerinstead of reproducing page-mode behavior yourself.
Full example
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue'
import { useRecycleScroller } from 'vue-virtual-scroller'
interface User {
id: number
name: string
}
const items = ref<User[]>(
Array.from({ length: 10000 }, (_, i) => ({
id: i + 1,
name: `User ${i + 1}`,
})),
)
const scrollerEl = useTemplateRef<HTMLElement>('scrollerEl')
const options = computed(() => ({
items: items.value,
keyField: 'id',
direction: 'vertical' as const,
itemSize: 40,
gridItems: undefined,
itemSecondarySize: undefined,
minItemSize: null,
sizeField: 'size',
typeField: 'type',
buffer: 200,
pageMode: false,
prerender: 0,
emitUpdate: false,
updateInterval: 0,
}))
const {
pool,
totalSize,
getViewStyle,
} = useRecycleScroller(options, scrollerEl)
</script>
<template>
<div
ref="scrollerEl"
class="my-scroller"
>
<div
class="my-scroller__inner"
:style="{ minHeight: `${totalSize}px` }"
>
<div
v-for="view in pool"
:key="view.nr.id"
class="my-scroller__item"
:style="getViewStyle(view)"
>
<strong>#{{ view.nr.index }}</strong> {{ (view.item as User).name }}
</div>
</div>
</div>
</template>
<style scoped>
.my-scroller {
height: 400px;
overflow-y: auto;
position: relative;
border: 1px solid #ddd;
}
.my-scroller__inner {
position: relative;
width: 100%;
overflow: hidden;
}
.my-scroller__item {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 40px;
display: flex;
align-items: center;
padding: 0 12px;
box-sizing: border-box;
border-bottom: 1px solid #f0f0f0;
}
</style>