All components
0:00/0:00
Appearance

Recorded on iPhone. Seven posters on a prism, spun and tilted by hand, then bent into a cylinder.

Shader

Geometric Carousel

Your images on the walls of a 3D prism you spin and tilt with a finger, standing on a polished floor.

Features

  • Your images on the walls of a 3D prism you spin with a finger
  • Drag up or down to tilt it and see over the rim
  • Switch to a cylinder and the walls bend into one smooth curve
  • A polished floor mirrors it, from a faint sheen to glass
  • Five images make a pentagon, six a hexagon
  • Flick it and it glides, then settles on the next image

Built with

  • Skia runtime shader
  • Ray tracing
  • Reanimated
  • Gesture Handler

Early access

Checkout opens soon

TesseraUI is in early access. Leave your email and we'll let you know the moment you can buy, before anyone else.

No spam. One email when checkout opens.

Or get it with Pro

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 { 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

Give it your images. Each one becomes a wall.

import { GeometricCarousel } from '@/components/geometric-carousel';

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

export function Gallery() {
  return <GeometricCarousel images={posters} width="90%" />;
}

Examples

As a cylinder

The same walls, bent into one round wall. Changing shape while it's on screen morphs between the two.

<GeometricCarousel images={posters} shape="cylinder" />

A wider front image

The front wall takes 70% of the width, and the walls beside it narrow as they turn away.

<GeometricCarousel
  images={posters}
  width="90%"
  height={220}
  centerWidth="70%"
  tilt={12}
/>

Driven from outside

Your own buttons, with a counter that follows drags too.

function PosterGallery() {
  const carousel = useRef<GeometricCarouselRef>(null);
  const [index, setIndex] = useState(0);

  return (
    <View>
      <GeometricCarousel
        ref={carousel}
        images={posters}
        onIndexChange={setIndex}
      />

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

Props

PropTypeDefaultDescription
imagesGeometricCarouselImage[]RequiredOne per wall: a require(), a URL or { uri }. Fewer than 3 repeat; more than 32 are cut.
widthpoints or "%""100%"How wide the whole shape is at rest.
heightpoints or "%"from the imagesHow tall the front image is. Leave it out and the images keep their own shape.
shape"prism" | "cylinder""prism"Flat walls or one round wall. Changing it morphs.
tiltnumber16Degrees the top leans toward you at rest. Negative looks from below.
reflectionPartial<GeometricCarouselReflection> | false—The floor's mirror image. false turns it off. See Advanced.
indexnumber—The wall in front. Pass it to drive the carousel.
onIndexChange(index: number) => void—Called when it settles on a new wall, never on every frame.

Advanced

Everything most apps never need to touch.

Pass only the fields you want to change.

FieldDefaultDescription
reflection.opacity0.3How strongly the floor mirrors the walls, 0–1. 0.7 looks like glass.
reflection.blur0Softness at the reflection's far edge, 0–1. 0 is an exact mirror.
reflection.spread0.5How far across the floor it reaches, 0–1.
shading.sides0.55Brightness of a wall seen edge-on, 0–1. 1 is no shading.
shading.inside0.4Brightness of the inside walls seen over the rim, 0–1.

MethodDescription
scrollTo(index, animated?)Turns to a wall the short way round.
next()Turns one wall forward.
previous()Turns one wall back.

Quick repeated calls queue up: each one turns on from the wall the last one was heading to.

PropDefaultDescription
maxTilt45Degrees a vertical drag can tilt either way. 0 locks the tilt.
perspective4.5Camera distance. 2.6 is wide-angle, 8 is nearly flat.
centerWidth—Share of the width the front wall takes, as a percentage like "80%". Leave it out for equal walls.
morphDuration650Milliseconds for the prism to cylinder morph.
momentum0.2How far a flick carries before it settles. 0 always lands on the nearest wall.
style—Margins and positioning.
accessibilityLabel"Image carousel"Read before "3 of 7".

  • Works on iOS and Android from the same code. It doesn't run on web.
  • The open top, the tilt and the reflection draw outside the box you give it, so leave some room above and below.
  • Without a height, the images keep their own shape and nothing is cropped. With one, they're cropped to fit the walls.
  • Nothing redraws while it's still, so a resting carousel costs nothing.

  • Screen readers read it as "3 of 7", and swiping up or down turns it.
  • With Reduce Motion on, it slides to each image instead of springing, and the shape changes instantly instead of bending.

How it works

The whole shape is drawn by one Skia shader that ray traces it: every pixel casts rays from a camera and finds the wall they hit. A flat wall and a round one are the same arc with a different radius, so one number morphs the prism into a cylinder. The images are packed into a single texture once, and rays that miss the walls bounce off an invisible floor to make the reflection. Spin and tilt are driven by the drag on the UI thread, so React never re-renders while it moves.

Next componentFireworks