DEV Community

Cover image for Scroll-Responsive Animated Header Bar with Expo Router
Will
Will

Posted on

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.

Top comments (1)

Collapse
 
uicraft_by_pratik profile image
Pratik Tamhane

Nice🚀