Material Carousel
A material-inspired horizontal carousel
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimatedCopy 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
