Accordion

A customizable accordion with smooth expand and collapse animation

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated expo-haptics sbaiahmed1/react-native-blur

Copy and paste the following code into your project. component/molecules/accordion.tsx

import { Ionicons } from "@expo/vector-icons";import { BlurView, type BlurViewProps } from "@sbaiahmed1/react-native-blur";import React, { createContext, useContext, useEffect, useState } from "react";import { Platform, Pressable, StyleSheet, View } from "react-native";import Animated, {  interpolate,  useAnimatedProps,  useAnimatedStyle,  useSharedValue,  withTiming,} from "react-native-reanimated";import { AccordionThemes } from "./presets";import type {  AccordionContentProps,  AccordionContextType,  AccordionItemProps,  AccordionProps,  AccordionTriggerProps,} from "./types";import {  AndroidHaptics,  impactAsync,  ImpactFeedbackStyle,  performAndroidHapticsAsync,} from "expo-haptics";const AnimatedBlurView =  Animated.createAnimatedComponent<BlurViewProps>(BlurView);const AccordionContext = createContext<AccordionContextType | null>(null);const AccordionItemContext = createContext<{  value: string;  isOpen: boolean;  icon: "chevron" | "cross";} | null>(null);const useAccordionContext = () => {  const context = useContext(AccordionContext);  if (!context)    throw new Error("Accordion components must be used within Accordion");  return context;};const useAccordionItemContext = () => {  const context = useContext(AccordionItemContext);  if (!context) throw new Error("Trigger and Content must be within Item");  return context;};const ChevronIcon = ({ isOpen }: { isOpen: boolean }) => {  const { theme } = useAccordionContext();  const rotation = useSharedValue<number>(0);  React.useEffect(() => {    rotation.value = withTiming<number>(isOpen ? 1 : 0, { duration: 200 });  }, [isOpen]);  const animatedStyle = useAnimatedStyle(() => ({    transform: [      { rotate: `${interpolate(rotation.value, [0, 1], [0, 180])}deg` },    ],  }));  return (    <>      <Animated.View style={animatedStyle}>        <Ionicons name="chevron-down" size={20} color={theme.iconColor} />      </Animated.View>    </>  );};const CrossIcon = ({ isOpen }: { isOpen: boolean }) => {  const { theme } = useAccordionContext();  const topLineTranslate = useSharedValue(0);  const bottomLineTranslate = useSharedValue(0);  const middleLineOpacity = useSharedValue(1);  React.useEffect(() => {    if (isOpen) {      topLineTranslate.value = withTiming(6, { duration: 200 });      bottomLineTranslate.value = withTiming(-6, { duration: 200 });      middleLineOpacity.value = withTiming(0, { duration: 200 });    } else {      topLineTranslate.value = withTiming(0, { duration: 200 });      bottomLineTranslate.value = withTiming(0, { duration: 200 });      middleLineOpacity.value = withTiming(1, { duration: 200 });    }  }, [isOpen]);  const topLineStyle = useAnimatedStyle(() => ({    transform: [{ translateY: topLineTranslate.value }],  }));  const middleLineStyle = useAnimatedStyle(() => ({    opacity: middleLineOpacity.value,  }));  const bottomLineStyle = useAnimatedStyle(() => ({    transform: [{ translateY: bottomLineTranslate.value }],  }));  return (    <View      style={{        width: 20,        height: 20,        justifyContent: "center",        alignItems: "center",      }}    >      <Animated.View        style={[          {            width: 16,            height: 2,            backgroundColor: theme.iconColor,            borderRadius: 1,            marginBottom: 4,          },          topLineStyle,        ]}      />      <Animated.View        style={[          {            width: 16,            height: 2,            backgroundColor: theme.iconColor,            borderRadius: 1,            marginBottom: 4,          },          middleLineStyle,        ]}      />      <Animated.View        style={[          {            width: 16,            height: 2,            backgroundColor: theme.iconColor,            borderRadius: 1,          },          bottomLineStyle,        ]}      />    </View>  );};const Accordion = ({  children,  type = "single",  theme = AccordionThemes.light,  spacing = 0,}: AccordionProps) => {  const [openItems, setOpenItems] = useState<Set<string>>(new Set());  const toggleItem = (id: string) => {    setOpenItems((prev) => {      const newSet = new Set(prev);      if (type === "single") {        if (newSet.has(id)) {          newSet.clear();        } else {          newSet.clear();          newSet.add(id);        }      } else {        if (newSet.has(id)) {          newSet.delete(id);        } else {          newSet.add(id);        }      }      return newSet;    });  };  const childrenArray = React.Children.toArray(children);  const childrenWithProps = childrenArray.map((child, index) => {    if (React.isValidElement(child)) {      return React.cloneElement(child as React.ReactElement<any>, {        isLast: index === childrenArray.length - 1,      });    }    return child;  });  return (    <AccordionContext.Provider      value={{ openItems, toggleItem, theme, spacing }}    >      <View        style={[          styles.accordion,          {            backgroundColor: theme.backgroundColor,            borderColor: theme.borderColor,          },        ]}      >        {childrenWithProps}      </View>    </AccordionContext.Provider>  );};const AccordionItem = ({  children,  value,  pop = false,  icon = "chevron",  popScale = 1.02,  isLast = false,}: AccordionItemProps) => {  const { openItems, theme, spacing } = useAccordionContext();  const isOpen = openItems.has(value);  const scale = useSharedValue(1);  React.useEffect(() => {    if (pop) {      scale.value = withTiming(isOpen ? popScale : 1, { duration: 200 });    }  }, [isOpen, pop]);  const animatedStyle = useAnimatedStyle(() => ({    transform: [{ scale: scale.value }],  }));  return (    <AccordionItemContext.Provider value={{ value, isOpen, icon }}>      <Animated.View        style={[          styles.item,          {            borderBottomColor: theme.borderColor,            borderBottomWidth: isLast ? 0 : 1,            marginBottom: spacing,          },          pop && animatedStyle,        ]}      >        {children}      </Animated.View>    </AccordionItemContext.Provider>  );};const AccordionTrigger = ({ children }: AccordionTriggerProps) => {  const { toggleItem } = useAccordionContext();  const { value, isOpen, icon } = useAccordionItemContext();  const blurIntensity = useSharedValue(40);  useEffect(() => {    blurIntensity.value = withTiming(isOpen ? 20 : 40, {      duration: 100,    });  }, [isOpen]);  return (    <Pressable      style={({ pressed }) => [styles.trigger]}      onPress={() => {        toggleItem(value);        if (Platform.OS === "android")          performAndroidHapticsAsync(AndroidHaptics.Clock_Tick);        else impactAsync(ImpactFeedbackStyle.Medium);      }}    >      <View style={styles.triggerContent}>        {children}        {icon === "chevron" ? (          <ChevronIcon isOpen={isOpen} />        ) : (          <CrossIcon isOpen={isOpen} />        )}      </View>    </Pressable>  );};const AccordionContent = ({ children }: AccordionContentProps) => {  const { isOpen } = useAccordionItemContext();  const { theme } = useAccordionContext();  const height = useSharedValue<number>(0);  const opacity = useSharedValue<number>(0);  const [contentHeight, setContentHeight] = useState<number>(0);  const [measured, setMeasured] = useState<boolean>(false);  const blurIntensity = useSharedValue<number>(40);  const onLayout = (e: any) => {    const h = e.nativeEvent.layout.height;    if (h > 0 && !measured) {      setContentHeight(h);      setMeasured(true);    }  };  React.useEffect(() => {    if (measured) {      if (isOpen) {        height.value = withTiming(contentHeight, { duration: 200 });        opacity.value = withTiming(1, { duration: 200 });      } else {        height.value = withTiming(0, { duration: 200 });        opacity.value = withTiming(0, { duration: 200 });      }    }  }, [isOpen, measured, contentHeight]);  const animatedStyle = useAnimatedStyle(() => ({    height: height.value,    opacity: measured ? opacity.value : 0,    overflow: "hidden",  }));  React.useEffect(() => {    blurIntensity.value = withTiming(isOpen ? 0 : 20, {      duration: 200,    });  }, [isOpen]);  const animatedBlurProps = useAnimatedProps(() => ({    blurAmount: blurIntensity.value,  }));  return (    <>      {!measured && (        <View onLayout={onLayout} style={styles.measuringContainer}>          <View style={styles.content}>{children}</View>        </View>      )}      <Animated.View style={animatedStyle}>        <View style={styles.contentWrapper}>          <View style={styles.content}>{children}</View>          <AnimatedBlurView            blurType={              theme.backgroundColor === "#18181b" ||              theme.backgroundColor === "#0c4a6e" ||              theme.backgroundColor === "#7c2d12"                ? "dark"                : "systemUltraThinMaterialDark"            }            animatedProps={animatedBlurProps}            style={[              {                overflow: "hidden",                position: "absolute",                width: "100%",                height: "100%",              },            ]}          />        </View>      </Animated.View>    </>  );};Accordion.Item = AccordionItem;Accordion.Trigger = AccordionTrigger;Accordion.Content = AccordionContent;const styles = StyleSheet.create({  accordion: {    width: "100%",    borderRadius: 8,    overflow: "hidden",    borderWidth: 1,  },  item: {    overflow: "hidden",  },  trigger: {    position: "relative",    overflow: "hidden",  },  triggerContent: {    flexDirection: "row",    justifyContent: "space-between",    alignItems: "center",    paddingVertical: 16,    paddingHorizontal: 16,  },  triggerText: {    fontSize: 15,    fontWeight: "500",    flex: 1,    zIndex: 1,  },  measuringContainer: {    position: "absolute",    opacity: 0,    left: 0,    right: 0,  },  contentWrapper: {    position: "absolute",    width: "100%",  },  content: {    paddingHorizontal: 16,    paddingTop: 0,    paddingBottom: 16,  },  contentText: {    fontSize: 14,    lineHeight: 20,  },});export {  AccordionThemes,  Accordion,  AccordionItem,  AccordionTrigger,  AccordionContent,};

Usage

import { View, Text, StyleSheet } 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 { Accordion } from "@/components";const FAQS = [  {    id: "1",    question: "How do I get started?",    answer:      "Simply create an account and follow the onboarding steps. It only takes a minute.",    icon: "questionmark.circle.fill",  },  {    id: "2",    question: "Is my data secure?",    answer:      "Yes, we use end-to-end encryption and never share your data with third parties.",    icon: "lock.fill",  },  {    id: "3",    question: "Can I cancel anytime?",    answer:      "Absolutely. No contracts, no hidden fees. Cancel whenever you want.",    icon: "xmark.circle.fill",  },];export default function App() {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),  });  const darkTheme = {    backgroundColor: "#141414",    borderColor: "#222",    textColor: "#fff",    iconColor: "#666",  };  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="light" />      <View style={styles.content}>        <Text          style={[            styles.title,            fontLoaded && { fontFamily: "HelveticaNowDisplay" },          ]}        >          FAQ        </Text>        <Text          style={[            styles.subtitle,            fontLoaded && { fontFamily: "SfProRounded" },          ]}        >          Common questions        </Text>        <View style={styles.accordionWrapper}>          <Accordion            type="single"            theme={{              backgroundColor: darkTheme.backgroundColor,              borderColor: darkTheme.borderColor,              iconColor: darkTheme.iconColor,              headlineColor: darkTheme.textColor,              subtitleColor: darkTheme.textColor,            }}            spacing={8}          >            {FAQS.map((faq) => (              <Accordion.Item key={faq.id} value={faq.id} icon="chevron">                <Accordion.Trigger>                  <View style={styles.triggerContent}>                    <SymbolView                      name={faq.icon as any}                      size={18}                      tintColor="#888"                    />                    <Text                      style={[                        styles.question,                        fontLoaded && { fontFamily: "SfProRounded" },                      ]}                    >                      {faq.question}                    </Text>                  </View>                </Accordion.Trigger>                <Accordion.Content>                  <Text                    style={[                      styles.answer,                      fontLoaded && { fontFamily: "SfProRounded" },                    ]}                  >                    {faq.answer}                  </Text>                </Accordion.Content>              </Accordion.Item>            ))}          </Accordion>        </View>      </View>    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#0a0a0a",  },  content: {    flex: 1,    paddingHorizontal: 20,    paddingTop: 80,  },  title: {    fontSize: 28,    fontWeight: "700",    color: "#fff",    marginBottom: 4,  },  subtitle: {    fontSize: 15,    color: "#555",    marginBottom: 32,  },  accordionWrapper: {    gap: 8,  },  triggerContent: {    flexDirection: "row",    alignItems: "center",    gap: 12,    flex: 1,  },  question: {    fontSize: 15,    fontWeight: "600",    color: "#fff",    flex: 1,  },  answer: {    fontSize: 14,    color: "#888",    lineHeight: 22,  },});

Props

AccordionItemProps

React Native Reanimated
Expo Haptics
React Native Blur