Material Carousel

A material-inspired horizontal carousel

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated

Copy and paste the following code into your project. component/molecules/material-carousel.tsx

import React, { memo, useMemo } from "react";import {  StyleSheet,  Image,  type NativeScrollEvent,  type NativeSyntheticEvent,  type ViewStyle,  ImageProps,} from "react-native";import Animated, {  Extrapolation,  interpolate,  useAnimatedStyle,  useSharedValue,} from "react-native-reanimated";import type { ICarouselItem, IMaterialCarousel } from "./types";import { IMAGE_WIDTH, MEDIUM_IMAGE, SCREEN_WIDTH, SMALL_IMAGE } from "./const";const AnimatedImage = Animated.createAnimatedComponent<ImageProps>(Image);const CarouselItem: React.FC<ICarouselItem> &  React.FunctionComponent<ICarouselItem> = memo<ICarouselItem>(  ({    item,    scrollX,    index,    renderItem,    dataLength,  }: ICarouselItem):    | (React.ReactNode & React.JSX.Element & React.ReactElement)    | null => {    const isLastImage = useMemo<boolean>(      () => index + 1 === dataLength,      [index],    );    const isSecondLastItem = useMemo<boolean>(      () => index + 2 === dataLength,      [index],    );    const inputRange = useMemo<number[]>(      () => [        (index - 2) * SMALL_IMAGE,        (index - 1) * SMALL_IMAGE,        index * SMALL_IMAGE,        (index + 1) * SMALL_IMAGE,      ],      [index],    );    const outputRange = useMemo<number[]>(      () =>        isLastImage          ? [SMALL_IMAGE, MEDIUM_IMAGE, IMAGE_WIDTH, IMAGE_WIDTH]          : isSecondLastItem            ? [SMALL_IMAGE, MEDIUM_IMAGE, IMAGE_WIDTH, MEDIUM_IMAGE]            : [SMALL_IMAGE, MEDIUM_IMAGE, IMAGE_WIDTH, SMALL_IMAGE],      [isLastImage, isSecondLastItem],    );    const rnStylez = useAnimatedStyle<      Required<        Partial<          Pick<ViewStyle, "marginRight" | "marginLeft" | "width" | "transform">        >      >    >(() => {      return {        marginRight: 16,        marginLeft: index === 0 ? 16 : 0,        width: interpolate(scrollX.value, inputRange, outputRange, "clamp"),        transform: [          {            scale: interpolate(              scrollX.value,              inputRange,              [0.8, 0.9, 1, 0.8],              Extrapolation.CLAMP,            ),          },        ],      };    }, [inputRange, outputRange, isLastImage]);    const containerStylez = useAnimatedStyle<      Required<Partial<Pick<ViewStyle, "width" | "opacity">>>    >(() => {      const outPutRangeItem = isLastImage        ? [0, 1, 1, 1]        : isSecondLastItem          ? [0, 1, 0, 0, 1]          : [0, 0, 1, 0, 1];      return {        width: interpolate(          scrollX.value,          inputRange,          outputRange,          Extrapolation.CLAMP,        ),        opacity: interpolate(          scrollX.value,          inputRange,          outPutRangeItem,          Extrapolation.CLAMP,        ),      };    });    return (      <>        <Animated.View style={[rnStylez]}>          <AnimatedImage source={{ uri: item }} style={[styles.imageStyle]} />        </Animated.View>        <Animated.View          style={[            {              position: "absolute",            },            containerStylez,          ]}        >          {renderItem?.(item, index)}        </Animated.View>      </>    );  },);const MaterialCarousel: React.FC<IMaterialCarousel> &  React.FunctionComponent<IMaterialCarousel> = memo<IMaterialCarousel>(  ({    data,    renderItem: _renderItem,  }: IMaterialCarousel):    | (React.ReactNode & React.JSX.Element & React.ReactElement)    | null => {    const scrollX = useSharedValue<number>(0);    const renderItem = ({ item, index }: { item: string; index: number }) => (      <CarouselItem        item={item}        scrollX={scrollX}        index={index}        renderItem={_renderItem}        dataLength={data.length}      />    );    const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {      scrollX.value = e.nativeEvent.contentOffset.x;    };    return (      <Animated.FlatList        data={data}        renderItem={renderItem}        keyExtractor={(_, idx) => idx.toString()}        horizontal        showsHorizontalScrollIndicator={false}        onScroll={onScroll}        bounces={true}        contentInsetAdjustmentBehavior={"always"}        decelerationRate="fast"        snapToInterval={SMALL_IMAGE}        snapToAlignment="start"        style={{ flexGrow: 0 }}        contentContainerStyle={[          styles.listStyle,          {            paddingLeft: 16,            paddingRight: SCREEN_WIDTH / 2 - IMAGE_WIDTH / 2 + 16,          },        ]}        scrollEventThrottle={16}      />    );  },);export default memo<  React.FC<IMaterialCarousel> & React.FunctionComponent<IMaterialCarousel>>(MaterialCarousel);const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "black",    alignItems: "center",    justifyContent: "center",  },  listStyle: {    alignItems: "center",  },  imageStyle: {    height: 400,    borderRadius: 24,  },});

Usage

import { View, Text, StyleSheet, Image, Dimensions } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { SymbolView } from "expo-symbols";import { useState } from "react";import { CircularCarousel } from "@/components/molecules/circular-carousel";import { LinearGradient } from "expo-linear-gradient";import MaterialCarousel from "@/components/molecules/material-carousel";const { width: SCREEN_WIDTH } = Dimensions.get("window");export default function App() {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),    StretchPro: require("@/assets/fonts/StretchPro.otf"),  });  const [currentIndex, setCurrentIndex] = useState<number>(0);  const DATA = [    {      id: "1",      name: "MY DEAR MELANCHOLY",      artist: "The Weeknd",      year: "2018",      image:        "https://i.pinimg.com/1200x/18/e6/e8/18e6e8e2d2b8c5b4dd77a4ae705bf96a.jpg",    },    {      id: "2",      name: "RANDOM ACCESS MEMORIES",      artist: "Daft Punk",      year: "2013",      image:        "https://i.pinimg.com/1200x/91/52/b2/9152b2dc174934279cda4509b0931434.jpg",    },    {      id: "3",      name: "CURRENTS",      artist: "Tame Impala",      year: "2015",      image:        "https://i.pinimg.com/1200x/1e/38/7f/1e387f131098067f7a9be0bc68b0b6f2.jpg",    },    {      id: "4",      name: "PLASTIC BEACH",      artist: "Gorillaz",      year: "2010",      image:        "https://i.pinimg.com/736x/43/e0/e0/43e0e0a542c0ccfbc5cf1b802bcf2d66.jpg",    },  ];  const ITEMS: string[] = [    "https://i.pinimg.com/736x/74/d1/83/74d183cb89a6b10bd96203322e0d5512.jpg",    "https://i.pinimg.com/736x/7a/52/bc/7a52bc56851dc1a16233308076658e47.jpg",    "https://i.pinimg.com/1200x/31/46/be/3146be20950b9567fd38eb2a5bd00572.jpg",    "https://i.pinimg.com/736x/cb/b2/b7/cbb2b7fc14c96fdb5916c82fa9fd555e.jpg",  ];  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="inverted" />      <View style={styles.header}>        <View>          <Text            style={[styles.title, fontLoaded && { fontFamily: "StretchPro" }]}          >            GALLARY          </Text>          <Text            style={[              styles.subtitle,              fontLoaded && { fontFamily: "HelveticaNowDisplay" },            ]}          >            Explore your recent favorites.          </Text>        </View>        <View style={styles.headerRight}>          <SymbolView            name="square.stack.3d.up.fill"            size={20}            tintColor="#fff"          />        </View>      </View>      <MaterialCarousel data={ITEMS} renderItem={(item, index) => <></>} />    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#0a0a0a",  },  header: {    flexDirection: "row",    justifyContent: "space-between",    alignItems: "center",    paddingHorizontal: 24,    paddingTop: 70,    paddingBottom: 32,  },  title: {    fontSize: 28,    color: "#fff",    letterSpacing: 2,  },  subtitle: {    fontSize: 12,    color: "#aaa",  },  headerRight: {    width: 40,    height: 40,    borderRadius: 20,    backgroundColor: "#1a1a1a",    justifyContent: "center",    alignItems: "center",  },  card: {    width: "100%",    height: 340,    borderRadius: 24,    overflow: "hidden",  },  cardImage: {    width: 300,    height: "100%",    resizeMode: "cover",  },  cardGradient: {    ...StyleSheet.absoluteFillObject,  },  cardContent: {    position: "absolute",    bottom: 0,    left: 0,    right: 0,    padding: 20,    gap: 8,  },  albumName: {    fontSize: 16,    color: "#fff",    letterSpacing: 1,  },  artistRow: {    flexDirection: "row",    alignItems: "center",    gap: 6,  },  artistText: {    fontSize: 13,    color: "rgba(255,255,255,0.7)",  },  dot: {    width: 3,    height: 3,    borderRadius: 1.5,    backgroundColor: "rgba(255,255,255,0.4)",  },  yearText: {    fontSize: 13,    color: "rgba(255,255,255,0.5)",  },  footer: {    paddingHorizontal: 24,    marginTop: 32,    gap: 20,  },  nowPlaying: {    flexDirection: "row",    alignItems: "center",    justifyContent: "space-between",    backgroundColor: "#141414",    padding: 12,    borderRadius: 16,  },  nowPlayingLeft: {    flexDirection: "row",    alignItems: "center",    gap: 12,    flex: 1,  },  nowPlayingImage: {    width: 48,    height: 48,    borderRadius: 10,  },  nowPlayingInfo: {    flex: 1,    gap: 2,  },  nowPlayingTitle: {    fontSize: 14,    fontWeight: "600",    color: "#fff",  },  nowPlayingArtist: {    fontSize: 12,    color: "#666",  },  nowPlayingControls: {    width: 44,    height: 44,    borderRadius: 22,    backgroundColor: "#fff",    justifyContent: "center",    alignItems: "center",  },  dots: {    flexDirection: "row",    justifyContent: "center",    alignItems: "center",    gap: 6,  },  dotIndicator: {    width: 6,    height: 6,    borderRadius: 3,    backgroundColor: "#333",  },  dotIndicatorActive: {    width: 20,    backgroundColor: "#fff",  },});

Props

ICarouselItem

React Native Reanimated