# Parallax Carousel

> A paging carousel whose pictures drift inside their cards as they slide, with the centre card brought forward.

- Category: Card
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: `react-native-gesture-handler`, `react-native-reanimated`, `react-native-worklets`
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add parallax-carousel
```

With the shadcn CLI:

```bash
npx shadcn@latest add https://kinetik-ui.dev/r/parallax-carousel.json
```

## Usage

```tsx
import { ParallaxCarousel } from '@/components/kinetik/parallax-carousel';

<ParallaxCarousel
  items={trips.map((t) => ({
    key: t.id,
    image: { uri: t.photo },
    title: t.name,
    subtitle: t.city,
  }))}
  onPress={(item) => router.push(`/trips/${item.key}`)}
/>;
```

The carousel fills its parent's width. Cards are `cardWidth` of it (78% by default) so the neighbours peek in from the sides, and `aspect` sets their height. Captions sit on a dark gradient at the bottom of each picture.

## When to use

- Browsing a handful of visual items: places, products, albums, artworks.
- Feature or story cards on a home screen.

## When not to use

- Long lists that people search or compare. Use a grid or a list.
- Content that has to be read in full; only the centre card has room to breathe.

## Notes

- `parallax` sets how far each picture drifts inside its card; the image is drawn wider than the card to make room.
- A tick plays each time a new card comes to the centre.
- Drag with a finger, a mouse or a pen; past either end the row resists like rubber, and a flick moves up to three cards.
- Tap the centre card to press it; tap a side card to bring it to the centre.
- Screen readers get Next card and Previous card actions on every card.

## ParallaxCarouselProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `readonly CarouselItem[]` |  |  |
| `cardWidth`? | `number` | `0.78` | Card width as a share of the carousel's width, 0.5–0.95. Defaults to 0.78. |
| `aspect`? | `number` | `1.25` | Card height divided by width. Defaults to 1.25. |
| `parallax`? | `number` | `0.6` | How far the image drifts inside its card, 0–1. Defaults to 0.6. |
| `gap`? | `number` | `14` | Space between cards. Defaults to 14. |
| `showProgress`? | `boolean` | `true` | Thin progress line under the cards. Defaults to true. |
| `onPress`? | `(item: CarouselItem, index: number) => void` |  | Called when the centre card is tapped. A tap on a side card brings it to the centre. |
| `onIndexChange`? | `(index: number) => void` |  | Called when a new card reaches the centre. |
| `haptics`? | `boolean` | `true` | A tick as each card reaches the centre. Defaults to true. |
| `testID`? | `string` |  |  |
| `style`? | `StyleProp<ViewStyle>` |  |  |

## Accessibility

- Reduced motion: Cards keep their size and the pictures stay put; cards settle without overshoot.
- Screen readers: Each card is an image (or a button when onPress is set) named by its title or accessibilityLabel, with Next card and Previous card actions; the centre card is selected.
- Touch target: Cards are at least half the carousel's width.

## Performance

One drag offset drives three transforms per card on the UI thread; React renders only when the centre card changes.

- Real-device measurement pending.

## Source

`components/kinetik/parallax-carousel.tsx`

```tsx
import { useState } from 'react';
import {
  Image,
  StyleSheet,
  Text,
  View,
  type AccessibilityActionEvent,
  type ImageSourcePropType,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  Extrapolation,
  interpolate,
  useAnimatedReaction,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';

import { gradient } from '../../lib/kinetik/tokens/gradient';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';

export type CarouselItem = {
  key: string;
  image: ImageSourcePropType;
  title?: string;
  subtitle?: string;
  /** Accessible description of the image. Defaults to the title. */
  accessibilityLabel?: string;
};

export type ParallaxCarouselProps = {
  items: readonly CarouselItem[];
  /** Card width as a share of the carousel's width, 0.5–0.95. Defaults to 0.78. */
  cardWidth?: number;
  /** Card height divided by width. Defaults to 1.25. */
  aspect?: number;
  /** How far the image drifts inside its card, 0–1. Defaults to 0.6. */
  parallax?: number;
  /** Space between cards. Defaults to 14. */
  gap?: number;
  /** Thin progress line under the cards. Defaults to true. */
  showProgress?: boolean;
  /** Called when the centre card is tapped. A tap on a side card brings it to the centre. */
  onPress?: (item: CarouselItem, index: number) => void;
  /** Called when a new card reaches the centre. */
  onIndexChange?: (index: number) => void;
  /** A tick as each card reaches the centre. Defaults to true. */
  haptics?: boolean;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

/** Width of the progress line. */
const TRACK = 72;

/**
 * A paging carousel where each picture sits a little behind its card: as
 * cards slide past, the images move more slowly than the frames that hold
 * them. The centre card comes forward and its caption drifts in. Drag it with
 * a finger or a mouse; a flick carries on to the next cards.
 */
export function ParallaxCarousel({
  items,
  cardWidth = 0.78,
  aspect = 1.25,
  parallax = 0.6,
  gap = 14,
  showProgress = true,
  onPress,
  onIndexChange,
  haptics = true,
  testID,
  style,
}: ParallaxCarouselProps) {
  const { colors, radius, space } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const [width, setWidth] = useState(0);
  const [index, setIndex] = useState(0);
  const offset = useSharedValue(0);
  const start = useSharedValue(0);

  const cardW = Math.round(width * Math.max(0.5, Math.min(0.95, cardWidth)));
  const step = cardW + gap;
  const side = (width - cardW) / 2;
  const height = Math.round(cardW * aspect);
  const last = Math.max(0, items.length - 1);

  const arrive = (next: number) => {
    setIndex(next);
    haptic('tick');
    onIndexChange?.(next);
  };
  // The centre card, reported once per change: live while dragging, and when a card settles.
  const current = useSharedValue(0);
  const report = (i: number) => {
    'worklet';
    if (i === current.get()) return;
    current.set(i);
    scheduleOnRN(arrive, i);
  };
  useAnimatedReaction(
    () => (step > 0 ? Math.max(0, Math.min(last, Math.round(offset.get() / step))) : 0),
    (next, before) => {
      if (before !== null && next !== before) report(next);
    },
    [step, last],
  );

  const settleOn = (i: number, velocity = 0) => {
    'worklet';
    const target = Math.max(0, Math.min(last, i));
    offset.set(withSpring(target * step, spring('smooth', reduced, velocity)));
    report(target);
  };

  const press = (i: number) => {
    const item = items[i];
    if (item) onPress?.(item, i);
  };

  // Past either end the row resists like rubber; a flick carries on to the
  // next cards, up to three at a time.
  const pan = Gesture.Pan()
    .withTestId('parallax-carousel')
    .enabled(items.length > 1 && step > 0)
    .activeOffsetX([-8, 8])
    .failOffsetY([-12, 12])
    .onBegin(() => {
      start.set(offset.get());
    })
    .onUpdate((e) => {
      const raw = start.get() - e.translationX;
      const max = last * step;
      const over = raw < 0 ? raw : raw > max ? raw - max : 0;
      offset.set(raw - over * 0.65);
    })
    .onEnd((e) => {
      const from = Math.round(start.get() / step);
      const nearest = Math.round((offset.get() - e.velocityX * 0.18) / step);
      // A deliberate swipe always moves at least one card.
      const next =
        nearest === from && Math.abs(e.translationX) > step * 0.18
          ? from - Math.sign(e.translationX)
          : Math.max(from - 3, Math.min(from + 3, nearest));
      settleOn(next, -e.velocityX);
    });

  // A tap on the centre card presses it; a tap on a side card brings it over.
  const tap = Gesture.Tap()
    .withTestId('parallax-carousel-tap')
    .enabled(step > 0)
    .maxDistance(8)
    .onEnd((e, ok) => {
      if (!ok) return;
      const i = Math.floor((e.x - side + offset.get() + gap / 2) / step);
      if (i < 0 || i > last) return;
      if (i === Math.round(offset.get() / step)) scheduleOnRN(press, i);
      else settleOn(i);
    });

  const row = useAnimatedStyle(() => ({ transform: [{ translateX: side - offset.get() }] }));

  const progress = useAnimatedStyle(() => {
    const span = Math.max(1, last * step);
    const t = Math.max(0, Math.min(1, offset.get() / span));
    const thumb = 1 / Math.max(1, items.length);
    return {
      width: `${thumb * 100}%`,
      transform: [{ translateX: t * (1 - thumb) * TRACK }],
    };
  });

  const onAccessibilityAction = (i: number) => (e: AccessibilityActionEvent) => {
    const name = e.nativeEvent.actionName;
    if (name === 'activate') press(i);
    else if (name === 'next') settleOn(index + 1);
    else if (name === 'previous') settleOn(index - 1);
  };

  return (
    <View
      testID={testID}
      style={[{ gap: space[4] }, style]}
      onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
    >
      {width > 0 && (
        <GestureDetector gesture={Gesture.Exclusive(pan, tap)}>
          <View collapsable={false} style={[styles.window, { height: height + 14 }]}>
            <Animated.View style={[styles.row, { gap }, row]}>
              {items.map((item, i) => (
                <Card
                  key={item.key}
                  item={item}
                  index={i}
                  active={i === index}
                  width={cardW}
                  height={height}
                  step={step}
                  offset={offset}
                  parallax={reduced ? 0 : Math.max(0, Math.min(1, parallax))}
                  still={reduced}
                  radius={radius.xl}
                  pressable={Boolean(onPress)}
                  onAccessibilityAction={onAccessibilityAction(i)}
                />
              ))}
            </Animated.View>
          </View>
        </GestureDetector>
      )}
      {showProgress && items.length > 1 && (
        <View
          style={[styles.track, { backgroundColor: colors.track }]}
          accessibilityElementsHidden
          importantForAccessibility="no-hide-descendants"
        >
          <Animated.View style={[styles.thumb, { backgroundColor: colors.text }, progress]} />
        </View>
      )}
    </View>
  );
}

function Card({
  item,
  index,
  active,
  width,
  height,
  step,
  offset,
  parallax,
  still,
  radius,
  pressable,
  onAccessibilityAction,
}: {
  item: CarouselItem;
  index: number;
  active: boolean;
  width: number;
  height: number;
  step: number;
  offset: SharedValue<number>;
  parallax: number;
  still: boolean;
  radius: number;
  pressable: boolean;
  onAccessibilityAction: (e: AccessibilityActionEvent) => void;
}) {
  const { colors, font, space, shadow, scheme } = useKinetikTheme();
  // Captions sit on black ink in both schemes; they are drawn over a picture.
  const ink = scheme === 'dark' ? colors.background : colors.text;
  // The image is wider than its card so it has room to drift.
  const extra = width * 0.35 * parallax;

  const frame = useAnimatedStyle(() => {
    if (still) return {};
    const d = Math.abs((offset.get() - index * step) / step);
    return {
      transform: [
        { scale: interpolate(d, [0, 1], [1, 0.9], Extrapolation.CLAMP) },
        { translateY: interpolate(d, [0, 1], [0, 10], Extrapolation.CLAMP) },
      ],
      opacity: interpolate(d, [0, 1.2], [1, 0.55], Extrapolation.CLAMP),
    };
  });
  const picture = useAnimatedStyle(() => {
    const d = (offset.get() - index * step) / step;
    return {
      transform: [
        { translateX: interpolate(d, [-1, 1], [-extra / 2, extra / 2], Extrapolation.CLAMP) },
      ],
    };
  });
  const caption = useAnimatedStyle(() => {
    if (still) return {};
    const d = (offset.get() - index * step) / step;
    return {
      opacity: interpolate(Math.abs(d), [0, 0.6], [1, 0], Extrapolation.CLAMP),
      transform: [{ translateX: interpolate(d, [-1, 1], [40, -40], Extrapolation.CLAMP) }],
    };
  });

  return (
    <Animated.View style={[{ width, height }, frame]}>
      <View
        accessible
        accessibilityRole={pressable ? 'button' : 'image'}
        accessibilityLabel={item.accessibilityLabel ?? item.title}
        accessibilityState={{ selected: active }}
        accessibilityActions={[
          ...(pressable ? [{ name: 'activate' }] : []),
          { name: 'next', label: 'Next card' },
          { name: 'previous', label: 'Previous card' },
        ]}
        onAccessibilityAction={onAccessibilityAction}
        style={[
          styles.card,
          { borderRadius: radius, backgroundColor: colors.surfaceSunken, boxShadow: shadow.lg },
        ]}
      >
        <Animated.View style={[styles.picture, { left: -extra / 2, right: -extra / 2 }, picture]}>
          <Image source={item.image} style={StyleSheet.absoluteFill} resizeMode="cover" />
        </Animated.View>
        {item.title || item.subtitle ? (
          <View
            pointerEvents="none"
            style={[
              styles.scrim,
              { padding: space[5] },
              gradient(
                `linear-gradient(180deg, ${withAlpha(ink, 0)} 0%, ${withAlpha(ink, 0)} 45%, ${withAlpha(ink, 0.78)} 100%)`,
              ),
            ]}
          >
            <Animated.View style={caption}>
              {item.subtitle ? (
                <Text
                  numberOfLines={1}
                  style={{
                    color: colors.highlight,
                    opacity: 0.75,
                    fontSize: font.size.xs,
                    fontWeight: font.weight.semibold,
                    letterSpacing: 1.2,
                    textTransform: 'uppercase',
                  }}
                >
                  {item.subtitle}
                </Text>
              ) : null}
              {item.title ? (
                <Text
                  numberOfLines={2}
                  style={{
                    color: colors.highlight,
                    fontSize: font.size.xl,
                    fontWeight: font.weight.bold,
                    letterSpacing: -0.4,
                    marginTop: 4,
                  }}
                >
                  {item.title}
                </Text>
              ) : null}
            </Animated.View>
          </View>
        ) : null}
      </View>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  window: { overflow: 'hidden' },
  row: { flexDirection: 'row', alignItems: 'flex-start' },
  card: { flex: 1, overflow: 'hidden' },
  picture: { position: 'absolute', top: 0, bottom: 0 },
  scrim: { ...StyleSheet.absoluteFill, justifyContent: 'flex-end' },
  track: { alignSelf: 'center', width: TRACK, height: 3, borderRadius: 2, overflow: 'hidden' },
  thumb: { height: '100%', borderRadius: 2 },
});
```
