Search Bar

An animated search bar with smooth focus transitions

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated @expo/vector-icons react-native-worklets expo-blur expo-symbols

Copy 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