Skip to content
All components
0:00/0:00

Recorded on iPhone. Swiped in portrait, then rotated to landscape, where more pages fan out along each edge.

Gesture

Rolling Carousel

Posters ride a surface that rolls away at the screen edges, like film around a spool.

FreeGitHub

Features

  • Cards roll around both edges of the screen, like film over a spool
  • A quick swipe keeps its momentum and glides to a stop
  • Tap the left or right side to move one card; tap fast to skip ahead
  • Can loop forever and play through the cards on its own
  • Choose a soft, sharp or default curve, or fine-tune every detail
  • Photos show in full at their own shape, never cropped

Built with

  • Skia Vertices
  • Reanimated worklets
  • Gesture Handler

Installation

1Install dependencies
npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets react-native-gesture-handler
2Wrap your app in GestureHandlerRootView
import { Stack } from 'expo-router';
import { GestureHandlerRootView } from 'react-native-gesture-handler';

export default function RootLayout() {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <Stack />
    </GestureHandlerRootView>
  );
}
3Run a development build (Expo Go can't load Skia)
npx expo run:ios   # or: npx expo run:android

Usage

import { RollingCarousel } from '@/components/rolling-carousel';

const posters = [require('./a.jpg'), require('./b.jpg'), 'https://…/c.jpg'];

export function Gallery() {
  return <RollingCarousel images={posters} style={{ height: 460 }} />;
}

How it works

It is one continuous surface, not a row of rotated cards. A mesh is rebuilt every frame in a Reanimated worklet and drawn with Skia, so pages bend smoothly around the fold instead of hinging at a corner. Dragging, the spring and the drawing all run on the UI thread, and nothing redraws while the carousel is still.

Examples

Presets

Change the shape of the fold, then override any single value.

import { RollingCarousel } from '@/components/rolling-carousel';

const posters = [require('./a.jpg'), require('./b.jpg'), require('./c.jpg')];

export function SoftGallery() {
  return <RollingCarousel images={posters} preset="soft" />;
}

export function SteeperSoftGallery() {
  return (
    <RollingCarousel
      images={posters}
      preset="soft"
      config={{ foldAngle: 85 }}
    />
  );
}

Three across, looping, auto-play

Several cards face-on, an endless strip, and a page every 2.5 seconds.

import { RollingCarousel } from '@/components/rolling-carousel';

const products = [
  require('./shoe.jpg'),
  require('./bag.jpg'),
  require('./watch.jpg'),
  require('./hat.jpg'),
  require('./scarf.jpg'),
];

export function ProductStrip() {
  return (
    <RollingCarousel
      images={products}
      loop
      autoPlay
      style={{ height: 320 }}
      config={{ cardsPerView: 3, gap: 12, autoPlayInterval: 2500 }}
    />
  );
}

Driven from outside

Your own buttons and a page counter, with tapping turned off.

import { useRef, useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import {
  RollingCarousel,
  type RollingCarouselHandle,
} from '@/components/rolling-carousel';

const photos = [require('./a.jpg'), require('./b.jpg'), require('./c.jpg')];

export function PhotoGallery() {
  const carousel = useRef<RollingCarouselHandle>(null);
  const [page, setPage] = useState(0);

  return (
    <View style={styles.screen}>
      <RollingCarousel
        ref={carousel}
        images={photos}
        onIndexChange={setPage}
        tapNavigation={false}
      />

      <View style={styles.controls}>
        <Button title="Back" onPress={() => carousel.current?.previous()} />
        <Text>
          {page + 1} / {photos.length}
        </Text>
        <Button title="Next" onPress={() => carousel.current?.next()} />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1 },
  controls: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
    gap: 16,
    padding: 16,
  },
});

Props

PropTypeDefaultDescription
imagesDataSourceParam[]Requiredrequire() or URLs. Keep the array stable; a new array reloads.
preset"default" | "soft" | "sharp""default"Shape of the fold: a sharp fold with a small roll, a rounder and broader roll, or a crisp crease.
loopbooleanfalseAfter the last page comes the first.
autoPlaybooleanfalseMove through the pages on a timer. Waits while a finger is down; stops at the last page unless loop is on.
onIndexChange(index: number) => void—Called as each page change starts.
configPartial<RollingCarouselConfig>—Fine tuning. See Advanced.
styleStyleProp<ViewStyle>—Fills its parent unless you set a height or flex.

Advanced

Everything most apps never need to touch.

Every field is optional and overrides the preset.

FieldDefaultDescription
cardsPerView1Cards face-on in the middle. Odd keeps one centred.
viewWidth"72%"Width those cards fill together, in points or a percentage. The rest is folds.
gap16Points between cards.
perspective4Pages stacked past each fold: 2 → one, 4 → two, 6 → three.
foldAngle89Degrees the strip turns at each fold, 0–90.
foldRadius0.25Roundness of the fold in card widths. 0 is a crease.
pageBow0.064How much each page curves back in the middle. 0 is flat.
settleDuration1200Milliseconds a page takes to settle, roughly.
settleDamping0.91 never overshoots; lower bounces.
flickLookahead0.2Seconds of flick speed to look ahead. Higher travels further.
overscrollResistance0.3Drag past the ends. 0 is a wall, 1 is free.
autoPlayInterval3000Milliseconds each page rests.
autoPlayStepDuration1200Milliseconds each step takes.
autoPlayDirection"forward""forward" or "backward".
placeholderColors"auto"Gradient while an image loads, as two colours. Auto follows dark mode.
meshColumns20Smoothness of the page curve. Higher costs more.

MethodDescription
next()One page forward from where it rests or is heading.
previous()One page back.
scrollTo(index, { animated })Settle on a page, or jump there with animated: false. With loop, it takes the shorter way round.

PropDefaultDescription
initialIndex0Starting page. Read once; use the ref to move later.
interactivetrueOff, swiping and tapping do nothing; auto-play, the ref and screen readers still move it.
tapNavigationtrueTap the left or right half to move one page.
accessibilityLabel"Gallery"Read before "3 of 8".
ref—A RollingCarouselHandle. See Ref methods.

  • Vertical swipes pass through to a parent ScrollView, so it works inside scrolling screens.
  • Quick taps queue up: three taps move three pages. onIndexChange fires when each move starts, not when it lands.
  • Cards keep their image's shape and grow as large as they fit. To make them bigger, make the carousel bigger.
  • A spinner shows until the first few pages load (at most 4 seconds), then the carousel fades in, so it never appears half-loaded.

  • Screen readers read it as "Gallery, 3 of 8", and swiping up or down moves one page.
  • With Reduce Motion on, pages move with a short, gentle slide instead of a spring, and auto-play turns off.
Next componentTideline Reveal
TesseraUI

Welcome back

Sign in to TesseraUI

Your purchases live in your account, so they open on every device you sign in on.

Sign-in isn’t available right now. Please try again in a moment.

By continuing you agree to the Terms, Licence and Privacy Policy.