import { IndexedSnapScroller, IndexedSnapScrollerContent, useIndexedSnapScrollerContext } from '@vega-ui/react';
const IndexedSlide = () => {
const { index } = useIndexedSnapScrollerContext();
return <IndexedSnapScrollerContent>{MONTHS[index] ?? `Month ${index + 1}`}</IndexedSnapScrollerContent>;
};
<IndexedSnapScroller defaultIndex={0} tabIndex={0} aria-label='Months' style={{ width: 240, gap: 12 }}>
<IndexedSlide />
</IndexedSnapScroller>- Browser support
- Widely available
- Chrome 111
- Edge 111
- Firefox 113
- Safari 15.4
- Safari on iOS 15.4
- Bundle size
- Oklab and OkLCh — comes from the design tokens
- Scroll snap
- scrollend — has a fallback in the code, not counted in the minimum
- scrollbar-width — covered by a -webkit- fallback, not counted in the minimum
- Resize observer
- contain
- scroll-behavior
Summary
IndexedSnapScroller is a compound component built on SnapScroller for virtualized, index-windowed paging. It maintains a sliding logical index sequence and can shift the window as the user reaches scroll boundaries.
Imports
import {
IndexedSnapScroller,
IndexedSnapScrollerContent,
type IndexedSnapScrollerApiRef,
type IndexedSnapScrollerContentProps,
type IndexedSnapScrollerProps,
} from '@vega-ui/react';Exported Types
IndexedSnapScrollerPropsIndexedSnapScrollerContentPropsIndexedSnapScrollerApiRef
Minimal Composition
import {
IndexedSnapScroller,
IndexedSnapScrollerContent,
useIndexedSnapScrollerContext,
} from '@vega-ui/react';
const today = new Date();
const monthFormat = new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' });
function MonthPage() {
const { index } = useIndexedSnapScrollerContext();
const month = new Date(today.getFullYear(), today.getMonth() + index, 1);Required Parts
IndexedSnapScroller: root indexed paging systemIndexedSnapScrollerContent: page content bound to the current virtual index
Optional Parts
apiReffor imperative index-window control
Composition Order
IndexedSnapScroller- one
IndexedSnapScrollerContentchild template
Variants
- Controlled logical
index - Uncontrolled windowed paging with
defaultIndex - Adjustable
size,shift,start, andstartDir
Edge Cases
- Controlled
indexand virtual window resets should stay synchronized. preserveScrollchanges how index-window shifts feel.- The children are re-rendered for each virtual index, so keyed state inside the page content must be intentional.
Common Mistakes
- Assuming child order stays static even though the virtual window shifts.
- Forgetting that one content template is rendered multiple times with different context indexes.
- Mixing external controlled index logic with ad hoc internal offset assumptions.
Examples
Basic
Basic: infinite week strip
One WeekPage template is rendered for every index in the window and reads its own index from useIndexedSnapScrollerContext; index 0 is the current week. The date math lives in the page component, the scroller only supplies the index.
import {
IndexedSnapScroller,
IndexedSnapScrollerContent,
Text,
useIndexedSnapScrollerContext,
} from '@vega-ui/react';
import type { CSSProperties } from 'react';
const today = new Date();
const daysSinceMonday = (today.getDay() + 6) % 7;
const weekdayFormat = new Intl.DateTimeFormat('en-US', { weekday: 'short' });
const weekFormat = new Intl.DateTimeFormat('en-US', { month: 'long', day: 'numeric' });Controlled/Stateful
Controlled/Stateful: month header with external controls
index lives in the parent and onScrollSnapChange writes the settled index back. The arrows call apiRef.next() and prev() for a smooth step; +1 year and This month set index directly, which rebuilds the window around a target outside it and jumps there without animation.
import {
Button,
Icon,
IconButton,
IndexedSnapScroller,
IndexedSnapScrollerContent,
Text,
useIndexedSnapScrollerContext,
type IndexedSnapScrollerApiRef,
} from '@vega-ui/react';
import { ChevronLeft, ChevronRight } from '@vega-ui/icons';
import { useRef, useState } from 'react';Form/Integration
Form/Integration: delivery day in a form
Only the pages of the current window exist in the DOM, so the selected day cannot live in a page: it is kept in form state and submitted through a hidden input read with FormData. Submitting without a day shows a HelperText linked to the group with aria-describedby; past days and Sundays are disabled buttons, and reset clears the value and returns to the current week with setIndexTo(0).
import {
Button,
HelperText,
Icon,
IconButton,
IndexedSnapScroller,
IndexedSnapScrollerContent,
Text,
useIndexedSnapScrollerContext,
type IndexedSnapScrollerApiRef,
} from '@vega-ui/react';
import { ChevronLeft, ChevronRight } from '@vega-ui/icons';Layout/Overlay
Layout/Overlay: month picker in a popover
Pages are years, each with twelve month buttons. The popover renders its content only while open, so defaultIndex reopens the picker on the year of the selected month; choosing a month closes the popover and updates the trigger label.
import {
Button,
IndexedSnapScroller,
IndexedSnapScrollerContent,
Popover,
PopoverContent,
PopoverTrigger,
Text,
useIndexedSnapScrollerContext,
} from '@vega-ui/react';
import { useState } from 'react';
Edge
Edge: bounded range
The window is infinite, so bounds are your job: indexes before the first statement and after the current month render placeholder pages, and when the scroll settles on one of them onScrollSnapChange scrolls back to the nearest real statement. The scroll back is scheduled with requestAnimationFrame because the scroller re-centers its window right after the callback.
import {
IndexedSnapScroller,
IndexedSnapScrollerContent,
Text,
useIndexedSnapScrollerContext,
type IndexedSnapScrollerApiRef,
} from '@vega-ui/react';
import { useRef, useState, type CSSProperties } from 'react';
const today = new Date();
const accountOpened = new Date(2025, 0, 1);
const firstIndex = (accountOpened.getFullYear() - today.getFullYear()) * 12 + accountOpened.getMonth() - today.getMonth();Edge: lazy page data
Each page requests its data on mount and the parent caches results by index, so pages that leave the window and come back are not fetched again. size={7} and shift={3} keep more pages ready on both sides; onOffset fires every time the window grows at an edge.
import {
IndexedSnapScroller,
IndexedSnapScrollerContent,
Text,
useIndexedSnapScrollerContext,
} from '@vega-ui/react';
import { useCallback, useEffect, useRef, useState } from 'react';
const today = new Date();
const dayFormat = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'short', day: 'numeric' });
const moneyFormat = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
Edge: right-to-left months
Month names come from Intl.DateTimeFormat('ar-EG') and the container is dir='rtl': later months are on the left, next() scrolls to the left, and the arrow icons are swapped to match.
import {
Icon,
IconButton,
IndexedSnapScroller,
IndexedSnapScrollerContent,
Text,
useIndexedSnapScrollerContext,
type IndexedSnapScrollerApiRef,
} from '@vega-ui/react';
import { ChevronLeft, ChevronRight } from '@vega-ui/icons';
import { useRef } from 'react';
Anatomy
Overview
IndexedSnapScroller wraps SnapScroller and generates a moving window of logical indexes. Each rendered child is wrapped in context that exposes its current virtual index.
Required Parts
IndexedSnapScroller
Required. Owns index-window generation, shift logic, controlled-index synchronization, and boundary offsets.
IndexedSnapScrollerContent
Required in normal composition. Binds one snapped page to the current virtual index from context.
Optional Parts
apiRef
Optional. Exposes the imperative indexed scroller API.
Composition Order
IndexedSnapScroller- one content template via
IndexedSnapScrollerContent
Valid Composition Patterns
The child is a component, not markup: it calls useIndexedSnapScrollerContext and wraps its output in IndexedSnapScrollerContent. The scroller renders this one template once per index in the window.
import {
IndexedSnapScroller,
IndexedSnapScrollerContent,
useIndexedSnapScrollerContext,
} from '@vega-ui/react';
const today = new Date();
const monthFormat = new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' });
function MonthPage() {
const { index } = useIndexedSnapScrollerContext();
const month = new Date(today.getFullYear(), today.getMonth() + index, 1);Invalid Composition Patterns
Treating the content child as one fixed page
The child is really a template reused for multiple logical indexes.
External index logic that ignores virtual window resets
The controlled index and visible window drift apart.
Relying on stable physical DOM order for logical page identity
Window shifts can invalidate that assumption.