/** * Carousel — a run of slides, one at a time, dragged with a finger. * * ```tsx * * * {photos.map((photo) => ( * * * * ))} * * * * ``` * * ## One shared value, four layouts * * Everything is driven from a single `progress` — 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 styles itself from its own distance to that number. * A pan writes to it, a spring settles it onto a whole number, and the dots * read it. * * That is why the track is a pan gesture rather than a paging `ScrollView`. A * scroll view carries its offset natively and would serve `default` 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 for it to scroll. One mechanism all four read beats a * native scroller for one of them and something else for the rest. * * ## What each layout is for * * - **`default`** — a track. The honest choice for content that is read rather * than admired: a row of cards, a gallery, an onboarding run. * - **`interactive`** — the run fans out around the middle slide and tilts away * on both sides, opening wider while a finger is down. For a small set worth * showing off. * - **`coverflow`** — the neighbours turn away from you in perspective. Best * with art: covers, posters, photographs. * - **`stack`** — a deck. The active card is on top with the next two peeking * out behind it, and dragging takes the top one away. For cards dealt with * one at a time, where the pile is the point. * * Depth is carried by scale, opacity and z-order rather than by moving slides * along z: React Native's transform has `perspective` and `rotateY` but no * `translateZ`, so a slide is made to *look* further away rather than put there. */ import { Children, createContext, forwardRef, isValidElement, useCallback, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState, type ReactNode, } from 'react'; import { Pressable, View, type LayoutChangeEvent, type ViewProps } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Extrapolation, interpolate, runOnJS, useAnimatedStyle, useReducedMotion, useSharedValue, withSpring, withTiming, type SharedValue, } from 'react-native-reanimated'; import { ChevronLeftIcon, ChevronRightIcon } from '../../icons'; import { useControllableState } from '../../primitives/controllable-state'; import { Text, textChildren } from '../../primitives/text'; import { cn } from '../../utils/cn'; import { normalizeCarouselIndex, useCarouselAutoplay, useCarouselIndexLifecycle, } from './carousel-lifecycle'; /** Settles the run onto a whole index. Tuned to stop rather than to bounce. */ const SPRING = { damping: 22, stiffness: 190, mass: 0.55 } as const; /** * Fraction of a slide that has to be dragged past for the move to count, and * the velocity that carries it there regardless. Together they are what lets a * short fast flick and a long slow drag both advance exactly one slide. */ const SNAP_FRACTION = 0.28; const SNAP_VELOCITY = 500; /** How far past the ends the run may be pulled when it does not loop. */ const OVERSCROLL = 0.4; /** Fraction of the finger's travel that lands past an end. */ const RUBBER = 0.35; /** Slides either side of the active one that `coverflow` still draws. */ const COVERFLOW_DEPTH = 2; /** * Gap between coverflow's slides, as a fraction of one slide's length. * * Proportional rather than a fixed number of points: the neighbours have to * clear the middle slide by enough to be read as separate cards, and how much * that is depends entirely on how wide the cards are. */ const COVERFLOW_SPREAD = 0.55; /** Cards behind the top one in `stack`. Two is a pile; five is a mess. */ const STACK_DEPTH = 2; export type CarouselVariant = 'default' | 'interactive' | 'coverflow' | 'stack'; export type CarouselOrientation = 'horizontal' | 'vertical'; export type CarouselAlign = 'start' | 'center'; interface CarouselContextValue { /** Position in the run as a fractional index. The whole component reads it. */ progress: SharedValue; /** 0 to 1 as a finger lands and lifts. Drives the resting and open states. */ engaged: SharedValue; count: number; setCount: (count: number) => void; /** Nearest whole index, in JS — a frame behind `progress`, by design. */ index: number; scrollTo: (index: number) => void; next: () => void; previous: () => void; variant: CarouselVariant; orientation: CarouselOrientation; align: CarouselAlign; loop: boolean; itemSize: number; } const CarouselContext = createContext(null); function useCarousel(component: string): CarouselContextValue { const context = useContext(CarouselContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } /** * A slide's own position in the run, handed down by `Content` rather than * counted up by each slide. * * The alternative is for a slide to register itself and be told its number * back, which works right up until one is inserted in the middle and every * slide after it is wrong until the next render settles. */ const ItemIndexContext = createContext(0); /** Position and controls, for a control of your own outside the built-in ones. */ export function useCarouselState() { const { index, count, scrollTo, next, previous } = useCarousel('useCarouselState'); return { index, count, scrollTo, next, previous }; } /** * Wraps a fractional index into the run. A worklet because the pan needs it * per frame on the UI thread, and the imperative handle needs it in JS. */ function wrap(value: number, count: number) { 'worklet'; return normalizeCarouselIndex(value, count, true); } /** * Signed distance from a slide to the current position, taking the short way * round when the run loops. * * Without the wrap the last slide sits `count - 1` away from the first, and * every layout throws it off screen at the exact moment it should be sliding * in from the other side. */ function distance(index: number, progress: number, count: number, loop: boolean) { 'worklet'; const raw = index - progress; if (!loop || count <= 1) return raw; const half = count / 2; if (raw > half) return raw - count; if (raw < -half) return raw + count; return raw; } /** Drop the empty nodes React omits before assigning slide positions. */ function renderableChildren(children: ReactNode) { // React treats a Fragment as one child here. Keep that keyed boundary rather // than recursively flattening it and changing React's reconciliation model. return Children.toArray(children); } function renderableChildKey(child: ReactNode, index: number) { return isValidElement(child) && child.key !== null ? child.key : index; } export interface CarouselProps extends ViewProps { className?: string; /** How the slides are arranged, and how they move. */ variant?: CarouselVariant; /** Which way the run travels. `stack` is always dealt sideways. */ orientation?: CarouselOrientation; /** Run past the last slide back to the first, and the other way. */ loop?: boolean; /** * 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. */ align?: CarouselAlign; /** * 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. */ itemSize?: number; /** Advance on a timer. Stops at the non-looping end or after the first touch. */ autoplay?: boolean; /** Milliseconds each slide is held when `autoplay` is set. */ autoplayInterval?: number; /** * Controlled active slide. Requests move visually only after this value changes; * an index invalidated by a child-count change is normalized and reported. */ index?: number; /** Starting slide when uncontrolled. */ defaultIndex?: number; onIndexChange?: (index: number) => void; /** Let go of the gesture, for a carousel inside something else that drags. */ scrollEnabled?: boolean; children?: ReactNode; } /** Imperative handle, for driving the run from outside its own controls. */ export interface CarouselHandle { next: () => void; previous: () => void; scrollTo: (index: number) => void; } const CarouselRoot = forwardRef(function CarouselRoot( { className, variant = 'default', orientation = 'horizontal', loop = false, align = 'center', itemSize: itemSizeProp, autoplay = false, autoplayInterval = 4000, index: indexProp, defaultIndex = 0, onIndexChange, scrollEnabled = true, children, ...props }, ref ) { const [size, setSize] = useState({ width: 0, height: 0 }); const [count, setCountValue] = useState(0); const [countKnown, setCountKnown] = useState(false); const [touched, setTouched] = useState(false); const reducedMotion = useReducedMotion(); const { value: requestedIndex, setValue: setIndex, isControlled, } = useControllableState({ value: indexProp, defaultValue: defaultIndex, onChange: onIndexChange, }); const progress = useSharedValue(indexProp ?? defaultIndex); const engaged = useSharedValue(0); // A deck is dealt from the top of a pile, so it is dragged sideways whatever // the run's own direction is — there is no track for it to travel along. const axis: CarouselOrientation = variant === 'stack' ? 'horizontal' : orientation; const along = axis === 'horizontal' ? size.width : size.height; const itemSize = itemSizeProp ?? along ?? 0; const setCount = useCallback((next: number) => { setCountValue(next); setCountKnown(true); }, []); /* * The slide the run is currently travelling to. * * Moving the run and recording where it went are two steps: `scrollTo` starts * the spring, then the new index arrives back through state and the lifecycle * effect asks for it again. Without this the second ask restarts the spring a * frame into the first — from a standstill, so a flick loses the momentum it * was carrying. Remembering the target lets the echo be recognised and * ignored, while a genuine request for the same slide still animates, because * that one comes through `scrollTo` and sets this first. */ const animatedTarget = useRef(null); const animateTo = useCallback( (target: number) => { animatedTarget.current = target; if (reducedMotion) { progress.value = target; } else if (loop) { // Spring to the nearest representation of the target rather than to the // target itself, so a wrap from the last slide to the first travels one // step forward instead of winding all the way back through the run. const shortest = progress.value + distance(target, progress.value, count, true); progress.value = withSpring(shortest, SPRING, (finished) => { if (finished) progress.value = wrap(progress.value, count); }); } else { progress.value = withSpring(target, SPRING); } }, [count, loop, progress, reducedMotion] ); const settleIndex = useCallback( (next: number) => { if (animatedTarget.current === next) return; if (Math.abs(progress.value - next) >= 0.001) animateTo(next); }, [animateTo, progress] ); const index = useCarouselIndexLifecycle({ requestedIndex, count, countKnown, loop, onCorrection: setIndex, onSettledIndex: settleIndex, }); const scrollTo = useCallback( (target: number) => { if (count <= 0) return; const settled = normalizeCarouselIndex(target, count, loop); // A controlled request belongs to its owner. The finger may move the run, // but after release it returns to the current prop until the owner accepts // the request by changing that prop. animateTo(isControlled ? index : settled); setIndex(settled); }, [animateTo, count, index, isControlled, loop, setIndex] ); const next = useCallback(() => scrollTo(index + 1), [index, scrollTo]); const previous = useCallback(() => scrollTo(index - 1), [index, scrollTo]); useImperativeHandle(ref, () => ({ next, previous, scrollTo }), [next, previous, scrollTo]); /* * Autoplay 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 start * moving again a few seconds later is the behaviour everybody hates. */ useCarouselAutoplay({ enabled: autoplay && !touched, index, count, loop, interval: autoplayInterval, onAdvance: scrollTo, }); const settle = useCallback( (target: number) => { setTouched(true); scrollTo(target); }, [scrollTo] ); const pan = useMemo( () => Gesture.Pan() .enabled(scrollEnabled && count > 1 && itemSize > 0) // A carousel inside a scroll view has to let the cross-axis drags // through, or the two fight over every diagonal. .activeOffsetX(axis === 'horizontal' ? [-10, 10] : [-10000, 10000]) .activeOffsetY(axis === 'horizontal' ? [-10000, 10000] : [-10, 10]) .onBegin(() => { engaged.value = withTiming(1, { duration: 160 }); }) .onUpdate((event) => { const moved = axis === 'horizontal' ? event.translationX : event.translationY; const raw = progress.value - moved / itemSize; if (loop) { progress.value = raw; return; } // Off the ends the run follows the finger at a fraction of the // distance, so the edge is felt rather than hit. const last = count - 1; progress.value = raw < 0 ? Math.max(-OVERSCROLL, raw * RUBBER) : raw > last ? Math.min(last + OVERSCROLL, last + (raw - last) * RUBBER) : raw; }) .onEnd((event) => { const velocity = axis === 'horizontal' ? event.velocityX : event.velocityY; const moved = axis === 'horizontal' ? event.translationX : event.translationY; // The slide it started on, not the one it is nearest now: rounding // the current position would let a slow drag that never reached the // threshold still count as a move. const from = Math.round(progress.value + moved / itemSize); const past = Math.abs(moved) / itemSize > SNAP_FRACTION; const flicked = Math.abs(velocity) > SNAP_VELOCITY; const step = past || flicked ? (moved < 0 ? 1 : -1) : 0; runOnJS(settle)(from + step); }) .onFinalize(() => { engaged.value = withTiming(0, { duration: 220 }); }), [scrollEnabled, count, itemSize, axis, engaged, progress, loop, settle] ); const onLayout = (event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; setSize((current) => Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1 ? current : { width, height } ); props.onLayout?.(event); }; const context = useMemo( () => ({ progress, engaged, count, setCount, index, scrollTo: settle, next, previous, variant, orientation: axis, align, loop, itemSize, }), [progress, engaged, count, index, settle, next, previous, variant, axis, align, loop, itemSize] ); return ( {textChildren(children)} ); }); CarouselRoot.displayName = 'Carousel'; /* -------------------------------------------------------------------------- */ /* Track */ /* -------------------------------------------------------------------------- */ export interface CarouselContentProps extends ViewProps { className?: string; children?: ReactNode; } /** * 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. * * It does not move. Every slide places itself from `progress`, which is what * lets `default` and `coverflow` be one component with different arithmetic * rather than two different trees. The alignment here is the *resting* place * every slide is offset from. */ function CarouselContent({ className, children, ...props }: CarouselContentProps) { const { setCount, variant, orientation, align } = useCarousel('Carousel.Content'); const slides = renderableChildren(children); const horizontal = orientation === 'horizontal'; const centred = align === 'center' || variant === 'coverflow' || variant === 'stack'; useEffect(() => { setCount(slides.length); }, [setCount, slides.length]); return ( {slides.map((child, index) => ( {child} ))} ); } export interface CarouselItemProps extends ViewProps { className?: string; children?: ReactNode; } /** One slide. Its transform is whatever the root's `variant` asks for. */ function CarouselItem({ className, children, style, ...props }: CarouselItemProps) { const { progress, engaged, count, index: active, variant, orientation, loop, itemSize } = useCarousel('Carousel.Item'); const index = useContext(ItemIndexContext); const horizontal = orientation === 'horizontal'; /* * In the layouts that pile slides on top of each other, only the one on top * takes touches. * * `zIndex` reorders what is *drawn* but, on iOS, not what is *hit*: hit * testing walks the subviews in the order they were added, so the last slide * rendered sits in front of every gesture regardless of its z-order — and in * a deck that slide is the one at the bottom of the pile, drawn at zero * opacity. An invisible card was swallowing every drag. */ const stacked = variant === 'coverflow' || variant === 'stack'; const inert = stacked && index !== active; const animated = useAnimatedStyle(() => { const d = distance(index, progress.value, count, loop); const a = Math.abs(d); if (variant === 'coverflow') { return { opacity: a > COVERFLOW_DEPTH ? 0 : Math.max(0, 1 - a * 0.25), zIndex: Math.round(100 - a * 10), transform: [ { perspective: 1000 }, { translateX: d * itemSize * COVERFLOW_SPREAD }, // Turned away from the middle and back towards it as it arrives. // Interpolated rather than switched, or a slide would snap flat. { rotateY: `${interpolate(d, [-1, 0, 1], [38, 0, -38], Extrapolation.CLAMP)}deg` }, { scale: interpolate(a, [0, 1], [1.1, 0.92], Extrapolation.CLAMP) }, ], }; } if (variant === 'stack') { // The pile behind the top card is stepped, not spread: each card back is // a little smaller and a little lower. Only the top one is dragged, and // it leaves sideways with a tilt. const behind = Math.min(Math.max(0, d), STACK_DEPTH); const leaving = Math.min(0, d); return { opacity: d < -1 || d > STACK_DEPTH ? 0 : 1, zIndex: Math.round(100 - Math.max(0, d) * 10), transform: [ { translateX: leaving * itemSize * 1.15 }, { translateY: behind * 14 }, { rotate: `${leaving * 12}deg` }, { scale: 1 - behind * 0.06 }, ], }; } if (variant === 'interactive') { /* * Two states blended by `engaged`, rather than switched between: at rest * the run is a tidy fan, and it opens wider under a finger. Blended, so * taking hold of it opens it; switched, it would jump open. */ const open = engaged.value; const spread = d * itemSize; const lift = d * 24 * open; const inactive = 0.78 - open * 0.15; return { opacity: a > 3 ? 0 : 1, zIndex: Math.round(100 - a * 10), transform: [ { translateX: horizontal ? spread : lift }, { translateY: horizontal ? lift : spread }, { rotate: `${d * (5 + open * 15)}deg` }, /* * The slide in the middle rests at exactly 1, and the fan's depth * comes from the others shrinking rather than from it growing. * * Scaling it up instead is what made its caption soft. Text is * rasterised at its layout size and then scaled by the compositor, * so a label on a slide held at 1.05 is drawn at the wrong raster * size for as long as it is the active one — which is the whole time * it is readable, since every other caption has faded out. */ { scale: interpolate(a, [0, 1], [1, inactive], Extrapolation.CLAMP) }, ], }; } // `default` — a plain track, each slide one length from the last. return { transform: [ { translateX: horizontal ? d * itemSize : 0 }, { translateY: horizontal ? 0 : d * itemSize }, ], }; }); // `coverflow` and `stack` are sized by whatever is put in them: they are // built around a card, and a card that had to be the width of the screen // would have nothing to stack behind. const sized = variant === 'default' || variant === 'interactive'; return ( {textChildren(children)} ); } /* -------------------------------------------------------------------------- */ /* Controls */ /* -------------------------------------------------------------------------- */ export interface CarouselCaptionProps extends ViewProps { className?: string; children?: ReactNode; } /** * A slide's label, shown only while that slide is the active one. * * It lives inside the slide rather than beside the run, so it travels with what * it names — a caption that stays put while the picture moves belongs to the * carousel rather than to the picture. */ function CarouselCaption({ className, children, ...props }: CarouselCaptionProps) { const { progress, count, loop } = useCarousel('Carousel.Caption'); const index = useContext(ItemIndexContext); /* * Gone well before the next slide arrives, rather than fading across the * whole step. The layouts that use a caption overlap their slides, so a * caption still at a third of its opacity halfway through a drag is a line * of grey text sitting on top of the neighbouring picture — which reads as a * rendering fault rather than as a transition. */ /* * Opacity only. A scale here would compound with the slide's own — the * caption is inside it — and the text would be resampled twice, once for * each. The slide already carries all the movement this needs to read as * belonging to the picture. */ const animated = useAnimatedStyle(() => { const a = Math.abs(distance(index, progress.value, count, loop)); return { opacity: interpolate(a, [0, 0.3], [1, 0], Extrapolation.CLAMP) }; }); return ( {textChildren(children, (text) => ( {text} ))} ); } export interface CarouselDotsProps extends ViewProps { className?: string; /** Lay the dots down the side instead of across. */ orientation?: CarouselOrientation; /** Jump to a slide by tapping its dot. */ interactive?: boolean; } /** * One dot per slide, the active one drawn as a bar. * * Length rather than colour alone carries the position: a row that differs only * in opacity is unreadable at a glance, and invisible to anyone who cannot * separate the two greys. */ function CarouselDots({ className, orientation = 'horizontal', interactive = true, ...props }: CarouselDotsProps) { const { count, index, scrollTo } = useCarousel('Carousel.Dots'); const horizontal = orientation === 'horizontal'; if (count <= 1) return null; return ( {Array.from({ length: count }, (_unused, dot) => { const active = dot === index; return ( scrollTo(dot)} accessibilityRole="tab" accessibilityLabel={`Slide ${dot + 1} of ${count}`} accessibilityState={{ selected: active }} // 24, not the 48 the buttons get. Dots sit a few points apart, so // a 48 box would either overlap its neighbours — making a tap near // the join land on the wrong slide — or push a five-slide run out // to the width of the screen. 24 clears the minimum with the pitch // still wider than the target, so no two dots contend for a touch. className={cn('items-center justify-center', interactive && 'h-6 w-6')} > ); })} ); } export interface CarouselArrowProps extends ViewProps { className?: string; children?: ReactNode; } /** Body of the two arrows — they differ only in icon, label and direction. */ function makeArrow(direction: 'previous' | 'next') { const name = direction === 'next' ? 'Next' : 'Previous'; function Arrow({ className, children, ...props }: CarouselArrowProps) { const { count, index, loop, next, previous } = useCarousel(`Carousel.${name}`); // Without a loop the ends are dead. An arrow that stays live and does // nothing is worse than one that says it cannot. const disabled = !loop && (direction === 'next' ? index >= count - 1 : index <= 0); return ( {children ?? (direction === 'next' ? ( ) : ( ))} ); } Arrow.displayName = `Carousel.${name}`; return Arrow; } const CarouselPrevious = makeArrow('previous'); const CarouselNext = makeArrow('next'); export interface CarouselControlsProps extends ViewProps { className?: string; } /** * The arrows and the dots in one pill. * * Together rather than scattered, because they answer the same question — where * am I in this, and how do I move — and a bar that reads as one object can sit * over the content instead of taking a strip of the layout for itself. */ function CarouselControls({ className, ...props }: CarouselControlsProps) { return ( ); } export const Carousel = Object.assign(CarouselRoot, { Content: CarouselContent, Item: CarouselItem, Caption: CarouselCaption, Dots: CarouselDots, Previous: CarouselPrevious, Next: CarouselNext, Controls: CarouselControls, });