import { SnapScroller, SnapScrollerContent } from '@vega-ui/react';
<SnapScroller tabIndex={0} aria-label='Dashboard sections' style={{ width: 240, gap: 12 }}>
{['Overview', 'Metrics', 'Billing'].map((section, index) => (
<SnapScrollerContent key={section} index={index}>{section}</SnapScrollerContent>
))}
</SnapScroller>- 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
SnapScroller is a compound component for CSS scroll-snap-based horizontal paging. It exposes direct scroller items, snapping callbacks, and an imperative API for previous/next navigation and keyed scrolling.
Imports
import {
SnapScroller,
SnapScrollerContent,
type SnapScrollerApiRef,
type SnapScrollerContentProps,
type SnapScrollerProps,
} from '@vega-ui/react';Exported Types
SnapScrollerPropsSnapScrollerContentPropsSnapScrollerApiRef
Minimal Composition
import { SnapScroller, SnapScrollerContent } from '@vega-ui/react';
import type { CSSProperties } from 'react';
const pageStyle: CSSProperties = {
width: '100%',
padding: 'var(--spacing-10)',
boxSizing: 'border-box',
borderRadius: 'var(--radius-4)',
background: 'var(--fills-secondary)',
};
export default function MinimalComposition() {Required Parts
SnapScroller: root scroll container and snap coordinatorSnapScrollerContent: one snap-aligned item with a stableindex
Optional Parts
- imperative
apiReffor previous/next and keyed scrolling
Composition Order
SnapScroller- repeated
SnapScrollerContent index={...}
Variants
- Passive snapping with callbacks
- Imperative control through
apiRef - Fixed-size or mixed-width content, though mixed width needs more testing
Edge Cases
- Snap behavior depends heavily on stable item sizing and order.
defaultIndexis only meaningful if the matching keyed item exists.- Imperative control should be coordinated with the same source of truth as highlighted state.
Common Mistakes
- Driving
apiReffrom stale indexes. - Reordering content without reconsidering snap keys.
- Assuming mixed-width items will snap as predictably as uniform pages.
Examples
Basic
Basic: feature carousel
Uniform full-width slides with realistic content. The scroller gets tabIndex={0} and an accessible name so keyboard users can focus it and scroll with the arrow keys; aria-roledescription tells screen readers it is a carousel of slides.
import { Heading, SnapScroller, SnapScrollerContent, Text } from '@vega-ui/react';
import type { CSSProperties } from 'react';
const features = [
{ title: 'Shared inbox', description: 'Route every customer email to one queue and assign it in a click.' },
{ title: 'SLA timers', description: 'See how long each conversation has waited before it breaches the 4-hour target.' },
{ title: 'Saved replies', description: 'Answer refund and shipping questions with approved templates.' },
{ title: 'Reports', description: 'Track first response time and resolution rate for every teammate.' },
];
const slideStyle: CSSProperties = {
display: 'grid',Controlled/Stateful
Controlled/Stateful: page control synced to the snap
The committed index from onScrollSnapChange is the single source of truth: it drives the PageControl and the readout, and a click on a dot only asks the scroller to move with scrollToElementByKey. The state changes once the scroll settles, so the dot and the visible page never disagree.
import {
Heading,
PageControl,
PageControlItem,
SnapScroller,
SnapScrollerContent,
Text,
type SnapScrollerApiRef,
} from '@vega-ui/react';
import { useRef, useState, type CSSProperties } from 'react';
const plans = [Controlled/Stateful: pending versus settled index
onScrollSnapChanging fires while the snap target is still changing, onScrollSnapChange only after the scroll settles. Swipe quickly across several days to see the two readouts diverge: use the pending index for instant visual feedback and the settled one for loading data or updating the URL.
import { SnapScroller, SnapScrollerContent, Text } from '@vega-ui/react';
import { useState } from 'react';
import './examples.css';
const days = [
{ date: 'Mon, Mar 3', slots: 4 },
{ date: 'Tue, Mar 4', slots: 2 },
{ date: 'Wed, Mar 5', slots: 6 },
{ date: 'Thu, Mar 6', slots: 1 },
{ date: 'Fri, Mar 7', slots: 5 },
{ date: 'Sat, Mar 8', slots: 3 },
{ date: 'Sun, Mar 9', slots: 0 },Form/Integration
Form/Integration: checkout wizard
Each step is a Fieldset on its own page. Next validates only the current step with reportValidity() before calling next(), inactive pages are inert so Tab cannot reach hidden fields, and overflowX: 'hidden' stops users from swiping past an invalid step while programmatic scrolling keeps working. The form is noValidate, so Enter advances the wizard instead of failing on fields the browser cannot focus; the final submit reads every step with FormData, and reset returns to the first page.
import {
Button,
Fieldset,
FieldsetLegend,
Label,
SnapScroller,
SnapScrollerContent,
Text,
TextField,
TextFieldInput,
type SnapScrollerApiRef,
} from '@vega-ui/react';Layout/Overlay
Layout/Overlay: gallery in a dialog
The dialog content mounts on every open, so defaultIndex opens the gallery on the room the user clicked. The counter and the disabled arrows follow onScrollSnapChange, and the close button sits in the dialog corner with logical insets.
import {
Button,
Dialog,
DialogClose,
DialogContent,
Heading,
Icon,
IconButton,
SnapScroller,
SnapScrollerContent,
Text,
type SnapScrollerApiRef,Error
Error: failed load with retry
The scroller is rendered only after the data arrives: defaultIndex is applied once on mount, so an empty scroller filled later would ignore it. The first request fails on purpose to show the error message with role='alert' and the retry path.
import { Button, SnapScroller, SnapScrollerContent, Text } from '@vega-ui/react';
import { useRef, useState, type CSSProperties } from 'react';
const reviews = [
{ author: 'Maya R.', text: 'Setup took ten minutes and the import kept all our tags.' },
{ author: 'Daniel K.', text: 'The SLA timers alone saved us from two missed escalations.' },
{ author: 'Priya S.', text: 'Saved replies cut our average response time almost in half.' },
];
type Status = 'idle' | 'loading' | 'error' | 'ready';
const slideStyle: CSSProperties = {Disabled
Disabled: arrows at the first and last item
Previous and next buttons are disabled at the edges based on the committed index, so users see there is nothing more in that direction. Disabled buttons leave the tab order, while the scroller itself stays focusable and scrollable from the keyboard.
import {
Icon,
IconButton,
SnapScroller,
SnapScrollerContent,
Text,
type SnapScrollerApiRef,
} from '@vega-ui/react';
import { ChevronLeft, ChevronRight } from '@vega-ui/icons';
import { useRef, useState, type CSSProperties } from 'react';
const products = [Edge
Edge: ids mapped to indexes
index must be the consecutive position of the item: prev() and next() step by ±1, so gaps such as index={1041} followed by index={1043} break them. Keep business ids in your data, map them to positions with findIndex, and pass the position to defaultIndex and scrollToElementByKey.
import {
Button,
SnapScroller,
SnapScrollerContent,
Text,
type SnapScrollerApiRef,
} from '@vega-ui/react';
import { useRef, useState, type CSSProperties } from 'react';
const invoices = [
{ id: 'INV-1038', customer: 'Northwind Traders', total: '$1,240.00' },
{ id: 'INV-1041', customer: 'Blue Yonder Airlines', total: '$860.50' },Edge: items of different widths
Chips are as wide as their labels. Items near the end cannot scroll to the start edge, so at the end of the row the committed item is the one whose snap point is closest to the maximum scroll position, not necessarily the last one. Use uniform pages when every item must be able to become the current one.
import { SnapScroller, SnapScrollerContent, Text } from '@vega-ui/react';
import { useState } from 'react';
const collections = [
'New in',
'Outerwear',
'Knitwear and cardigans',
'Shoes',
'Bags',
'Accessories',
'Sale',
];Edge: right-to-left layout
With dir='rtl' the first item is on the right and next() moves to the left. The arrow icons are swapped so they point where the content moves, and the button labels are in the page language.
import {
Icon,
IconButton,
SnapScroller,
SnapScrollerContent,
Text,
type SnapScrollerApiRef,
} from '@vega-ui/react';
import { ChevronLeft, ChevronRight } from '@vega-ui/icons';
import { useRef, useState, type CSSProperties } from 'react';
const headlines = [Anatomy
Overview
SnapScroller is a snap-aware scroll container that registers child items by index and uses the scroll-snap hook to derive pending and committed snap positions.
Required Parts
SnapScroller
Required. Owns the scroll container, snap detection, imperative API, and item registry.
SnapScrollerContent
Required in normal composition. Represents one snap-aligned item and registers its index with the root.
Optional Parts
apiRef
Optional imperative surface for prev(), next(), keyed scrolling, and measuring.
Composition Order
SnapScroller- repeated
SnapScrollerContent index={...}
Valid Composition Patterns
Every direct child is a SnapScrollerContent with a consecutive index. With asChild the item renders your own element, here a <figure> with a caption, and still registers as a snap item.
import { SnapScroller, SnapScrollerContent, Text } from '@vega-ui/react';
import type { CSSProperties } from 'react';
const rooms = ['Reception', 'Meeting room', 'Quiet zone', 'Kitchen'];
const figureStyle: CSSProperties = {
display: 'grid',
gap: 'var(--spacing-4)',
width: '100%',
margin: 0,
};
Invalid Composition Patterns
Content without stable indexes
The snap system cannot derive consistent keys.
Child order changing while external code still assumes old indexes
Imperative navigation and highlighted state drift apart.
Treating SnapScrollerContent as arbitrary non-page layout
The component is page-oriented, not a generic overflow row.