Skip to content

Exploring React Native: Header blur effect in Expo Router

Published:

5 min read

Modern mobile app interfaces go beyond opaque header bars. On iOS, a frosted-glass like blur effect when scrolling a screen which contains the header is quite easy with Expo Router and React Navigation.

In this article, let’s explore how to implement this effect in a React Native app using Expo Router. For demonstration, the following will be the final result:

Using Stack.Screen options

Since Expo Router wraps the React Navigation library, you can use Stack.Screen options to achieve the blur effect. On iOS, this requires adding two props: headerBlurEffect and headerTransparent.

In app/_layout.tsx, a Stack component is used to define the layout of the app. The Stack.Screen component is used to define the screen’s layout and options. Currently, there’s only one screen in the app, so it’s the root screen (app/index.tsx). Update the Stack.Screen component with the headerBlurEffect and headerTransparent props:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';

const queryClient = new QueryClient();

export default function RootLayout() {
  return (
    <QueryClientProvider client={queryClient}>
      <Stack>
        <Stack.Screen
          name="index"
          options={{
            headerTitle: 'Manga',
            headerBlurEffect: 'regular',
            headerTransparent: true
          }}
        />
      </Stack>
      <StatusBar style="auto" />
    </QueryClientProvider>
  );
}

It’s important to note that adding headerBlurEffect alone will have no effect on the look and feel of the app. You have to set the header to transparent for the blur effect prop to work.

The header is now appears blurred. Though, as an app user, I haven’t scrolled the screen yet, the content of the screen is already behind the header.

There’s another problem with this approach. On Android, this effect does not work and results in a bad experience:

Using header height hook to add a safe area

To fix the issue from the previous section, you can use the useHeaderHeight hook. This hook returns the height of the header, which you can use to offset the content in app/index.tsx.

import { useHeaderHeight } from '@react-navigation/elements';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';

import Indicator from '@/components/Indicator';
import { useTrendingManga } from '@/hooks';
import Ionicons from '@expo/vector-icons/Ionicons';
import { Link } from 'expo-router';

export default function HomeScreen() {
  const { data, isLoading, isError } = useTrendingManga();
  const headerHeight = useHeaderHeight();

  if (isLoading) {
    return <Indicator isLoading={isLoading} />;
  }

  if (isError) {
    return <Indicator isError={isError} />;
  }

  return (
    <View style={[styles.container]}>
      <FlatList
        data={data}
        renderItem={({ item }) => (
          <Link href={`/${item.id}`} asChild>
            <Pressable style={styles.mangaItem}>
              <Text style={styles.mangaTitle}>{item.title.romaji}</Text>
              <Ionicons name="chevron-forward" size={16} color="#ccc" />
            </Pressable>
          </Link>
        )}
        contentContainerStyle={{ paddingTop: headerHeight }}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1
  },
  loadingContainer: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center'
  },
  errorText: {
    color: 'red',
    fontSize: 16
  },
  header: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 16,
    paddingHorizontal: 12
  },
  mangaItem: {
    padding: 12,
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between'
  },
  mangaTitle: {
    fontSize: 16,
    color: 'red'
  }
});

Result of the above code is:

Importing useHeaderHeight from React Navigation gets called to get the exacct header height (which can vary by the device’s screen size). Then, the value of the height is applied as paddingTop to the FlatList component’s contentContainerStyle to correctly offset the content and prevent it from appearing behind the transparent header.

A key insight here is that the contentContainerStyle with paddingTop: headerHeight makes sure that the content starts after the header. The value of paddingTop is only applied to the scrollable content.

Applying blur effect only on iOS

In the first section, you learned that the blur effect does not work on Android.

To make sure the blur effect is applied only on iOS, you can use the Platform module from React Native. In app\_layout.tsx, import Platform from react-native and conditionally check if the platform is iOS to apply headerBlurEffect and headerTransparent:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Stack } from 'expo-router';
import { Platform } from 'react-native';

const queryClient = new QueryClient();

export default function RootLayout() {
  return (
    <QueryClientProvider client={queryClient}>
      <Stack>
        <Stack.Screen
          name="index"
          options={{
            headerTitle: 'Manga',
            headerBlurEffect: Platform.OS === 'ios' ? 'regular' : undefined,
            headerTransparent: Platform.OS === 'ios'
          }}
        />
        <Stack.Screen
          name="[id]"
          options={{
            headerTitle: 'Manga Details'
          }}
        />
      </Stack>
    </QueryClientProvider>
  );
}

Then, in app/index.tsx, for headerHeight let’s do the similar platform check:

<FlatList
  data={data}
  renderItem={({ item }) => (
    <Link href={`/${item.id}`} asChild>
      <Pressable style={styles.mangaItem}>
        <Text style={styles.mangaTitle}>{item.title.romaji}</Text>
        <Ionicons name="chevron-forward" size={16} color="#ccc" />
      </Pressable>
    </Link>
  )}
  contentContainerStyle={{
    paddingTop: Platform.OS === 'ios' ? headerHeight : 0
  }}
/>

Using ternary operator on contentContainerStyle ensures that padding is added only on iOS. On Android, it has a regular opaque header that doesn’t need this adjustment.

Other types of blur properties

Since Expo Router wraps React Navigation library underneath, all supported values documented in React Navigation documentation are available.

For example, if you use systemThinMaterialDark, it will be applied to the iOS app, giving a different blur aesthetic that might better match a dark-themed application:

<Stack.Screen
  name="index"
  options={{
    headerTitle: 'Manga',
    headerBlurEffect:
      Platform.OS === 'ios' ? 'systemThinMaterialDark' : undefined,
    headerTransparent: Platform.OS === 'ios'
  }}
/>

Wrapping up

By combining Stack.Screen options with platform-specific code to gracefully add a fall back for Android, you can create an iOS-style blur effect for the header of your React Native app. This approach provides a native experience that users expect from modern mobile applications, with minimal code complexity and excellent cross-platform compatibility.


Next Post
Advanced code blocks with language labels and copy buttons in Astro

Aman Mittal author

I'm a software developer and technical writer. On this blog, I share my learnings about both fields. Recently, I have begun exploring other topics, so don't be surprised if you find something new here.

Currently, working as a documentation lead at Expo.