Skip to content
v2.5.0
English
Русский
Documentation

SnapScroller

SnapScroller is a compound component for CSS scroll-snap-based horizontal paging.
Overview
Metrics
Billing
snap-scroller.tsx · 7 lines
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
Tested in
23 tests

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

  • SnapScrollerProps
  • SnapScrollerContentProps
  • SnapScrollerApiRef

Minimal Composition

minimal-composition.tsx · 20 lines
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 coordinator
  • SnapScrollerContent: one snap-aligned item with a stable index

Optional Parts

  • imperative apiRef for previous/next and keyed scrolling

Composition Order

  1. SnapScroller
  2. 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.
  • defaultIndex is 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 apiRef from stale indexes.
  • Reordering content without reconsidering snap keys.
  • Assuming mixed-width items will snap as predictably as uniform pages.

Examples

Basic

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.

simple-carousel-shell.tsx · 49 lines
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.

synced-page-control.tsx · 62 lines
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.

highlight-while-scrolling.tsx · 53 lines
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.

checkout-wizard-form.tsx · 158 lines
import {
  Button,
  Fieldset,
  FieldsetLegend,
  Label,
  SnapScroller,
  SnapScrollerContent,
  Text,
  TextField,
  TextFieldInput,
  type SnapScrollerApiRef,
} from '@vega-ui/react';

Layout/Overlay

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.

gallery-in-dialog.tsx · 111 lines
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.

failed-load-retry.tsx · 74 lines
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.

button-controlled-carousel.tsx · 72 lines
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.

ids-mapped-to-indexes.tsx · 83 lines
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.

variable-width-items.tsx · 43 lines
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.

rtl-navigation.tsx · 69 lines
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

  1. SnapScroller
  2. 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.

valid-composition-patterns.tsx · 34 lines
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.