Search Bar
An animated search bar with smooth focus transitions
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated @expo/vector-icons react-native-worklets expo-blur expo-symbolsCopy and paste the following code into your project.
component/molecules/search-bar
import React, { useState, useRef, useEffect } from "react";import { View, TextInput, TouchableOpacity, StyleSheet, Text, Dimensions, Platform,} from "react-native";import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, interpolate, useAnimatedProps,} from "react-native-reanimated";import { SymbolView } from "expo-symbols";import { BlurView, BlurViewProps } from "expo-blur";import type { SearchBarProps } from "./SearchBar.types";import { scheduleOnRN } from "react-native-worklets";import { Ionicons } from "@expo/vector-icons";const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);const AnimatedView = Animated.createAnimatedComponent(View);const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);const AnimatedBlurView = Animated.createAnimatedComponent<BlurViewProps>(BlurView);const { width: screenWidth } = Dimensions.get("window");export const SearchBar = ({ placeholder = "Search", onSearch, onClear, style, renderLeadingIcons, renderTrailingIcons, onSearchDone = () => {}, onSearchMount = () => {}, containerWidth, focusedWidth, cancelButtonWidth = 68, enableWidthAnimation = true, centerWhenUnfocused = true, ...props}: SearchBarProps) => { const [query, setQuery] = useState(""); const [isFocused, setIsFocused] = useState(false); const [containerDimensions, setContainerDimensions] = useState({ width: 0 }); const inputRef = useRef<TextInput>(null); const focusProgress = useSharedValue(0); const clearButtonScale = useSharedValue(0); const clearButtonOpacity = useSharedValue(0); const textOpacity = useSharedValue(1); const textScale = useSharedValue(1); const textTranslateY = useSharedValue(0); const currentWidth = useSharedValue(containerWidth || screenWidth - 32); useEffect(() => { if (containerWidth) { currentWidth.value = containerWidth; } else if (containerDimensions.width > 0) { currentWidth.value = containerDimensions.width; } }, [containerWidth, containerDimensions.width]); const animatedContainerStyle = useAnimatedStyle(() => { if (!enableWidthAnimation) { return { width: currentWidth.value }; } const searchBarWidth = interpolate( focusProgress.value, [0, 1], [ currentWidth.value, focusedWidth || currentWidth.value - cancelButtonWidth, ], ); return { width: searchBarWidth }; }); const animatedCancelStyle = useAnimatedStyle(() => { const opacity = interpolate(focusProgress.value, [0, 0.5, 1], [0, 0, 1]); const translateX = interpolate(focusProgress.value, [0, 1], [20, 0]); return { opacity, transform: [{ translateX }], }; }); const animatedBlurViewProps = useAnimatedProps(() => { const blurAmount = withSpring( interpolate(focusProgress.value, [0, 0.3, 0.5, 1], [0, 20, 30, 0]), ); return { intensity: blurAmount, }; }); const animatedSearchContentStyle = useAnimatedStyle(() => { const justifyContent = focusProgress.value === 0 && centerWhenUnfocused ? "center" : "flex-start"; const paddingLeft = interpolate(focusProgress.value, [0, 1], [0, 12]); return { justifyContent, paddingLeft }; }); const animatedInputWrapperStyle = useAnimatedStyle(() => { if (!centerWhenUnfocused) { return { transform: [{ translateX: 0 }] }; } const iconAndPadding = 40; const _centerOffSetValue = props?.textCenterOffset ?? 2.5; const centerOffset = (currentWidth.value - iconAndPadding * _centerOffSetValue) / 2 - 10; const translateX = interpolate( focusProgress.value, [0, 1], [centerOffset, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, ); return { transform: [{ translateX }], }; }); const animatedIconStyle = useAnimatedStyle(() => { if (!centerWhenUnfocused) { return { transform: [{ translateX: 0 }] }; } const _iconCenterValue = props?.iconCenterOffset ?? 2.5; const centerOffset = (currentWidth.value - 36 * _iconCenterValue) / 2 - 10; const translateX = interpolate( focusProgress.value, [0, 1], [centerOffset, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, ); return { transform: [{ translateX }], }; }); const animatedClearButtonStyle = useAnimatedStyle(() => ({ transform: [{ scale: clearButtonScale.value }], opacity: clearButtonOpacity.value, })); const animatedInputStyle = useAnimatedStyle(() => { return { opacity: textOpacity.value, transform: [ { scale: textScale.value }, { translateY: textTranslateY.value }, ], }; }); const handleFocus = () => { onSearchMount(); setIsFocused(true); focusProgress.value = withSpring(1, { damping: 20, stiffness: 200, mass: 0.8, velocity: 0.5, duration: 550 as any, }); }; const handleCancel = () => { inputRef.current?.blur(); setIsFocused(false); setQuery(""); onSearchDone(); onClear?.(); focusProgress.value = withTiming(0); clearButtonScale.value = withTiming(0); clearButtonOpacity.value = withTiming(0, { duration: 200 }); }; const handleBlur = () => { if (!query) handleCancel(); }; const handleChangeText = (text: string) => { setQuery(text); if (text.length > 0) { clearButtonScale.value = withSpring(1); clearButtonOpacity.value = withTiming(1, { duration: 200 }); textOpacity.value = withTiming(1, { duration: 150 }); } else { clearButtonScale.value = withSpring(0); clearButtonOpacity.value = withTiming(0, { duration: 200 }); } onSearch?.(text); }; const handleClear = () => { textOpacity.value = withTiming(0, { duration: 150 }, () => { scheduleOnRN(setQuery, ""); textOpacity.value = withTiming(1, { duration: 150 }); }); clearButtonScale.value = withTiming(0); clearButtonOpacity.value = withTiming(0, { duration: 200 }); onClear?.(); inputRef.current?.focus(); }; const handleLayout = (event: any) => { const { width } = event.nativeEvent.layout; setContainerDimensions({ width }); }; return ( <View style={[styles.container, style]} onLayout={handleLayout}> <View style={styles.searchRow}> <AnimatedView style={[styles.searchBarContainer, animatedContainerStyle]} > <BlurView intensity={15} tint="systemChromeMaterialDark" style={styles.blurContainer} > <View style={styles.searchContainer}> <AnimatedView style={[styles.searchContent, animatedSearchContentStyle]} > <AnimatedView style={[ styles.searchIconContainer, animatedIconStyle, props?.iconStyle, ]} > {renderLeadingIcons ? ( renderLeadingIcons() ) : ( <SymbolView name="magnifyingglass" size={18} tintColor="#8E8E93" fallback={ <Ionicons name="search" size={18} color="#8E8E93" /> } /> )} </AnimatedView> <AnimatedView style={[{ flex: 1 }, animatedInputWrapperStyle]}> <AnimatedTextInput ref={inputRef} style={[ styles.input, animatedInputStyle, props?.inputStyle, ]} cursorColor={props?.tint ?? "#007AFF"} placeholder={placeholder} placeholderTextColor="#8E8E93" value={query} onChangeText={handleChangeText} onFocus={handleFocus} onBlur={handleBlur} returnKeyType="search" autoCorrect={false} autoCapitalize="none" selectionColor={props?.tint ?? "#007AFF"} {...props} /> </AnimatedView> {Platform.OS === "ios" && ( <AnimatedBlurView style={[ StyleSheet.absoluteFillObject, { overflow: "hidden", }, ]} animatedProps={animatedBlurViewProps} pointerEvents={"none"} /> )} {query.length > 0 && ( <AnimatedTouchable onPress={handleClear} style={[styles.clearButton, animatedClearButtonStyle]} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} > {renderTrailingIcons ? ( renderTrailingIcons() ) : ( <SymbolView name="xmark.circle.fill" size={18} tintColor="#8E8E93" /> )} </AnimatedTouchable> )} </AnimatedView> </View> </BlurView> </AnimatedView> <AnimatedView style={[styles.cancelButtonContainer, animatedCancelStyle]} > <TouchableOpacity onPress={handleCancel} style={styles.cancelButton} activeOpacity={0.6} hitSlop={{ top: 10, bottom: 10, left: 5, right: 5 }} > <Text style={[ styles.cancelText, { color: props?.tint ?? "#007AFF", }, ]} > Cancel </Text> </TouchableOpacity> </AnimatedView> </View> </View> );};const styles = StyleSheet.create({ container: { width: "100%", paddingHorizontal: 0, paddingVertical: 8, }, searchRow: { flexDirection: "row", alignItems: "center", }, searchBarContainer: {}, blurContainer: { borderRadius: 12, overflow: "hidden", }, searchContainer: { backgroundColor: "rgba(118, 118, 128, 0.12)", borderRadius: 12, minHeight: 35, justifyContent: "center", }, searchContent: { flexDirection: "row", alignItems: "center", paddingHorizontal: 12, paddingVertical: 10, }, searchIconContainer: { width: 20, height: 20, justifyContent: "center", alignItems: "center", marginRight: 8, }, input: { width: "100%", color: "#FFFFFF", fontSize: 17, fontFamily: "System", fontWeight: "400", includeFontPadding: false, textAlignVertical: "center", minHeight: 24, textAlign: "left", }, clearButton: { padding: 4, marginLeft: 4, }, cancelButtonContainer: { paddingLeft: 12, minWidth: 60, justifyContent: "center", alignItems: "flex-start", }, cancelButton: { paddingVertical: 8, paddingHorizontal: 0, }, cancelText: { fontSize: 17, fontFamily: "System", fontWeight: "400", },});Usage
import { View, Text, StyleSheet, Image, Dimensions } 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 { useState } from "react";import { CircularCarousel } from "@/components/molecules/circular-carousel";import { LinearGradient } from "expo-linear-gradient";import MaterialCarousel from "@/components/molecules/material-carousel";import { MorphicTabBar } from "@/components/molecules/morphing-tabbar";import { ParallaxCarousel } from "@/components/molecules/parallax-carousel";import { RotateCarousel } from "@/components/molecules/rotate-carousel";import { SearchBar } from "@/components";const { width: SCREEN_WIDTH } = Dimensions.get("window");export default function App() { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), StretchPro: require("@/assets/fonts/StretchPro.otf"), }); const [currentIndex, setCurrentIndex] = useState<number>(0); const DATA = [ { id: "1", name: "MY DEAR MELANCHOLY", artist: "The Weeknd", year: "2018", image: "https://i.pinimg.com/1200x/18/e6/e8/18e6e8e2d2b8c5b4dd77a4ae705bf96a.jpg", }, { id: "2", name: "RANDOM ACCESS MEMORIES", artist: "Daft Punk", year: "2013", image: "https://i.pinimg.com/1200x/91/52/b2/9152b2dc174934279cda4509b0931434.jpg", }, { id: "3", name: "CURRENTS", artist: "Tame Impala", year: "2015", image: "https://i.pinimg.com/1200x/1e/38/7f/1e387f131098067f7a9be0bc68b0b6f2.jpg", }, { id: "4", name: "PLASTIC BEACH", artist: "Gorillaz", year: "2010", image: "https://i.pinimg.com/736x/43/e0/e0/43e0e0a542c0ccfbc5cf1b802bcf2d66.jpg", }, ]; const ITEMS: string[] = [ "https://i.pinimg.com/1200x/5a/ad/c6/5aadc6ef06807b24de9d0ea236c28978.jpg", "https://i.pinimg.com/736x/ea/9f/97/ea9f9778de29809187e40b6b12f3ca28.jpg", "https://i.pinimg.com/736x/c7/ad/93/c7ad937da5f3796492a4ce378db61700.jpg", "https://i.pinimg.com/736x/d8/af/cc/d8afcc936c977bab53cec723a2f2fc1c.jpg", ]; return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="inverted" /> <View style={styles.header}> <View> <Text style={[ styles.title, fontLoaded && { fontFamily: "HelveticaNowDisplay" }, ]} > Search </Text> <Text style={[ styles.subtitle, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Recent posters collection. </Text> </View> </View> <SearchBar containerWidth={350} tint="#fff" style={{ marginTop: 20, left: 35, }} textCenterOffset={2.2} iconCenterOffset={2.2} /> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#0a0a0a", }, header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingHorizontal: 24, paddingTop: 70, }, title: { fontSize: 35, color: "#fff", letterSpacing: 2, }, subtitle: { fontSize: 15, color: "#aaa", }, headerRight: { width: 40, height: 40, borderRadius: 20, backgroundColor: "#1a1a1a", justifyContent: "center", alignItems: "center", }, card: { width: "100%", height: 340, borderRadius: 24, overflow: "hidden", }, cardImage: { width: 300, height: 400, resizeMode: "cover", }, cardGradient: { ...StyleSheet.absoluteFillObject, }, cardContent: { position: "absolute", bottom: 0, left: 0, right: 0, padding: 20, gap: 8, }, albumName: { fontSize: 16, color: "#fff", letterSpacing: 1, }, artistRow: { flexDirection: "row", alignItems: "center", gap: 6, }, artistText: { fontSize: 13, color: "rgba(255,255,255,0.7)", }, dot: { width: 3, height: 3, borderRadius: 1.5, backgroundColor: "rgba(255,255,255,0.4)", }, yearText: { fontSize: 13, color: "rgba(255,255,255,0.5)", }, footer: { paddingHorizontal: 24, marginTop: 32, gap: 20, }, nowPlaying: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", backgroundColor: "#141414", padding: 12, borderRadius: 16, }, nowPlayingLeft: { flexDirection: "row", alignItems: "center", gap: 12, flex: 1, }, nowPlayingImage: { width: 48, height: 48, borderRadius: 10, }, nowPlayingInfo: { flex: 1, gap: 2, }, nowPlayingTitle: { fontSize: 14, fontWeight: "600", color: "#fff", }, nowPlayingArtist: { fontSize: 12, color: "#666", }, nowPlayingControls: { width: 44, height: 44, borderRadius: 22, backgroundColor: "#fff", justifyContent: "center", alignItems: "center", }, dots: { flexDirection: "row", justifyContent: "center", alignItems: "center", gap: 6, }, dotIndicator: { width: 6, height: 6, borderRadius: 3, backgroundColor: "#333", }, dotIndicatorActive: { width: 20, backgroundColor: "#fff", },});Props
React Native Reanimated
React Native Worklets
Expo Blur
Expo Vector Icons
