Stacked Chips
A stacked chip menu where each chip expands sideways on tap
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated expo-haptics expo-blurCopy and paste the following code into your project.
component/micro-interactions/stacked-chips.tsx
import { BlurView, type BlurViewProps } from "expo-blur";import React, { createContext, useContext, useState } from "react";import { Pressable, View, StyleSheet, Platform, type LayoutChangeEvent, type ViewStyle,} from "react-native";import Animated, { interpolate, useAnimatedProps, useAnimatedStyle, useDerivedValue, withSpring,} from "react-native-reanimated";import { impactAsync, performAndroidHapticsAsync, AndroidHaptics, ImpactFeedbackStyle,} from "expo-haptics";import type { ChipContextType, StackedChipsProps, TriggerProps } from "./types";const AnimatedBlurView = Animated.createAnimatedComponent<BlurViewProps>(BlurView);const ChipContext = createContext<ChipContextType | null>(null);export const StackedChips = ({ children }: StackedChipsProps) => { const parentContext = useContext<ChipContextType | null>(ChipContext); const [isOpen, setIsOpen] = useState<boolean>(false); const [triggerWidth, setTriggerWidth] = useState<number>(0); const toggle = () => setIsOpen((prev) => !prev); const depth = parentContext ? parentContext.depth + 1 : 0; const parentIsOpen = parentContext ? parentContext.isOpen : false; return ( <ChipContext.Provider value={{ isOpen, toggle, triggerWidth, depth, parentIsOpen, setTriggerWidth, }} > <View style={[styles.container, { zIndex: 100 - depth }]}> {children} </View> </ChipContext.Provider> );};StackedChips.Trigger = ({ children, onPress }: TriggerProps) => { const context = useContext<ChipContextType | null>(ChipContext); if (!context) { throw new Error("StackedChips.Trigger must be used within StackedChips"); } const { toggle, setTriggerWidth, depth, parentIsOpen } = context; const progress = useDerivedValue<number>( () => withSpring(parentIsOpen ? 1 : 0), [parentIsOpen], ); const animatedBlurProps = useAnimatedProps<Required<Partial<BlurViewProps>>>( () => ({ intensity: interpolate( progress.value, [0, 0.2, 0.4, 0.8, 1], [0, 2.5, 3.5, 4.5, 0], ), }), ); const handleLayout = <T extends LayoutChangeEvent>(e: T) => { setTriggerWidth(e.nativeEvent.layout.width); }; const handleOnPress = () => { toggle(); onPress?.(); if (Platform.OS === "ios") { impactAsync(ImpactFeedbackStyle.Rigid); } else { performAndroidHapticsAsync(AndroidHaptics.Confirm); } }; return ( <Pressable onPress={handleOnPress} onLayout={handleLayout} style={{ zIndex: 100 - depth }} > <View> {children} {depth > 0 && ( <AnimatedBlurView pointerEvents="none" style={[ StyleSheet.absoluteFillObject, { overflow: "hidden", borderRadius: 30, }, ]} animatedProps={animatedBlurProps} /> )} </View> </Pressable> );};StackedChips.Content = ({ children }: StackedChipsProps) => { const context = useContext<ChipContextType | null>(ChipContext); if (!context) { throw new Error("StackedChips.Content must be used within StackedChips"); } const { isOpen, triggerWidth, depth } = context; const [contentWidth, setContentWidth] = useState(0); const progress = useDerivedValue<number>( () => withSpring(isOpen ? 1 : 0), [isOpen], ); const animatedStyle = useAnimatedStyle<Partial<ViewStyle>>(() => ({ transform: [{ translateX: withSpring(isOpen ? 0 : -contentWidth + 60) }], opacity: withSpring(isOpen ? 1 : 0), marginLeft: withSpring(isOpen ? -50 : 0), })); const animatedBlurProps = useAnimatedProps<Required<Partial<BlurViewProps>>>( () => ({ intensity: interpolate( progress.value, [0, 0.2, 0.4, 0.8, 1], [0, 2.5, 3.5, 4.5, 0], ), }), ); const handleLayout = <T extends LayoutChangeEvent>(e: T) => { setContentWidth(e.nativeEvent.layout.width); }; return ( <Animated.View onLayout={handleLayout} style={[ styles.content, { left: triggerWidth, zIndex: 99 - depth, }, animatedStyle, ]} pointerEvents={isOpen ? "auto" : "none"} > {children} <AnimatedBlurView pointerEvents="none" style={[ StyleSheet.absoluteFillObject, { overflow: "hidden", borderRadius: 30, }, ]} animatedProps={animatedBlurProps} /> </Animated.View> );};const styles = StyleSheet.create({ container: { flexDirection: "row" }, content: { position: "absolute" },});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 { StackedChips } from "@/components/micro-interactions/stacked-chips";export default function App() { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), }); return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="light" /> <View style={styles.content}> <Text style={[ styles.title, fontLoaded && { fontFamily: "HelveticaNowDisplay" }, ]} > Quick Actions </Text> <StackedChips> <StackedChips.Trigger> <View style={[styles.chip, styles.chipPrimary]}> <SymbolView name="plus" size={16} tintColor="#000" /> <Text style={[ styles.chipText, fontLoaded && { fontFamily: "SfProRounded" }, { color: "#000", }, ]} > Create </Text> </View> </StackedChips.Trigger> <StackedChips.Content> <StackedChips> <StackedChips.Trigger> <View style={[ styles.chip, styles.chipSecondary, { marginLeft: 20, }, ]} > <SymbolView name="doc.fill" size={16} tintColor="#fff" /> <Text style={[ styles.chipText, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Document </Text> </View> </StackedChips.Trigger> <StackedChips.Content> <StackedChips> <StackedChips.Trigger> <View style={[styles.chip, styles.chipTertiary]}> <SymbolView name="folder.fill" size={16} tintColor="#fff" /> <Text style={[ styles.chipText, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Folder </Text> </View> </StackedChips.Trigger> <StackedChips.Content> <View style={[styles.chip, styles.chipQuaternary]}> <SymbolView name="photo.fill" size={16} tintColor="#fff" /> <Text style={[ styles.chipText, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Photo </Text> </View> </StackedChips.Content> </StackedChips> </StackedChips.Content> </StackedChips> </StackedChips.Content> </StackedChips> <Text style={[styles.hint, fontLoaded && { fontFamily: "SfProRounded" }]} > Tap to expand </Text> </View> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#0a0a0a", }, content: { flex: 1, // justifyContent: "center", marginTop: 150, paddingHorizontal: 12, gap: 20, }, title: { fontSize: 24, fontWeight: "700", color: "#fff", marginBottom: 8, }, chip: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 10, paddingHorizontal: 20, borderRadius: 50, }, chipPrimary: { backgroundColor: "#fff", }, chipSecondary: { backgroundColor: "#333", marginLeft: 20, paddingHorizontal: 50, }, chipTertiary: { backgroundColor: "#222", marginLeft: 20, paddingHorizontal: 50, }, chipQuaternary: { backgroundColor: "#1a1a1a", marginLeft: 20, }, chipText: { fontSize: 15, fontWeight: "600", color: "#fff", }, hint: { fontSize: 13, color: "#444", marginTop: 8, },});Props
TriggerProps
React Native Reanimated
Expo Blur
Expo Haptics
