Stacked Chips

A stacked chip menu where each chip expands sideways on tap

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

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

Copy 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