vue-virtual-flow 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,808 @@
1
+ # vue-virtual-flow
2
+
3
+ [English](./README.md) | [Tiếng Việt](./README.vi.md)
4
+
5
+ A small, typed, and accessible collection of virtual scrolling components for
6
+ Vue 3. It covers fixed and variable-height lists, window scrolling, chat,
7
+ short-media feeds, and horizontal carousels.
8
+
9
+ Only the items needed for the current viewport are mounted, keeping large data
10
+ sets responsive without prescribing how an item should look.
11
+
12
+ ## Table of contents
13
+
14
+ - [Installation](#installation)
15
+ - [Quick start](#quick-start)
16
+ - [Choose a component](#choose-a-component)
17
+ - [Component guides](#component-guides)
18
+ - [VirtualList and DynamicVirtualScroll](#virtuallist-and-dynamicvirtualscroll)
19
+ - [VirtualScroll](#virtualscroll)
20
+ - [WindowDynamicVirtualScroll](#windowdynamicvirtualscroll)
21
+ - [ChatVirtualScroll](#chatvirtualscroll)
22
+ - [ShortMediaFeed](#shortmediafeed)
23
+ - [VirtualCarousel](#virtualcarousel)
24
+ - [Shared recipes](#shared-recipes)
25
+ - [Viewport height](#viewport-height)
26
+ - [Stable item keys](#stable-item-keys)
27
+ - [Load more](#load-more)
28
+ - [Pull to refresh](#pull-to-refresh)
29
+ - [TypeScript exports](#typescript-exports)
30
+ - [Development and release](#development-and-release)
31
+ - [Contributing](#contributing)
32
+ - [License](#license)
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ npm install vue-virtual-flow
38
+ ```
39
+
40
+ Import the library stylesheet once in your application entry point:
41
+
42
+ ```ts
43
+ import 'vue-virtual-flow/style.css'
44
+ ```
45
+
46
+ The package requires Vue `^3.4.0` and Node.js `>=18`.
47
+
48
+ ## Quick start
49
+
50
+ Import components locally:
51
+
52
+ ```vue
53
+ <script setup lang="ts">
54
+ import { VirtualList } from 'vue-virtual-flow'
55
+ import 'vue-virtual-flow/style.css'
56
+
57
+ const users = Array.from({ length: 10_000 }, (_, id) => ({
58
+ id,
59
+ name: `User ${id + 1}`,
60
+ }))
61
+ </script>
62
+
63
+ <template>
64
+ <VirtualList :items="users" item-key="id">
65
+ <template #default="{ item, index }">
66
+ <div>{{ index + 1 }}. {{ item.name }}</div>
67
+ </template>
68
+
69
+ <template #empty>No users found.</template>
70
+ </VirtualList>
71
+ </template>
72
+ ```
73
+
74
+ Or install every component globally:
75
+
76
+ ```ts
77
+ import { createApp } from 'vue'
78
+ import VueVirtualScroll from 'vue-virtual-flow'
79
+ import 'vue-virtual-flow/style.css'
80
+ import App from './App.vue'
81
+
82
+ createApp(App).use(VueVirtualScroll).mount('#app')
83
+ ```
84
+
85
+ The plugin registers `VirtualList`, `DynamicVirtualScroll`, `VirtualScroll`,
86
+ `WindowDynamicVirtualScroll`, `ChatVirtualScroll`, `ShortMediaFeed`, and
87
+ `VirtualCarousel`.
88
+
89
+ ## Choose a component
90
+
91
+ | Component | Use it when | Scroll viewport | Item size |
92
+ | --- | --- | --- | --- |
93
+ | `VirtualList` | You want the simplest general-purpose list. | Component container | Variable, measured automatically |
94
+ | `DynamicVirtualScroll` | Same behavior as `VirtualList`, with an explicit name. | Component container | Variable, measured automatically |
95
+ | `VirtualScroll` | Every row has the same known height. | Component container | Fixed |
96
+ | `WindowDynamicVirtualScroll` | The page itself should scroll instead of a nested container. | Browser window | Variable, measured automatically |
97
+ | `ChatVirtualScroll` | Messages are appended at the bottom and older history is prepended. | Component container | Variable, measured automatically |
98
+ | `ShortMediaFeed` | One full-height item should snap into view at a time. | Component container | One viewport per item |
99
+ | `VirtualCarousel` | Several horizontal slides should snap and virtualize. | Component container | Calculated from container width |
100
+
101
+ ## Component guides
102
+
103
+ ### VirtualList and DynamicVirtualScroll
104
+
105
+ `VirtualList` is an alias of `DynamicVirtualScroll`. Both names render the same
106
+ component and share the same API. Use it for cards, search results, activity
107
+ feeds, or any list whose rows can have different heights.
108
+
109
+ Rows initially use `estimatedItemSize`. `ResizeObserver` then replaces each
110
+ estimate with the measured height. Measurement changes above the viewport are
111
+ compensated to avoid visible jumps.
112
+
113
+ #### Example
114
+
115
+ ```vue
116
+ <script setup lang="ts">
117
+ import { ref } from 'vue'
118
+ import {
119
+ VirtualList,
120
+ type VirtualScrollEvent,
121
+ type VirtualListExpose,
122
+ } from 'vue-virtual-flow'
123
+
124
+ const list = ref<VirtualListExpose>()
125
+ const posts = ref([
126
+ { id: 'p1', title: 'Short post', body: 'One line.' },
127
+ {
128
+ id: 'p2',
129
+ title: 'Long post',
130
+ body: 'Content that wraps and gives this row a different height.',
131
+ },
132
+ ])
133
+
134
+ function onScroll(event: VirtualScrollEvent) {
135
+ console.log(event.scrollTop, event.startIndex, event.endIndex)
136
+ }
137
+ </script>
138
+
139
+ <template>
140
+ <VirtualList
141
+ ref="list"
142
+ :items="posts"
143
+ :estimated-item-size="96"
144
+ :height="500"
145
+ :overscan="4"
146
+ item-key="id"
147
+ aria-label="Posts"
148
+ @scroll="onScroll"
149
+ >
150
+ <template #default="{ item, index }">
151
+ <article>
152
+ <small>#{{ index + 1 }}</small>
153
+ <h2>{{ item.title }}</h2>
154
+ <p>{{ item.body }}</p>
155
+ </article>
156
+ </template>
157
+
158
+ <template #empty>No posts yet.</template>
159
+ </VirtualList>
160
+
161
+ <button @click="list?.scrollToIndex(1, { align: 'center' })">
162
+ Go to the second post
163
+ </button>
164
+ </template>
165
+ ```
166
+
167
+ Choose an estimate close to the average rendered row height. Rows do not need
168
+ to match it; a good estimate mainly improves the initial scrollbar and jumps
169
+ to items that have not been measured.
170
+
171
+ #### Props
172
+
173
+ | Prop | Type | Default | Description |
174
+ | --- | --- | --- | --- |
175
+ | `items` | `readonly T[]` | required | Data passed to the default slot. |
176
+ | `estimatedItemSize` | `number` | `48` | Initial average row height in pixels. |
177
+ | `height` | `number \| string` | `400` | Viewport height. A number is pixels; `"fill"` maps to `100%`. |
178
+ | `overscan` | `number` | `3` | Extra rows mounted before and after the visible range. |
179
+ | `itemKey` | `keyof T \| (item, index) => PropertyKey` | `id`, `key`, or index | Stable identity used for keys and measurements. |
180
+ | `ariaLabel` | `string` | `"Dynamic virtual list"` | Accessible label for the list. |
181
+ | `hasMore` | `boolean` | `false` | Indicates that another page can be loaded. |
182
+ | `loading` | `boolean` | `false` | Prevents duplicate requests and shows the loading row. |
183
+ | `loadingItemSize` | `number` | `estimatedItemSize` | Reserved loading-row height in pixels. |
184
+ | `loadMoreThreshold` | `number` | `200` | Distance from the end, in pixels, that triggers `load-more`. |
185
+ | `pullToRefresh` | `boolean` | `false` | Enables the touch pull-down gesture at the top. |
186
+ | `refreshing` | `boolean` | `false` | Keeps the refresh indicator open during async work. |
187
+ | `pullRefreshThreshold` | `number` | `64` | Pull distance required to emit `refresh`, in pixels. |
188
+
189
+ #### Slots
190
+
191
+ | Slot | Scope | Description |
192
+ | --- | --- | --- |
193
+ | `default` | `{ item, index }` | Renders each mounted row. |
194
+ | `empty` | none | Rendered when `items` is empty and `loading` is false. |
195
+ | `loading` | none | Loading row; defaults to “Loading more…”. |
196
+ | `refresh` | `{ pullDistance, progress, refreshing }` | Pull-to-refresh indicator. `progress` is clamped from `0` to `1`. |
197
+
198
+ #### Events
199
+
200
+ | Event | Payload | When it fires |
201
+ | --- | --- | --- |
202
+ | `scroll` | `{ scrollTop, startIndex, endIndex }` | On container scroll. `endIndex` is exclusive. |
203
+ | `load-more` | none | Near the end when `hasMore` is true and `loading` is false. |
204
+ | `refresh` | none | After pulling past the refresh threshold and releasing. |
205
+
206
+ #### Exposed API
207
+
208
+ | Method | Description |
209
+ | --- | --- |
210
+ | `scrollTo(position, options?)` | Scrolls to a pixel offset inside the list. |
211
+ | `scrollToIndex(index, { align?, behavior? })` | Scrolls to a zero-based index. `align` is `start`, `center`, or `end`. |
212
+ | `scrollToTop(behavior?)` | Scrolls to the start of the list. |
213
+
214
+ Long-distance smooth jumps automatically become immediate jumps because
215
+ unmeasured rows can change the target offset while scrolling.
216
+
217
+ ### VirtualScroll
218
+
219
+ Use `VirtualScroll` when every row has exactly the same height. It does not
220
+ need to measure rows, making it the most predictable option for tables, menus,
221
+ logs, and dense result lists.
222
+
223
+ #### Example
224
+
225
+ ```vue
226
+ <script setup lang="ts">
227
+ import { ref } from 'vue'
228
+ import { VirtualScroll, type VirtualScrollExpose } from 'vue-virtual-flow'
229
+
230
+ const list = ref<VirtualScrollExpose>()
231
+ const rows = Array.from({ length: 50_000 }, (_, id) => ({
232
+ id,
233
+ label: `Row ${id + 1}`,
234
+ }))
235
+ </script>
236
+
237
+ <template>
238
+ <VirtualScroll
239
+ ref="list"
240
+ :items="rows"
241
+ :item-size="44"
242
+ :height="480"
243
+ :overscan="5"
244
+ item-key="id"
245
+ aria-label="Results"
246
+ >
247
+ <template #default="{ item }">
248
+ <div class="result-row">{{ item.label }}</div>
249
+ </template>
250
+ </VirtualScroll>
251
+
252
+ <button @click="list?.scrollToIndex(999, { align: 'center' })">
253
+ Go to row 1,000
254
+ </button>
255
+ </template>
256
+
257
+ <style scoped>
258
+ .result-row {
259
+ height: 44px;
260
+ box-sizing: border-box;
261
+ }
262
+ </style>
263
+ ```
264
+
265
+ The rendered row must stay at `itemSize`. If content can wrap or resize, use
266
+ `VirtualList` instead.
267
+
268
+ #### Props
269
+
270
+ | Prop | Type | Default | Description |
271
+ | --- | --- | --- | --- |
272
+ | `items` | `readonly T[]` | required | Data passed to the default slot. |
273
+ | `itemSize` | `number` | required | Exact height of every row in pixels. |
274
+ | `height` | `number \| string` | `400` | Viewport height. A number is pixels; `"fill"` maps to `100%`. |
275
+ | `overscan` | `number` | `3` | Extra rows mounted before and after the visible range. |
276
+ | `itemKey` | `keyof T \| (item, index) => PropertyKey` | `id`, `key`, or index | Stable identity for each row. |
277
+ | `ariaLabel` | `string` | `"Virtual list"` | Accessible label for the list. |
278
+ | `hasMore` | `boolean` | `false` | Indicates that another page can be loaded. |
279
+ | `loading` | `boolean` | `false` | Prevents duplicate requests and shows the loading row. |
280
+ | `loadingItemSize` | `number` | `itemSize` | Reserved loading-row height in pixels. |
281
+ | `loadMoreThreshold` | `number` | `200` | Distance from the end, in pixels, that triggers `load-more`. |
282
+ | `pullToRefresh` | `boolean` | `false` | Enables the touch pull-down gesture at the top. |
283
+ | `refreshing` | `boolean` | `false` | Keeps the refresh indicator open during async work. |
284
+ | `pullRefreshThreshold` | `number` | `64` | Pull distance required to emit `refresh`, in pixels. |
285
+
286
+ #### Slots, events, and exposed API
287
+
288
+ - Slots: `default({ item, index })`, `empty`, `loading`, and
289
+ `refresh({ pullDistance, progress, refreshing })`.
290
+ - Events: `scroll({ scrollTop, startIndex, endIndex })`, `load-more`, and
291
+ `refresh`.
292
+ - Methods: `scrollTo`, `scrollToIndex`, and `scrollToTop`.
293
+
294
+ ### WindowDynamicVirtualScroll
295
+
296
+ Use `WindowDynamicVirtualScroll` for a long page or feed where the browser
297
+ window—not an inner element—is the scrolling viewport. It measures
298
+ variable-height rows and listens to `window.scrollY` and `window.innerHeight`.
299
+ There is no `height` prop.
300
+
301
+ #### Example
302
+
303
+ ```vue
304
+ <script setup lang="ts">
305
+ import { ref } from 'vue'
306
+ import {
307
+ WindowDynamicVirtualScroll,
308
+ type VirtualScrollExpose,
309
+ } from 'vue-virtual-flow'
310
+
311
+ const feed = ref(loadInitialFeed())
312
+ const list = ref<VirtualScrollExpose>()
313
+ </script>
314
+
315
+ <template>
316
+ <header>Content before the virtual list</header>
317
+
318
+ <WindowDynamicVirtualScroll
319
+ ref="list"
320
+ :items="feed"
321
+ :estimated-item-size="320"
322
+ :overscan="3"
323
+ item-key="id"
324
+ aria-label="Article feed"
325
+ >
326
+ <template #default="{ item, index }">
327
+ <article>
328
+ <small>{{ index + 1 }}</small>
329
+ <h2>{{ item.title }}</h2>
330
+ <p>{{ item.summary }}</p>
331
+ </article>
332
+ </template>
333
+ </WindowDynamicVirtualScroll>
334
+
335
+ <footer>Content after the virtual list</footer>
336
+ </template>
337
+ ```
338
+
339
+ #### Props
340
+
341
+ | Prop | Type | Default | Description |
342
+ | --- | --- | --- | --- |
343
+ | `items` | `readonly T[]` | required | Data passed to the default slot. |
344
+ | `estimatedItemSize` | `number` | `48` | Initial average row height in pixels. |
345
+ | `overscan` | `number` | `3` | Extra rows mounted before and after the visible range. |
346
+ | `itemKey` | `keyof T \| (item, index) => PropertyKey` | `id`, `key`, or index | Stable identity used for keys and measurements. |
347
+ | `ariaLabel` | `string` | `"Window dynamic virtual list"` | Accessible label for the list. |
348
+ | `hasMore` | `boolean` | `false` | Indicates that another page can be loaded. |
349
+ | `loading` | `boolean` | `false` | Prevents duplicate requests and shows the loading row. |
350
+ | `loadingItemSize` | `number` | `estimatedItemSize` | Reserved loading-row height in pixels. |
351
+ | `loadMoreThreshold` | `number` | `200` | Distance between the window bottom and list end that triggers `load-more`. |
352
+ | `pullToRefresh` | `boolean` | `false` | Enables refresh while the page is at `window.scrollY = 0`. |
353
+ | `refreshing` | `boolean` | `false` | Keeps the refresh indicator open during async work. |
354
+ | `pullRefreshThreshold` | `number` | `64` | Pull distance required to emit `refresh`, in pixels. |
355
+
356
+ #### Slots, events, and exposed API
357
+
358
+ - Slots: `default({ item, index })`, `empty`, `loading`, and
359
+ `refresh({ pullDistance, progress, refreshing })`.
360
+ - Events: `scroll({ scrollTop, startIndex, endIndex })`, `load-more`, and
361
+ `refresh`. `scrollTop` is relative to the component start.
362
+ - Methods: `scrollTo`, `scrollToIndex`, and `scrollToTop`. They scroll the
363
+ browser window; `scrollTo` accepts an offset relative to the list.
364
+
365
+ ### ChatVirtualScroll
366
+
367
+ `ChatVirtualScroll` is designed for variable-height conversations. It opens at
368
+ the newest message by default, follows appended messages only while the user
369
+ is near the bottom, and preserves the visible message when older items are
370
+ prepended.
371
+
372
+ #### Example
373
+
374
+ ```vue
375
+ <script setup lang="ts">
376
+ import { ref } from 'vue'
377
+ import {
378
+ ChatVirtualScroll,
379
+ type ChatVirtualScrollExpose,
380
+ } from 'vue-virtual-flow'
381
+
382
+ const chat = ref<ChatVirtualScrollExpose>()
383
+ const loadingOlder = ref(false)
384
+ const messages = ref(loadLatestMessages())
385
+ const hasOlder = ref(true)
386
+
387
+ async function loadOlder() {
388
+ if (loadingOlder.value || !hasOlder.value) return
389
+ loadingOlder.value = true
390
+ const page = await fetchOlderPage(messages.value[0]?.id)
391
+ messages.value.unshift(...page.items)
392
+ hasOlder.value = page.hasMore
393
+ loadingOlder.value = false
394
+ }
395
+ </script>
396
+
397
+ <template>
398
+ <ChatVirtualScroll
399
+ ref="chat"
400
+ :items="messages"
401
+ :estimated-item-size="72"
402
+ :height="600"
403
+ :has-older="hasOlder"
404
+ :loading-older="loadingOlder"
405
+ :load-older-threshold="120"
406
+ item-key="id"
407
+ aria-label="Support conversation"
408
+ @load-older="loadOlder"
409
+ >
410
+ <template #default="{ item }">
411
+ <MessageBubble :message="item" />
412
+ </template>
413
+
414
+ <template #loadingOlder>Loading older messages…</template>
415
+ <template #empty>No messages.</template>
416
+ </ChatVirtualScroll>
417
+
418
+ <button v-if="!chat?.isAtBottom" @click="chat?.scrollToBottom('smooth')">
419
+ Newest messages
420
+ </button>
421
+ </template>
422
+ ```
423
+
424
+ Prepend older data to the same keyed collection. The component calculates the
425
+ height added above the viewport and restores the reader's position.
426
+
427
+ #### Props
428
+
429
+ | Prop | Type | Default | Description |
430
+ | --- | --- | --- | --- |
431
+ | `items` | `readonly T[]` | required | Messages ordered from oldest to newest. |
432
+ | `estimatedItemSize` | `number` | `48` | Initial average message height in pixels. |
433
+ | `height` | `number \| string` | `400` | Viewport height. A number is pixels; `"fill"` maps to `100%`. |
434
+ | `overscan` | `number` | `5` | Extra messages mounted before and after the visible range. |
435
+ | `itemKey` | `keyof T \| (item, index) => PropertyKey` | `id`, `key`, or index | Stable identity; especially important when prepending. |
436
+ | `ariaLabel` | `string` | `"Chat messages"` | Accessible label for the live log. |
437
+ | `stickToBottom` | `boolean` | `true` | Follows appended messages while currently near the bottom. |
438
+ | `bottomThreshold` | `number` | `80` | Distance still considered “at bottom”, in pixels. |
439
+ | `initialScroll` | `"top" \| "bottom"` | `"bottom"` | Initial position after mount. |
440
+ | `hasOlder` | `boolean` | `false` | Indicates that older history is available. |
441
+ | `loadingOlder` | `boolean` | `false` | Prevents duplicate requests and shows the top indicator. |
442
+ | `loadOlderThreshold` | `number` | `120` | Distance from the top that triggers `load-older`, in pixels. |
443
+
444
+ #### Slots
445
+
446
+ | Slot | Scope | Description |
447
+ | --- | --- | --- |
448
+ | `default` | `{ item, index }` | Renders each mounted message. |
449
+ | `empty` | none | Rendered when there are no messages. |
450
+ | `loadingOlder` | none | Sticky top status; defaults to “Loading older messages…”. |
451
+
452
+ #### Events
453
+
454
+ | Event | Payload | When it fires |
455
+ | --- | --- | --- |
456
+ | `scroll` | `{ scrollTop, startIndex, endIndex }` | On container scroll. |
457
+ | `load-older` | none | Near the top when `hasOlder` is true and `loadingOlder` is false. |
458
+ | `bottom-change` | `boolean` | When the viewport enters or leaves the bottom threshold. |
459
+
460
+ #### Exposed API
461
+
462
+ | Member | Description |
463
+ | --- | --- |
464
+ | `isAtBottom` | Readonly boolean indicating whether the user is near the bottom. |
465
+ | `scrollTo(position, options?)` | Scrolls to a pixel offset. |
466
+ | `scrollToIndex(index, { align?, behavior? })` | Scrolls to a zero-based message index. |
467
+ | `scrollToTop(behavior?)` | Scrolls to the oldest loaded message. |
468
+ | `scrollToBottom(behavior?)` | Scrolls to the newest loaded message. |
469
+
470
+ ### ShortMediaFeed
471
+
472
+ `ShortMediaFeed` is a headless, vertically snapping viewport for reels,
473
+ stories, or short-form media. Each item occupies one complete viewport. The
474
+ component manages mounting and navigation; the slot owns video, images,
475
+ playback, audio, and controls.
476
+
477
+ #### Example
478
+
479
+ ```vue
480
+ <script setup lang="ts">
481
+ import { ref } from 'vue'
482
+ import { ShortMediaFeed, type ShortMediaFeedExpose } from 'vue-virtual-flow'
483
+
484
+ const feed = ref<ShortMediaFeedExpose>()
485
+ const activeIndex = ref(0)
486
+ const media = ref([
487
+ { id: 1, src: '/media/one.mp4' },
488
+ { id: 2, src: '/media/two.mp4' },
489
+ { id: 3, src: '/media/three.mp4' },
490
+ ])
491
+ </script>
492
+
493
+ <template>
494
+ <ShortMediaFeed
495
+ ref="feed"
496
+ v-model:active-index="activeIndex"
497
+ :items="media"
498
+ :buffer="1"
499
+ height="100dvh"
500
+ item-key="id"
501
+ aria-label="Video feed"
502
+ >
503
+ <template #default="{ item, active }">
504
+ <video
505
+ :src="item.src"
506
+ :autoplay="active"
507
+ :muted="!active"
508
+ playsinline
509
+ controls
510
+ />
511
+ </template>
512
+
513
+ <template #empty>No media found.</template>
514
+ </ShortMediaFeed>
515
+ </template>
516
+ ```
517
+
518
+ With `buffer="1"`, at most the active item, one previous item, and one next
519
+ item are mounted. Slot-local state is lost when an item leaves the buffer, so
520
+ keep persistent playback state in the parent.
521
+
522
+ #### Props
523
+
524
+ | Prop | Type | Default | Description |
525
+ | --- | --- | --- | --- |
526
+ | `items` | `readonly T[]` | required | Media data passed to the default slot. |
527
+ | `activeIndex` | `number` | `0` | Active zero-based index; supports `v-model:active-index`. |
528
+ | `buffer` | `number` | `1` | Extra mounted items before and after the active item. |
529
+ | `height` | `number \| string` | `"100dvh"` | Feed viewport height. |
530
+ | `itemKey` | `keyof T \| (item, index) => PropertyKey` | `id`, `key`, or index | Stable identity for each item. |
531
+ | `ariaLabel` | `string` | `"Short media feed"` | Accessible label for the feed. |
532
+ | `hasMore` | `boolean` | `false` | Indicates that another page can be loaded. |
533
+ | `loading` | `boolean` | `false` | Prevents duplicate `load-more` requests. |
534
+ | `loadMoreThreshold` | `number` | `2` | Remaining item count that triggers `load-more`. |
535
+
536
+ #### Slots and events
537
+
538
+ | Kind | Name | Payload/scope | Description |
539
+ | --- | --- | --- | --- |
540
+ | Slot | `default` | `{ item, index, active }` | Renders a mounted item; `active` marks the snapped item. |
541
+ | Slot | `empty` | none | Rendered when `items` is empty. |
542
+ | Event | `update:activeIndex` | `number` | Supports `v-model:active-index`. |
543
+ | Event | `change` | `{ index, item }` | The active item changes. |
544
+ | Event | `reach-start` | none | Navigation changes to the first item. |
545
+ | Event | `reach-end` | none | Navigation changes to the last item. |
546
+ | Event | `load-more` | none | Remaining items reach `loadMoreThreshold`. |
547
+
548
+ #### Exposed API and keyboard controls
549
+
550
+ `scrollToIndex(index, options?)` scrolls to an item using standard
551
+ `ScrollToOptions`. The viewport supports Arrow Up/Down, Page Up/Down, Home,
552
+ and End.
553
+
554
+ For an unbounded feed, page data with `load-more` and keep a reasonable item
555
+ window in the parent. Virtualization limits mounted DOM and media elements,
556
+ but it does not remove objects from `items`.
557
+
558
+ ### VirtualCarousel
559
+
560
+ `VirtualCarousel` is a headless horizontal carousel with scroll snapping and
561
+ virtualized slides. `activeIndex` represents the first slide in the current
562
+ view.
563
+
564
+ #### Example
565
+
566
+ ```vue
567
+ <script setup lang="ts">
568
+ import { ref } from 'vue'
569
+ import { VirtualCarousel, type VirtualCarouselExpose } from 'vue-virtual-flow'
570
+
571
+ const carousel = ref<VirtualCarouselExpose>()
572
+ const activeIndex = ref(0)
573
+ const products = ref(loadProducts())
574
+ </script>
575
+
576
+ <template>
577
+ <VirtualCarousel
578
+ ref="carousel"
579
+ v-model:active-index="activeIndex"
580
+ :items="products"
581
+ :slides-per-view="3"
582
+ :slides-to-scroll="1"
583
+ :gap="16"
584
+ :buffer="1"
585
+ :height="320"
586
+ item-key="id"
587
+ aria-label="Featured products"
588
+ >
589
+ <template #default="{ item, index, active, visible }">
590
+ <ProductCard
591
+ :product="item"
592
+ :position="index"
593
+ :active="active"
594
+ :visible="visible"
595
+ />
596
+ </template>
597
+
598
+ <template #empty>No products.</template>
599
+ </VirtualCarousel>
600
+
601
+ <button @click="carousel?.previous()">Previous</button>
602
+ <button @click="carousel?.next()">Next</button>
603
+ </template>
604
+ ```
605
+
606
+ Slide width is calculated as:
607
+
608
+ ```text
609
+ (viewport width - (slidesPerView - 1) × gap) / slidesPerView
610
+ ```
611
+
612
+ `active` is true only for the first slide in the view. `visible` is true for
613
+ every slide in that view. With three visible slides and a buffer of one, up to
614
+ five slides are normally mounted in the middle of the collection.
615
+
616
+ #### Props
617
+
618
+ | Prop | Type | Default | Description |
619
+ | --- | --- | --- | --- |
620
+ | `items` | `readonly T[]` | required | Slide data passed to the default slot. |
621
+ | `activeIndex` | `number` | `0` | First visible slide; supports `v-model:active-index`. |
622
+ | `slidesPerView` | `number` | `1` | Number of slides displayed at once. |
623
+ | `slidesToScroll` | `number` | `1` | Slides moved by `next()` and `previous()`. |
624
+ | `gap` | `number` | `0` | Gap between slides in pixels. |
625
+ | `buffer` | `number` | `1` | Extra mounted slides before and after the visible group. |
626
+ | `height` | `number \| string` | `"auto"` | Carousel height. |
627
+ | `itemKey` | `keyof T \| (item, index) => PropertyKey` | `id`, `key`, or index | Stable identity for each slide. |
628
+ | `ariaLabel` | `string` | `"Carousel"` | Accessible carousel label. |
629
+ | `hasMore` | `boolean` | `false` | Indicates that another page can be loaded. |
630
+ | `loading` | `boolean` | `false` | Prevents duplicate `load-more` requests. |
631
+ | `loadMoreThreshold` | `number` | `2` | Remaining slides after the visible group that trigger `load-more`. |
632
+
633
+ #### Slots and events
634
+
635
+ | Kind | Name | Payload/scope | Description |
636
+ | --- | --- | --- | --- |
637
+ | Slot | `default` | `{ item, index, active, visible }` | Renders each mounted slide. |
638
+ | Slot | `empty` | none | Rendered when `items` is empty. |
639
+ | Event | `update:activeIndex` | `number` | Supports `v-model:active-index`. |
640
+ | Event | `change` | `{ index, item }` | The first visible slide changes. |
641
+ | Event | `reach-start` | none | Navigation changes to the first position. |
642
+ | Event | `reach-end` | none | Navigation changes to the final valid position. |
643
+ | Event | `load-more` | none | Remaining slides reach `loadMoreThreshold`. |
644
+
645
+ #### Exposed API and keyboard controls
646
+
647
+ | Method | Description |
648
+ | --- | --- |
649
+ | `next(behavior?)` | Advances by `slidesToScroll`; defaults to smooth scrolling. |
650
+ | `previous(behavior?)` | Moves back by `slidesToScroll`. |
651
+ | `scrollToIndex(index, options?)` | Makes the index the first visible slide. |
652
+
653
+ The viewport supports Arrow Left/Right, Page Up/Down, Home, and End. A
654
+ long-distance smooth jump becomes immediate so virtualized gaps are not shown.
655
+
656
+ ## Shared recipes
657
+
658
+ ### Viewport height
659
+
660
+ Container-based components accept a number or CSS height string. Numbers are
661
+ converted to pixels. `height="fill"` is shorthand for `height="100%"`:
662
+
663
+ ```vue
664
+ <div class="list-container">
665
+ <VirtualList :items="items" height="fill">
666
+ <template #default="{ item }"><ResultRow :item="item" /></template>
667
+ </VirtualList>
668
+ </div>
669
+ ```
670
+
671
+ ```css
672
+ .list-container {
673
+ height: 100%;
674
+ min-height: 0;
675
+ }
676
+ ```
677
+
678
+ A percentage height only works when its parent has an explicit height.
679
+ Development builds warn when it resolves to `0px`.
680
+ `WindowDynamicVirtualScroll` uses the browser window and has no `height` prop.
681
+
682
+ ### Stable item keys
683
+
684
+ For object items, components try `item.id` and then `item.key`. Primitive items
685
+ fall back to their index. Provide `itemKey` when the identifier has another
686
+ name:
687
+
688
+ ```vue
689
+ <VirtualList :items="users" item-key="uuid" />
690
+
691
+ <VirtualList
692
+ :items="rows"
693
+ :item-key="(row) => `${row.accountId}:${row.sequence}`"
694
+ />
695
+ ```
696
+
697
+ Stable unique keys are essential when inserting, deleting, prepending, or
698
+ reordering. They also associate saved height measurements with the correct
699
+ variable-height item.
700
+
701
+ ### Load more
702
+
703
+ List components use a pixel distance threshold. `ShortMediaFeed` and
704
+ `VirtualCarousel` use a remaining-item threshold.
705
+
706
+ ```vue
707
+ <script setup lang="ts">
708
+ import { computed, ref } from 'vue'
709
+
710
+ const items = ref(loadFirstPage())
711
+ const loading = ref(false)
712
+ const hasMore = computed(() => items.value.length < 1_000)
713
+
714
+ async function loadMore() {
715
+ if (loading.value || !hasMore.value) return
716
+ loading.value = true
717
+ items.value.push(...(await fetchNextPage()))
718
+ loading.value = false
719
+ }
720
+ </script>
721
+
722
+ <template>
723
+ <VirtualList
724
+ :items="items"
725
+ :has-more="hasMore"
726
+ :loading="loading"
727
+ :load-more-threshold="400"
728
+ @load-more="loadMore"
729
+ >
730
+ <template #default="{ item }"><FeedCard :item="item" /></template>
731
+ <template #loading>Loading the next page…</template>
732
+ </VirtualList>
733
+ </template>
734
+ ```
735
+
736
+ The event fires at most once for the current item count. Appending items allows
737
+ the next request. Keep `loading` true for the entire request and set `hasMore`
738
+ to false after the final page.
739
+
740
+ ### Pull to refresh
741
+
742
+ Pull-to-refresh is available on `VirtualList`, `DynamicVirtualScroll`,
743
+ `VirtualScroll`, and `WindowDynamicVirtualScroll`:
744
+
745
+ ```vue
746
+ <VirtualList
747
+ :items="items"
748
+ pull-to-refresh
749
+ :refreshing="refreshing"
750
+ :pull-refresh-threshold="72"
751
+ @refresh="refreshFirstPage"
752
+ >
753
+ <template #default="{ item }"><FeedCard :item="item" /></template>
754
+ <template #refresh="{ progress, refreshing: busy }">
755
+ {{ busy ? 'Refreshing…' : progress >= 1 ? 'Release' : 'Pull down' }}
756
+ </template>
757
+ </VirtualList>
758
+ ```
759
+
760
+ The single-touch gesture begins only at the top. Set `refreshing` to true while
761
+ the async refresh runs, then return it to false to close the indicator.
762
+
763
+ ## TypeScript exports
764
+
765
+ ```ts
766
+ import type {
767
+ ChatVirtualScrollExpose,
768
+ ChatVirtualScrollProps,
769
+ DynamicVirtualScrollProps,
770
+ ItemKey,
771
+ ScrollAlignment,
772
+ ShortMediaFeedChangeEvent,
773
+ ShortMediaFeedExpose,
774
+ ShortMediaFeedProps,
775
+ VirtualCarouselChangeEvent,
776
+ VirtualCarouselExpose,
777
+ VirtualCarouselProps,
778
+ VirtualListExpose,
779
+ VirtualListProps,
780
+ VirtualScrollEvent,
781
+ VirtualScrollExpose,
782
+ VirtualScrollProps,
783
+ WindowDynamicVirtualScrollProps,
784
+ } from 'vue-virtual-flow'
785
+ ```
786
+
787
+ ## Development and release
788
+
789
+ ```bash
790
+ bun install
791
+ bunx playwright install chromium firefox webkit
792
+ bun run dev
793
+ bun run check
794
+ ```
795
+
796
+ The playground includes fixed-height, variable-height, window, chat,
797
+ load-more, short-media, and carousel demos. Before publishing, also run
798
+ `npm pack --dry-run`. GitHub releases are published through
799
+ `.github/workflows/publish.yml`.
800
+
801
+ ## Contributing
802
+
803
+ Issues and pull requests are welcome. Read
804
+ [CONTRIBUTING.md](./CONTRIBUTING.md) before submitting a change.
805
+
806
+ ## License
807
+
808
+ [MIT](./LICENSE)