Carousel

A run of slides, one at a time, dragged with a finger.

A run of slides, shown one at a time and moved with a finger.

Everything is driven from one value: the position in the run, as a fractional index. 2.4 is two-fifths of the way from the third slide to the fourth, and every slide places itself from its own distance to that number. A drag writes to it, a spring settles it onto a whole slide, and the dots read it.

That is why the track is a gesture rather than a paging scroll view. A scroll view would serve the plain track well enough, but coverflow and stack do not lay their slides along a track at all — they hold them in one place and pull them apart with transforms, so there would be nothing to scroll. One mechanism all four layouts read beats a native scroller for one of them and something else for the rest.

Installation

Carousel ships with the library — no separate install.

import { Carousel, Text, Card, Button, Separator, BookmarkIcon } from 'panelui-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add carousel

Usage

<Carousel loop>
  <Carousel.Content className="h-56">
    {photos.map((photo) => (
      <Carousel.Item key={photo.id} className="px-2">
        <Image source={{ uri: photo.uri }} className="h-full w-full rounded-2xl" />
      </Carousel.Item>
    ))}
  </Carousel.Content>
  <Carousel.Controls className="mt-4" />
</Carousel>

Composition

<Carousel>
  <Carousel.Content>
    <Carousel.Item>
      <Carousel.Caption>…</Carousel.Caption>
    </Carousel.Item>
  </Carousel.Content>
  <Carousel.Controls>
    <Carousel.Previous />
    <Carousel.Dots />
    <Carousel.Next />
  </Carousel.Controls>
</Carousel>
  • Carousel.Content — The box the slides live in. Give it a height — nothing inside it is in the layout flow, so it has no height of its own to take. Its alignment is the resting place every slide is offset from.
  • Carousel.Item — One slide. It reads its own position from the run and takes whatever transform the root's variant asks for.
  • Carousel.Caption — A label shown only while its slide is the active one. It lives inside the slide so it travels with what it names.
  • Carousel.Dots — One dot per slide, the active one drawn as a bar. Length rather than colour alone carries the position. orientation="vertical" runs them down an edge.
  • Carousel.Previous — Step back. Goes dim and stops responding at the start of a run that does not loop.
  • Carousel.Next — Step forward. Goes dim and stops responding at the end of a run that does not loop.
  • Carousel.Controls — The arrows and the dots in one pill, for sitting over the content rather than taking a strip of the layout.

Examples

A track

The plain layout, and the honest choice for content that is read rather than admired. loop runs past the last slide back to the first.

<Carousel loop>
  <Carousel.Content className="h-56">
    {scenes.map((scene) => (
      <Carousel.Item key={scene.title} className="px-2">
        <Image source={{ uri: scene.uri }} className="h-full w-full rounded-2xl" />
      </Carousel.Item>
    ))}
  </Carousel.Content>
  <Carousel.Controls className="mt-4" />
</Carousel>

Interactive

The run fans out around the middle slide and tilts away on both sides, opening wider while a finger is down and settling when it lifts. Set itemSize so the slides overlap.

<Carousel variant="interactive" itemSize={160} defaultIndex={2}>
  <Carousel.Content className="h-56">
    {scenes.map((scene) => (
      <Carousel.Item key={scene.title} className="items-center gap-2">
        <Carousel.Caption>{scene.title}</Carousel.Caption>
        <Image source={{ uri: scene.uri }} className="h-28 w-28 rounded-xl" />
      </Carousel.Item>
    ))}
  </Carousel.Content>
  <Carousel.Controls className="mt-2" />
</Carousel>

Interactive, with nothing under it

The same fan with no arrows and no dots — dragged and nothing else. The right shape when the pictures are the whole point and a control bar would be the only chrome on the screen.

<Carousel variant="interactive" itemSize={160} defaultIndex={2}>
  <Carousel.Content className="h-56">
    {scenes.map((scene) => (
      <Carousel.Item key={scene.title} className="items-center gap-2">
        <Carousel.Caption>{scene.title}</Carousel.Caption>
        <Image source={{ uri: scene.uri }} className="h-28 w-28 rounded-xl" />
      </Carousel.Item>
    ))}
  </Carousel.Content>
</Carousel>

Coverflow

The neighbours turn away from you in perspective. Best with art — covers, posters, photographs — where the turned edge reads as a shelf rather than as a glitch.

<Carousel variant="coverflow" itemSize={150} defaultIndex={2}>
  <Carousel.Content className="h-48">
    {scenes.map((scene) => (
      <Carousel.Item key={scene.title}>
        <Image source={{ uri: scene.uri }} className="h-32 w-24 rounded-xl" />
      </Carousel.Item>
    ))}
  </Carousel.Content>
  <Carousel.Controls className="mt-4" />
</Carousel>

A deck of cards

The active card on top, the next two peeking out behind it, and a drag that takes the top one away. Size the cards — a card as wide as the screen has nothing to stack behind it. Carousel.Dots turned vertical becomes the rail down the card's edge.

<Carousel variant="stack" itemSize={260}>
  <Carousel.Content className="h-72">
    {roles.map((role) => (
      <Carousel.Item key={role.title}>
        <Card className="w-64 gap-0 overflow-hidden">
          <Card.Content className="gap-6 py-4">
            <View className="flex-row items-start justify-between">
              <Text size="sm" muted>{role.rate}</Text>
              <BookmarkIcon size={16} />
            </View>
            <View className="flex-row items-end justify-between gap-3">
              <Text size="2xl" weight="bold" className="flex-1">{role.title}</Text>
              <Carousel.Dots orientation="vertical" className="pb-1" />
            </View>
          </Card.Content>
          <Separator />
          <Card.Content className="flex-row items-center justify-between gap-3 py-3">
            <View className="flex-1">
              <Text size="sm" weight="semibold">{role.company}</Text>
              <Text size="xs" muted>{role.field}</Text>
            </View>
            <Button size="sm" className="rounded-full">View</Button>
          </Card.Content>
        </Card>
      </Carousel.Item>
    ))}
  </Carousel.Content>
</Carousel>

Autoplay

Advancing on a timer. It stops for good the first time a finger lands rather than pausing — someone who has taken hold of the run is reading it, and having it move again a few seconds later is the behaviour everybody hates.

<Carousel loop autoplay autoplayInterval={2200}>
  <Carousel.Content className="h-40">…</Carousel.Content>
  <Carousel.Dots className="mt-4 self-center" />
</Carousel>

Controlled

Pass index to drive the run from your own state, or take a ref for next, previous and scrollTo without holding the index yourself.

const carousel = useRef<CarouselHandle>(null);
const [index, setIndex] = useState(0);

<Carousel index={index} onIndexChange={setIndex} ref={carousel}>

</Carousel>

<Button onPress={() => carousel.current?.next()}>Skip</Button>

API Reference

PropTypeDefaultDescription
classNamestring
variantCarouselVariant'default'How the slides are arranged, and how they move.
orientationCarouselOrientation'horizontal'Which way the run travels. stack is always dealt sideways.
loopbooleanfalseRun past the last slide back to the first, and the other way.
alignCarouselAlign'center'Where the active slide sits. center is what the fanned layouts want; start suits a row of cards running off the trailing edge. coverflow and stack are always centred.
itemSizenumberLength of one slide along the direction of travel, in points. Measured from the carousel's own box when omitted, which is what a full-width slide wants; set it for a run that shows more than one at a time.
autoplaybooleanfalseAdvance on a timer. Stops for good the first time a finger lands.
autoplayIntervalnumber4000Milliseconds each slide is held when autoplay is set.
indexnumberControlled active slide.
defaultIndexnumber0Starting slide when uncontrolled.
onIndexChange(index: number) => void
scrollEnabledbooleantrueLet go of the gesture, for a carousel inside something else that drags.

Carousel.Content

PropTypeDefaultDescription
classNamestring

Carousel.Item

PropTypeDefaultDescription
classNamestring

Carousel.Caption

PropTypeDefaultDescription
classNamestring

Carousel.Dots

PropTypeDefaultDescription
classNamestring
orientationCarouselOrientation'horizontal'Lay the dots down the side instead of across.
interactivebooleantrueJump to a slide by tapping its dot.

Carousel.Arrow

PropTypeDefaultDescription
classNamestring

Carousel.Controls

PropTypeDefaultDescription
classNamestring

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

One value drives all four layouts. The position in the run is a fractional index on the UI thread, and each slide styles itself from its distance to it — so default and coverflow are the same component with different arithmetic rather than two different trees.

Give Carousel.Content a height. Every slide is absolutely positioned, so nothing inside it is in the layout flow and it has no height of its own to take.

itemSize is the length along the direction of travel. It defaults to the carousel's own box, which is what a full-width slide wants. Set it smaller for a run that shows more than one slide at a time, and for interactive, where the overlap is the effect.

Depth is faked, deliberately. React Native's transform has perspective and rotateY but no translateZ, so a slide in coverflow is made to look further away with scale, opacity and z-order rather than actually being moved back.

A short flick and a long drag both move one slide. Either passing a fraction of a slide or exceeding a velocity counts as a move, and the step is measured from the slide the drag started on — rounding the current position would let a slow drag that never reached the threshold still count.

Autoplay does not resume. The first touch ends it for the session.

stack is always dealt sideways, whatever orientation says. A deck has no track to travel along.

In the piled layouts, only the top slide takes touches. zIndex reorders what is drawn but not, on iOS, what is hit — hit testing walks subviews in the order they were added, so without this the last slide rendered would sit in front of every gesture regardless of its z-order. In a deck that slide is the one at the bottom of the pile, drawn at zero opacity.

The middle slide rests at exactly 1. In interactive the fan's depth comes from the others shrinking rather than from the active one growing, because text is rasterised at its layout size and then scaled — a caption on a slide held above 1 is drawn at the wrong raster size for the whole time it is readable. Carousel.Caption fades but never scales, for the same reason: inside the slide, the two would compound.

On this page