Scrollable Search
A pull to search layout with smooth scroll animations
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated react-native-worklets react-native-safe-area-context expo-blurCopy and paste the following code into your project.
component/base/scrollable-search.tsx
// @ts-checkimport React, { createContext, useContext, useRef, useState, useMemo, memo, useEffect,} from "react";import { BlurView, type BlurViewProps } from "expo-blur";import { Keyboard, Platform, StyleSheet, TouchableOpacity, View, type ViewStyle,} from "react-native";import Animated, { Extrapolation, interpolate, useAnimatedProps, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withSpring, withTiming,} from "react-native-reanimated";import { SafeAreaView } from "react-native-safe-area-context";import { scheduleOnRN } from "react-native-worklets";import type { IAnimatedComponent, IFocusedScreen, IOverlay, IScrollableSearch, IScrollableSearchContext, IScrollContent,} from "./types";const AnimatedBlurView = Animated.createAnimatedComponent<BlurViewProps>(BlurView);const ScrollableSearchContext = createContext<IScrollableSearchContext | null>( null,);const useScrollableSearch = () => { const context = useContext<IScrollableSearchContext | null>( ScrollableSearchContext, ); if (!context) { throw new Error( "ScrollableSearch compound components must be rendered within <ScrollableSearch>", ); } return context;};const ScrollableSearchRoot: React.FC<IScrollableSearch> & React.FunctionComponent<IScrollableSearch> = memo<IScrollableSearch>( ({ children, }: IScrollableSearch): React.ReactNode & React.JSX.Element & React.ReactNode => { const [isFocused, setIsFocused] = useState<boolean>(false); const dismissTimeoutRef = useRef<NodeJS.Timeout | null>(null); const scrollY = useSharedValue<number>(0); const pullDistance = useSharedValue<number>(0); const shouldAutoFocus = useSharedValue<boolean>(false); const onPullToFocusCallback = useRef<(() => void) | null>(null); const setIsFocusedWithDelay = <T extends boolean>(focused: T) => { if (dismissTimeoutRef.current) { clearTimeout(dismissTimeoutRef.current); dismissTimeoutRef.current = null; } if (!focused) { dismissTimeoutRef.current = setTimeout<[]>(() => { Keyboard.dismiss(); }, 450); } setIsFocused(focused); }; useEffect(() => { return () => { if (dismissTimeoutRef.current) { clearTimeout(dismissTimeoutRef.current); } }; }, []); const value = useMemo<IScrollableSearchContext>( () => ({ isFocused, setIsFocused: setIsFocusedWithDelay, scrollY, pullDistance, shouldAutoFocus, onPullToFocusCallback, }), [isFocused, scrollY, pullDistance, shouldAutoFocus], ); return ( <ScrollableSearchContext.Provider value={value}> <View style={styles.wrapper}>{children}</View> </ScrollableSearchContext.Provider> ); },);const ScrollContent: React.FC<IScrollContent> & React.FunctionComponent<IScrollContent> = memo<IScrollContent>( ({ children, pullThreshold = 80, }: IScrollContent): React.ReactNode & React.JSX.Element & React.ReactNode => { const { isFocused, scrollY, pullDistance, shouldAutoFocus, onPullToFocusCallback, } = useScrollableSearch(); const triggerFocus = () => { if (onPullToFocusCallback.current) { onPullToFocusCallback.current(); } }; const onScroll = useAnimatedScrollHandler({ onScroll: (event) => { const offsetY = event.contentOffset.y; scrollY.value = offsetY; if (offsetY < 0) { pullDistance.value = Math.abs(offsetY); if (pullDistance.value > pullThreshold && !shouldAutoFocus.value) { shouldAutoFocus.value = true; scheduleOnRN(triggerFocus); } } else { pullDistance.value = 0; } }, onEndDrag: () => { "worklet"; shouldAutoFocus.value = false; }, }); const animatedStyle = useAnimatedStyle<Pick<ViewStyle, "opacity">>(() => { return { opacity: 1, }; }); return ( <Animated.View style={[StyleSheet.absoluteFill, animatedStyle]} pointerEvents={isFocused ? "none" : "auto"} > <Animated.ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false} onScroll={onScroll} scrollEventThrottle={8} bounces={true} > {children} </Animated.ScrollView> </Animated.View> ); },);const AnimatedComponent: React.FC<IAnimatedComponent> & React.FunctionComponent<IAnimatedComponent> = memo<IAnimatedComponent>( ({ children, focusedOffset = -90, unfocusedOffset = 30, enablePullEffect = true, onPullToFocus, springConfig = { damping: 18, stiffness: 120, mass: 0.6, }, }: IAnimatedComponent): React.ReactNode & React.JSX.Element & React.ReactNode => { const { isFocused, scrollY, pullDistance, onPullToFocusCallback } = useScrollableSearch(); useEffect(() => { if (onPullToFocus) { onPullToFocusCallback.current = onPullToFocus; } return () => { onPullToFocusCallback.current = null; }; }, [onPullToFocus, onPullToFocusCallback]); const animatedSearchStylez = useAnimatedStyle< Pick<ViewStyle, "transform" | "shadowOpacity"> >(() => { const scale = enablePullEffect ? interpolate( pullDistance.value, [0, 60, 120], [1, 1.02, 1.05], Extrapolation.CLAMP, ) : 1; const shadowOpacity = enablePullEffect ? interpolate( pullDistance.value, [0, 60], [0.05, 0.2], Extrapolation.CLAMP, ) : 0.05; const translateY = interpolate( scrollY.value, [0, scrollY.value], [0, -scrollY.value], Extrapolation.CLAMP, ); return { transform: [{ scale }, { translateY }], shadowOpacity, }; }); const animatedContainerStylez = useAnimatedStyle< Pick<ViewStyle, "opacity" | "transform"> >(() => { const baseOffset = isFocused ? focusedOffset : unfocusedOffset; const opacity = interpolate( scrollY.value, [0, 100], [1, 0], Extrapolation.CLAMP, ); const translateY = withSpring(baseOffset, springConfig); return { transform: [{ translateY }], opacity, }; }, [isFocused]); return ( <Animated.View style={[styles.animatedContainer, animatedContainerStylez]} > <SafeAreaView edges={["top"]}> <Animated.View style={[animatedSearchStylez]}> {children} </Animated.View> </SafeAreaView> </Animated.View> ); },);const Overlay: React.FC<IOverlay> & React.FunctionComponent<IOverlay> = memo<IOverlay>( ({ children, onPress, enableBlur = true, blurTint = "dark", maxBlurIntensity = 80, }: IOverlay): React.ReactNode & React.JSX.Element & React.ReactNode => { const { isFocused, pullDistance, setIsFocused } = useScrollableSearch(); const animatedBlurProps = useAnimatedProps(() => { if (isFocused) { return { intensity: maxBlurIntensity, }; } const intensity = interpolate( pullDistance.value, [0, 20, 80], [0, 30, maxBlurIntensity], Extrapolation.CLAMP, ); return { intensity, }; }, [isFocused]); const animatedStyle = useAnimatedStyle(() => { if (isFocused) { return { opacity: withTiming(1, { duration: 350 }), }; } const opacity = interpolate( pullDistance.value, [0, 10], [0, 1], Extrapolation.CLAMP, ); return { opacity: pullDistance.value > 0 ? opacity : withTiming(0, { duration: 400 }), }; }, [isFocused]); const handlePress = () => { if (isFocused) { setIsFocused(false); } onPress?.(); }; return ( <Animated.View style={[styles.overlay, animatedStyle]} pointerEvents={isFocused ? "auto" : "none"} > <TouchableOpacity style={StyleSheet.absoluteFill} activeOpacity={1} onPress={handlePress} > {enableBlur ? ( Platform.OS === "ios" ? ( <AnimatedBlurView style={StyleSheet.absoluteFill} tint={blurTint} animatedProps={animatedBlurProps} > {children} </AnimatedBlurView> ) : ( <View style={[ StyleSheet.absoluteFill, { backgroundColor: "rgba(0,0,0,1)", }, ]} > {children} </View> ) ) : ( <Animated.View style={StyleSheet.absoluteFill}> {children} </Animated.View> )} </TouchableOpacity> </Animated.View> ); }, );const FocusedScreen: React.FC<IFocusedScreen> & React.FunctionComponent<IFocusedScreen> = memo<IFocusedScreen>( ({ children, }: IFocusedScreen): React.ReactNode & React.JSX.Element & React.ReactNode => { const { isFocused } = useScrollableSearch(); const animatedStylez = useAnimatedStyle(() => { return { opacity: withTiming(isFocused ? 1 : 0, { duration: isFocused ? 350 : 400, }), }; }, [isFocused]); return ( <Animated.View style={[StyleSheet.absoluteFill, animatedStylez]} pointerEvents={isFocused ? "box-none" : "none"} > {children} </Animated.View> ); },);const ScrollableSearch = Object.assign( memo<IScrollableSearch>(ScrollableSearchRoot), { ScrollContent, AnimatedComponent, Overlay, FocusedScreen, },);export { useScrollableSearch, ScrollableSearch };const styles = StyleSheet.create({ wrapper: { flex: 1, backgroundColor: "#0A0A0A", }, scrollView: { flex: 1, }, scrollContent: { paddingTop: 100, paddingBottom: 20, }, animatedContainer: { position: "absolute", top: 90, left: 0, right: 0, zIndex: 100, backgroundColor: "transparent", }, overlay: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, zIndex: 50, },});Usage
import React from "react";import { StyleSheet, Text, View, Pressable, ScrollView } from "react-native";import { SafeAreaView } from "react-native-safe-area-context";import { SymbolView } from "expo-symbols";import { useFonts } from "expo-font";import { ScrollableSearch, useScrollableSearch,} from "@/components/base/scrollable-search";const PLACES = [ { id: "1", title: "Tokyo", subtitle: "Japan", symbol: "building.2.fill" }, { id: "2", title: "Kyoto", subtitle: "Japan", symbol: "leaf.fill" }, { id: "3", title: "Mount Fuji", subtitle: "Nature", symbol: "mountain.2.fill", }, { id: "4", title: "Osaka", subtitle: "Japan", symbol: "sparkles" },];const RECENT = ["Tokyo", "Kyoto", "Osaka"];const SearchBar = ({ fontLoaded }: { fontLoaded: boolean }) => { const { setIsFocused, isFocused } = useScrollableSearch(); return ( <ScrollableSearch.AnimatedComponent onPullToFocus={() => setIsFocused(true)} focusedOffset={-70} unfocusedOffset={53} > <Pressable style={styles.searchBar} onPress={() => setIsFocused(!isFocused)} > <SymbolView name="magnifyingglass" size={18} tintColor="#666" /> <Text style={[ styles.searchPlaceholder, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Search destinations... </Text> </Pressable> </ScrollableSearch.AnimatedComponent> );};const Content = ({ fontLoaded }: { fontLoaded: boolean }) => { const { setIsFocused } = useScrollableSearch(); return ( <> <ScrollableSearch.ScrollContent> <SafeAreaView> <View style={styles.header}> <Text style={[ styles.title, fontLoaded && { fontFamily: "HelveticaNowDisplay" }, ]} > Explore </Text> <Text style={[ styles.subtitle, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Find your next adventure </Text> </View> <View style={styles.section}> <View style={styles.sectionHeader}> <SymbolView name="star.fill" size={16} tintColor="#fbbf24" /> <Text style={[ styles.sectionTitle, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Popular </Text> </View> {PLACES.map((place) => ( <Pressable key={place.id} style={styles.card}> <View style={styles.cardIcon}> <SymbolView name={place.symbol as any} size={20} tintColor="#fff" /> </View> <View style={styles.cardContent}> <Text style={[ styles.cardTitle, fontLoaded && { fontFamily: "HelveticaNowDisplay" }, ]} > {place.title} </Text> <Text style={[ styles.cardSubtitle, fontLoaded && { fontFamily: "SfProRounded" }, ]} > {place.subtitle} </Text> </View> <SymbolView name="chevron.right" size={14} tintColor="#444" /> </Pressable> ))} </View> </SafeAreaView> </ScrollableSearch.ScrollContent> <ScrollableSearch.Overlay onPress={() => setIsFocused(false)}> <ScrollableSearch.FocusedScreen> <SafeAreaView edges={["top"]} style={styles.focusedContainer}> <ScrollView style={styles.focusedScroll} showsVerticalScrollIndicator={false} > <View style={styles.focusedSection}> <View style={styles.focusedHeader}> <SymbolView name="clock.fill" size={16} tintColor="#888" /> <Text style={[ styles.focusedTitle, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Recent Searches </Text> </View> {RECENT.map((item, index) => ( <Pressable key={index} style={styles.recentItem}> <SymbolView name="magnifyingglass" size={16} tintColor="#666" /> <Text style={[ styles.recentText, fontLoaded && { fontFamily: "SfProRounded" }, ]} > {item} </Text> <SymbolView name="arrow.up.left" size={14} tintColor="#444" /> </Pressable> ))} </View> <View style={styles.tipCard}> <SymbolView name="sparkles" size={18} tintColor="#a78bfa" /> <View style={styles.tipContent}> <Text style={[ styles.tipTitle, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Pro Tip </Text> <Text style={[ styles.tipText, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Pull down to quickly access search </Text> </View> </View> </ScrollView> </SafeAreaView> </ScrollableSearch.FocusedScreen> </ScrollableSearch.Overlay> <SearchBar fontLoaded={fontLoaded} /> </> );};export default function App() { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), }); return ( <ScrollableSearch> <Content fontLoaded={fontLoaded} /> </ScrollableSearch> );}const styles = StyleSheet.create({ header: { paddingHorizontal: 20, paddingTop: 16, paddingBottom: 28, bottom: 60, }, title: { fontSize: 34, fontWeight: "700", color: "#fff", marginBottom: 4, }, subtitle: { fontSize: 15, color: "#666", }, section: { paddingHorizontal: 20, }, sectionHeader: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 16, }, sectionTitle: { fontSize: 17, fontWeight: "600", color: "#fff", }, card: { flexDirection: "row", alignItems: "center", backgroundColor: "#121212", padding: 16, borderRadius: 16, marginBottom: 10, gap: 14, }, cardIcon: { width: 44, height: 44, borderRadius: 12, backgroundColor: "#252525", justifyContent: "center", alignItems: "center", }, cardContent: { flex: 1, gap: 2, }, cardTitle: { fontSize: 17, fontWeight: "600", color: "#fff", }, cardSubtitle: { fontSize: 14, color: "#666", }, searchBar: { flexDirection: "row", alignItems: "center", backgroundColor: "#1a1a1a", marginHorizontal: 16, paddingHorizontal: 16, paddingVertical: 14, borderRadius: 14, gap: 10, borderWidth: 1, borderColor: "#252525", }, searchPlaceholder: { flex: 1, fontSize: 16, color: "#555", }, focusedContainer: { flex: 1, }, focusedScroll: { flex: 1, paddingTop: 90, }, focusedSection: { paddingHorizontal: 20, marginBottom: 24, }, focusedHeader: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 14, }, focusedTitle: { fontSize: 15, fontWeight: "600", color: "#888", }, recentItem: { flexDirection: "row", alignItems: "center", backgroundColor: "#1a1a1a", padding: 14, borderRadius: 12, marginBottom: 8, gap: 12, borderWidth: 1, borderColor: "#252525", }, recentText: { flex: 1, fontSize: 15, color: "#fff", }, tipCard: { flexDirection: "row", backgroundColor: "rgba(167, 139, 250, 0.1)", marginHorizontal: 20, padding: 16, borderRadius: 14, gap: 12, borderWidth: 1, borderColor: "rgba(167, 139, 250, 0.2)", }, tipContent: { flex: 1, gap: 2, }, tipTitle: { fontSize: 14, fontWeight: "600", color: "#a78bfa", }, tipText: { fontSize: 13, color: "#888", },});Props
IScrollableSearchContext
React Native Reanimated
React Native Worklets
React Native Safe Area Context
Expo Blur
