AniUI

Carousel Scale

Center-focused carousel with dramatic scale on the active slide.

Tap a card — neighbors shrink, the focused one zooms up

Web preview — components render natively on iOS & Android
import { CarouselScale } from "@/components/ui/carousel-scale";
<CarouselScale data={slides} />

Installation#

npx @aniui/cli add carousel-scale

Usage#

import { CarouselScale } from "@/components/ui/carousel-scale";
<CarouselScale data={slides} />

Source#

components/ui/carousel-scale.tsx
import React, { useState } from "react";
import { View, useWindowDimensions } from "react-native";
import Animated, {
  Extrapolation,
  interpolate,
  runOnJS,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useSharedValue,
  type SharedValue,
} from "react-native-reanimated";
import * as Haptics from "expo-haptics";
import { cn } from "@/lib/utils";

export interface CarouselScaleProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  data: React.ReactNode[];
  itemWidth?: number;
  spacing?: number;
  scaleRange?: [number, number, number];
  rotationRange?: [number, number, number];
  showDots?: boolean;
}

export function CarouselScale({
  className,
  data,
  itemWidth: itemWidthProp,
  spacing = 12,
  scaleRange = [1.6, 1, 1.6],
  rotationRange = [15, 0, -10],
  showDots = true,
  ...props
}: CarouselScaleProps) {
  const { width } = useWindowDimensions();
  const itemWidth = itemWidthProp ?? width * 0.62;
  const snap = itemWidth + spacing;
  const scrollX = useSharedValue(0);
  const [active, setActive] = useState(0);

  const onScroll = useAnimatedScrollHandler({
    onScroll: (e) => {
      scrollX.value = e.contentOffset.x;
    },
    onEndDrag: () => {
      runOnJS(Haptics.impactAsync)(Haptics.ImpactFeedbackStyle.Rigid);
    },
  });

  return (
    <View className={cn("", className)} accessibilityRole="adjustable" {...props}>
      <Animated.ScrollView
        horizontal
        showsHorizontalScrollIndicator={false}
        snapToInterval={snap}
        decelerationRate="fast"
        contentContainerStyle={{ paddingHorizontal: (width - itemWidth) / 2, gap: spacing }}
        onScroll={onScroll}
        scrollEventThrottle={16}
        onMomentumScrollEnd={(e) => setActive(Math.round(e.nativeEvent.contentOffset.x / snap))}
      >
        {data.map((item, index) => (
          <ScaleSlide key={index} index={index} scrollX={scrollX} snap={snap} width={itemWidth} scaleRange={scaleRange} rotationRange={rotationRange}>
            {item}
          </ScaleSlide>
        ))}
      </Animated.ScrollView>
      {showDots && data.length > 1 && (
        <View className="mt-3 flex-row items-center justify-center gap-1.5">
          {data.map((_, i) => (
            <View key={i} className={cn("h-2 rounded-full", i === active ? "w-4 bg-primary" : "w-2 bg-muted-foreground/30")} />
          ))}
        </View>
      )}
    </View>
  );
}

function ScaleSlide({
  index,
  scrollX,
  snap,
  width,
  scaleRange,
  rotationRange,
  children,
}: {
  index: number;
  scrollX: SharedValue<number>;
  snap: number;
  width: number;
  scaleRange: [number, number, number];
  rotationRange: [number, number, number];
  children: React.ReactNode;
}) {
  const style = useAnimatedStyle(() => {
    const input = [(index - 1) * snap, index * snap, (index + 1) * snap];
    const scale = interpolate(scrollX.value, input, scaleRange, Extrapolation.CLAMP);
    const rotate = interpolate(scrollX.value, input, rotationRange, Extrapolation.CLAMP);
    return { transform: [{ scale }, { rotate: `${rotate}deg` }] };
  });
  return (
    <Animated.View style={[{ width }, style]} className="overflow-hidden rounded-2xl">
      {children}
    </Animated.View>
  );
}