AniUI

Vertical Flow Carousel

Vertical coverflow — the centered card scales up and sharpens, its neighbors tilt, shrink, and blur away.

Tap a card — neighbors tilt, shrink, and blur away from the centered one

Web preview — components render natively on iOS & Android
import { VerticalFlowCarousel } from "@/components/ui/vertical-flow-carousel";
<VerticalFlowCarousel data={cards} onIndexChange={setIndex} />

Installation#

npx @aniui/cli add vertical-flow-carousel

Usage#

import { VerticalFlowCarousel } from "@/components/ui/vertical-flow-carousel";
<VerticalFlowCarousel data={cards} onIndexChange={setIndex} />

Source#

components/ui/vertical-flow-carousel.tsx
import React from "react";
import { View } from "react-native";
import { BlurView } from "expo-blur";
import Animated, {
  Extrapolation,
  interpolate,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useSharedValue,
  type SharedValue,
} from "react-native-reanimated";
import { cn } from "@/lib/utils";

// Extra space after the last card so it can still scroll all the way to the
// centered/focused position instead of stopping short once content runs out.
const END_SPACER = 400;

export interface VerticalFlowCarouselProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  data: React.ReactNode[];
  itemHeight?: number;
  spacing?: number;
  rotationAngle?: number;
  scaleInactive?: number;
  opacityInactive?: number;
  showBlur?: boolean;
  blurIntensity?: number;
  snapEnabled?: boolean;
  onIndexChange?: (index: number) => void;
}

export function VerticalFlowCarousel({
  className,
  data,
  itemHeight = 120,
  spacing = 50,
  rotationAngle = 12,
  scaleInactive = 0.85,
  opacityInactive = 0.5,
  showBlur = true,
  blurIntensity = 16,
  snapEnabled = true,
  onIndexChange,
  ...props
}: VerticalFlowCarouselProps) {
  const step = itemHeight + spacing;
  const scrollY = useSharedValue(0);

  const onScroll = useAnimatedScrollHandler({
    onScroll: (e) => {
      scrollY.value = e.contentOffset.y;
    },
  });

  return (
    <View className={cn("flex-1", className)} accessibilityRole="adjustable" {...props}>
      <Animated.ScrollView
        onScroll={onScroll}
        scrollEventThrottle={16}
        snapToInterval={snapEnabled ? step : undefined}
        decelerationRate="fast"
        showsVerticalScrollIndicator={false}
        onMomentumScrollEnd={(e) => onIndexChange?.(Math.round(e.nativeEvent.contentOffset.y / step))}
      >
        {data.map((item, index) => (
          <FlowItem
            key={index}
            index={index}
            count={data.length}
            scrollY={scrollY}
            itemHeight={itemHeight}
            spacing={spacing}
            rotationAngle={rotationAngle}
            scaleInactive={scaleInactive}
            opacityInactive={opacityInactive}
            showBlur={showBlur}
            blurIntensity={blurIntensity}
          >
            {item}
          </FlowItem>
        ))}
      </Animated.ScrollView>
    </View>
  );
}

function FlowItem({
  index,
  count,
  scrollY,
  itemHeight,
  spacing,
  rotationAngle,
  scaleInactive,
  opacityInactive,
  showBlur,
  blurIntensity,
  children,
}: {
  index: number;
  count: number;
  scrollY: SharedValue<number>;
  itemHeight: number;
  spacing: number;
  rotationAngle: number;
  scaleInactive: number;
  opacityInactive: number;
  showBlur: boolean;
  blurIntensity: number;
  children: React.ReactNode;
}) {
  const step = itemHeight + spacing;
  const inputRange = [(index - 1) * step, index * step, (index + 1) * step];

  const style = useAnimatedStyle(() => {
    const scale = interpolate(scrollY.value, inputRange, [scaleInactive, 1, scaleInactive], Extrapolation.CLAMP);
    const opacity = interpolate(scrollY.value, inputRange, [opacityInactive, 1, opacityInactive], Extrapolation.CLAMP);
    const rotateZ = interpolate(scrollY.value, inputRange, [rotationAngle, 0, -rotationAngle], Extrapolation.CLAMP);
    return { opacity, transform: [{ perspective: 1000 }, { scale }, { rotateZ: `${rotateZ}deg` }] };
  });

  const blurStyle = useAnimatedStyle(() => ({
    opacity: interpolate(scrollY.value, inputRange, [1, 0, 1], Extrapolation.CLAMP),
  }));

  return (
    <Animated.View style={[{ width: "100%" }, style, { marginBottom: index === count - 1 ? END_SPACER : spacing }]}>
      <View className="w-full overflow-hidden">
        {children}
        {showBlur && (
          <Animated.View pointerEvents="none" className="absolute inset-0 overflow-hidden" style={blurStyle}>
            <BlurView intensity={blurIntensity} tint="dark" className="absolute inset-0" />
          </Animated.View>
        )}
      </View>
    </Animated.View>
  );
}