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 number: the position in the run, as a fractional index. 2.4 is two-fifths of the way from the third slide to the fourth. A drag writes to it, a spring settles it onto a whole slide, and the dots read it.
The track is a gesture rather than a paging scroll view, because the coverflow and stack layouts 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 for a scroll view to scroll.
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 carouselUsage
<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'svariantasks 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>Lifecycle contract
| Case | Contract |
|---|---|
| default initialization | defaultIndex owns the initial uncontrolled request until the child count is known. |
| controlled acceptance | A requested index renders only after the owner supplies it. |
| controlled rejection | A rejected request returns to the accepted prop without restarting its spring. |
| external reset | A new owner index renders without being reported as a user request. |
| disabled path | scrollEnabled disables gestures; imperative and explicit controls remain available. |
| prop replacement | Count, loop, interval, and index changes normalize and re-arm from accepted state. |
| unmount cleanup | Autoplay owns one timeout and its effect cleanup cancels it. |
| reduced motion | Accepted movement writes progress immediately instead of starting a spring. |
| callback counts | Invalid controlled corrections report once per request/count/loop identity. |
Executable evidence: packages/panelui/test/carousel-lifecycle.test.mjs (4 tests).
API Reference
Carousel
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
variant | CarouselVariant | 'default' | How the slides are arranged, and how they move. |
orientation | CarouselOrientation | 'horizontal' | Which way the run travels. stack is always dealt sideways. |
loop | boolean | false | Run past the last slide back to the first, and the other way. |
align | CarouselAlign | '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. |
itemSize | number | — | Length 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. |
autoplay | boolean | false | Advance on a timer. Stops at the non-looping end or after the first touch. |
autoplayInterval | number | 4000 | Milliseconds each slide is held when autoplay is set. |
index | number | — | Controlled active slide. Requests move visually only after this value changes; an index invalidated by a child-count change is normalized and reported. |
defaultIndex | number | 0 | Starting slide when uncontrolled. |
onIndexChange | (index: number) => void | — | |
scrollEnabled | boolean | true | Let go of the gesture, for a carousel inside something else that drags. |
Carousel.Content
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Carousel.Item
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Carousel.Caption
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Carousel.Dots
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
orientation | CarouselOrientation | 'horizontal' | Lay the dots down the side instead of across. |
interactive | boolean | true | Jump to a slide by tapping its dot. |
Carousel.Arrow
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Carousel.Controls
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
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.
Carousel.Previous and Carousel.Next forward ordinary view props, but keep ownership of their navigation handler, button name, and boundary-disabled state so caller props cannot turn a visible arrow into a different action.
Controlled and uncontrolled requests share the same owner-driven lifecycle: a controlled gesture reports through onIndexChange but renders the accepted index until its owner updates; an uncontrolled gesture commits locally. Requests for the already accepted index are no-ops, external controlled resets do not emit change callbacks, and leaving controlled mode retains the last accepted index instead of restoring a stale default.
Public exports
Values: Carousel, useCarouselState
Types: CarouselProps, CarouselHandle, CarouselContentProps, CarouselItemProps, CarouselCaptionProps, CarouselDotsProps, CarouselArrowProps, CarouselControlsProps, CarouselVariant, CarouselOrientation, CarouselAlign