Logo

dev-resources.site

for different kinds of informations.

Scroll-Responsive Animated Header Bar with Expo Router

Published at
8/21/2024
Categories
reactnative
expo
react
animation
Author
willkre
Categories
4 categories in total
reactnative
open
expo
open
react
open
animation
open
Author
7 person written this
willkre
open
Scroll-Responsive Animated Header Bar with Expo Router

A common UI pattern you'll see in mobile apps is the "native" header dynamically transitioning elements in and out or animating colors as you scroll up and down. Using Expo Router's Stack component, we can create a reusable component that abstracts much of the logic while maintaining flexibility through prop customisation.

We'll be creating a component called AnimatedHeaderScreen which you can quickly wrap around screens to add this functionality. While customisation will depend on specific needs, we'll be animating optional left/right icons and changing the background color, along with applying small details like a border.

What we'll be building

Demo of animated header

Prerequisites

This tutorial assumes you're using Expo Router in your project, as we'll be utilising components like Stack.Screen. If you want to start with a fresh install, you can use the following command to create a new TypeScript project with Expo:

npx create-expo-app@latest
Enter fullscreen mode Exit fullscreen mode

Diving into the implementation

import React, { useRef, ReactNode, useCallback } from "react";
import {
  View,
  Animated,
  ScrollView,
  StyleSheet,
  TouchableOpacity,
} from "react-native";
import { Stack } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";

type AnimatedHeaderScreenProps = {
  children: ReactNode;
  title?: string;
  leftIcon?: {
    name: keyof typeof Ionicons.glyphMap;
    onPress: () => void;
  };
  rightIcon?: {
    name: keyof typeof Ionicons.glyphMap;
    onPress: () => void;
  };
};

const colors = {
  background: "#000000",
  backgroundScrolled: "#1C1C1D",
  headerBorder: "#2C2C2E",
  borderColor: "#3A3A3C",
  text: "#FFFFFF",
  tint: "#4A90E2",
};

export default function AnimatedHeaderScreen({
  title,
  children,
  leftIcon,
  rightIcon,
}: AnimatedHeaderScreenProps) {
  const scrollY = useRef(new Animated.Value(0)).current;
  const insets = useSafeAreaInsets();

  const headerBackgroundColor = scrollY.interpolate({
    inputRange: [0, 50],
    outputRange: [colors.background, colors.backgroundScrolled],
    extrapolate: "clamp",
  });

  const handleScroll = Animated.event(
    [{ nativeEvent: { contentOffset: { y: scrollY } } }],
    { useNativeDriver: false }
  );

  const headerBorderWidth = scrollY.interpolate({
    inputRange: [0, 50],
    outputRange: [0, StyleSheet.hairlineWidth],
    extrapolate: "clamp",
  });

  const rightIconOpacity = rightIcon
    ? scrollY.interpolate({
        inputRange: [30, 50],
        outputRange: [0, 1],
        extrapolate: "clamp",
      })
    : 0;

  const rightIconTranslateY = rightIcon
    ? scrollY.interpolate({
        inputRange: [30, 50],
        outputRange: [10, 0],
        extrapolate: "clamp",
      })
    : 0;

  return (
    <>
      <Stack.Screen
        options={{
          headerShown: true,
          headerTitleAlign: "center",
          headerTitle: title,
          headerLeft: leftIcon
            ? () => (
                <Animated.View
                  style={{
                    opacity: rightIconOpacity,
                    transform: [{ translateY: rightIconTranslateY }],
                  }}
                >
                  <TouchableOpacity onPress={leftIcon.onPress}>
                    <Ionicons
                      name={leftIcon.name}
                      size={24}
                      color={colors.tint}
                      style={styles.leftIcon}
                    />
                  </TouchableOpacity>
                </Animated.View>
              )
            : undefined,
          headerRight: rightIcon
            ? () => (
                <Animated.View
                  style={{
                    opacity: rightIconOpacity,
                    transform: [{ translateY: rightIconTranslateY }],
                  }}
                >
                  <TouchableOpacity onPress={rightIcon.onPress}>
                    <Ionicons
                      name={rightIcon.name}
                      size={24}
                      color={colors.tint}
                      style={styles.rightIcon}
                    />
                  </TouchableOpacity>
                </Animated.View>
              )
            : undefined,
          headerBackground: () => (
            <Animated.View
              style={[
                StyleSheet.absoluteFill,
                styles.headerBackground,
                {
                  backgroundColor: headerBackgroundColor,
                  borderBottomColor: colors.borderColor,
                  borderBottomWidth: headerBorderWidth,
                },
              ]}
            />
          ),
        }}
      />

      <ScrollView
        style={styles.scrollView}
        contentContainerStyle={[
          styles.scrollViewContent,
          { paddingBottom: insets.bottom },
        ]}
        onScroll={handleScroll}
        scrollEventThrottle={16}
      >
        <View style={styles.content}>{children}</View>
      </ScrollView>
    </>
  );
}

const styles = StyleSheet.create({
  scrollView: {
    flex: 1,
  },
  scrollViewContent: {
    flexGrow: 1,
  },
  content: {
    flex: 1,
    paddingHorizontal: 8,
    paddingTop: 8,
  },
  headerBackground: {
    borderBottomWidth: 0,
  },
  leftIcon: {
    marginLeft: 16,
  },
  rightIcon: {
    marginRight: 16,
  },
});
Enter fullscreen mode Exit fullscreen mode

How It Works

Tracking Scroll Position
We use an Animated.Value to keep tabs on how far the user has scrolled:

const scrollY = useRef(new Animated.Value(0)).current;
Enter fullscreen mode Exit fullscreen mode

This value updates as the user scrolls, which we'll use to drive our animations.

Smooth Transitions with Interpolation
We use interpolate to map the scroll position to different style properties. For example:

const headerBackgroundColor = scrollY.interpolate({
  inputRange: [0, 50],
  outputRange: [colors.background, colors.backgroundScrolled],
  extrapolate: "clamp",
});
Enter fullscreen mode Exit fullscreen mode

This creates a smooth color change for the header background as you scroll from 0 to 50 pixels. The clamp part just makes sure the color doesn't keep changing beyond what we've set.

Applying Animated Styles
We use these interpolated values in our components with Animated.View and inline styles:

<Animated.View
  style={[
    StyleSheet.absoluteFill,
    styles.headerBackground,
    {
      backgroundColor: headerBackgroundColor,
      borderBottomColor: colors.borderColor,
      borderBottomWidth: headerBorderWidth,
    },
  ]}
/>
Enter fullscreen mode Exit fullscreen mode

This lets the header update its look based on how far you've scrolled.

Animating Optional Elements
For things like icons, we only apply animations if they're actually there:

const rightIconOpacity = rightIcon
  ? scrollY.interpolate({
      inputRange: [30, 50],
      outputRange: [0, 1],
      extrapolate: "clamp",
    })
  : 0;
Enter fullscreen mode Exit fullscreen mode

This way, icons fade in smoothly, but only if you've included them as props.

Handling Scroll Events
We use Animated.event to connect scroll events directly to our scrollY value:

const handleScroll = Animated.event(
  [{ nativeEvent: { contentOffset: { y: scrollY } } }],
  { useNativeDriver: false }
);
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ Note: Make sure you have useNativeDriver set to false or you'll encounter the error: "_this.props.onScroll is not a function (it is Object)". This occurs because the native driver can only handle a subset of styles that can be animated on the native side. We're animating non-compatible styles like backgroundColor, which requires JavaScript based animations.

Usage

To use the AnimatedHeaderScreen, simply wrap your screen content with it:

import { Alert, StyleSheet, Text, View } from "react-native";
import AnimatedHeaderScreen from "@/components/AnimatedHeaderScreen";

export default function HomeScreen() {
  return (
    <AnimatedHeaderScreen
      title="Lorem"
      rightIcon={{
        name: "search",
        onPress: () => Alert.alert("Handle search here..."),
      }}
    >
      {/* // Mock cards to fill out the screen... */}
      {Array.from({ length: 20 }, (_, index) => index + 1).map((item) => (
        <View
          style={[
            styles.card,
            { backgroundColor: item % 2 === 0 ? "#4A90E2" : "#67B8E3" },
          ]}
          key={item}
        >
          <Text style={styles.text}>{item}</Text>
        </View>
      ))}
    </AnimatedHeaderScreen>
  );
}

const styles = StyleSheet.create({
  card: {
    height: 80,
    elevation: 6,
    marginTop: 16,
    shadowRadius: 4,
    borderRadius: 12,
    shadowOpacity: 0.1,
    marginHorizontal: 8,
    alignItems: "center",
    justifyContent: "center",
    shadowOffset: { width: 0, height: 3 },
  },
  text: {
    color: "#FFF",
    fontSize: 16,
    fontWeight: "bold",
  },
});
Enter fullscreen mode Exit fullscreen mode

That's it! You've now got a solid foundation for an animated header in your Expo Router app. Feel free to tweak the animations, add more interactive elements, or adjust the styling to fit your app's needs.

expo Article's
30 articles in total
Favicon
Read Text Asset File in Expo
Favicon
Run Storybook with NX Expo and React Native Paper
Favicon
Explorando Notificaรงรตes Push no React Native com Expo e OneSignal!
Favicon
Starting React Native Project in 2025
Favicon
Guia Completo: Como Integrar WatermelonDB no React Native 0.76 com Expo 52 e TypeScript
Favicon
How to create authenticated routes with the new Expo SDK 51 using Expo Router
Favicon
React Native Expo with NativeWind v4 and Typescript
Favicon
Translate & Guess: Build a Flag Quiz with Expo and Tolgee
Favicon
How i implemented my server login screen for Mastodon
Favicon
How to Change iOS Push Notifications Permission Dialog with Expo
Favicon
Exploring React Native Navigation with Expo: A Complete Guide
Favicon
How to Render OpenStreetMap in an Expo React Native Web App Using React Leaflet
Favicon
EAS build reads your .gitignore file
Favicon
#2 - Expo apk keeps on crashing after build
Favicon
Dear expo, who are you?
Favicon
npm i openai-react-native
Favicon
Expo51 Jotai Template + flashlist + tamagui
Favicon
Scroll-Responsive Animated Header Bar with Expo Router
Favicon
Expo vs. React Native: Pros, Cons, and Key Differences
Favicon
To Obfuscate or Not Obfuscate (React Native)
Favicon
How to disable keyboard suggestions for Android in React Native
Favicon
Expo51 Jotai Template, ready to use
Favicon
Let's get started with React Native + Expo
Favicon
Generar APK con EAS โš›๏ธ React Native
Favicon
How to publish your React Native app to Expo Store 2024
Favicon
Creating a WebView app in React Native using Expo
Favicon
Embracing Expo: The New Standard for Creating React Native Apps
Favicon
Automate Your Expo Builds with EAS Using GitHub Actions: A Step-by-Step Guide (Android)
Favicon
How to Generate APK Using React Native Expo
Favicon
Creating Chat Bubbles with curls in React Native (svg)

Featured ones: