Accordion
A customizable accordion with smooth expand and collapse animation
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated expo-haptics sbaiahmed1/react-native-blurCopy 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
