Skip to main content

Overview

Expo Router provides file-based routing similar to Next.js. You can integrate react-native-screen-transitions for custom animations while keeping Expo Router’s routing structure.

Setup

First, install dependencies:
npm install expo-router react-native-safe-area-context
npm install react-native-screen-transitions react-native-reanimated react-native-gesture-handler
Configure Babel with Reanimated:
// babel.config.js
module.exports = {
  presets: ['babel-preset-expo'],
  plugins: [
    'react-native-reanimated/plugin',
  ],
};

File Structure

Typical Expo Router project with transitions:
app/
  ├─ _layout.tsx          // Root layout
  ├─ index.tsx            // Home screen
  ├─ details
  │  └─ [id].tsx         // Detail screen
  └─ modal
     └─ settings.tsx     // Modal screen

Root Layout with Transitions

Configure the root layout with custom animations:
// app/_layout.tsx
import { Transition } from 'react-native-screen-transitions';
import { interpolate } from 'react-native-reanimated';
import { Stack } from 'expo-router';

export default function RootLayout() {
  const slideInterpolator = ({ progress }) => {
    "worklet";
    return {
      content: {
        style: {
          opacity: interpolate(progress, [0, 1], [0, 1]),
          transform: [
            {
              translateX: interpolate(progress, [0, 1], [50, 0]),
            },
          ],
        },
      },
      backdrop: {
        style: {
          opacity: interpolate(progress, [0, 1], [0, 0.5]),
        },
      },
    };
  };

  return (
    <Transition.Stack
      screenStyleInterpolator={slideInterpolator}
      gestureEnabled
      gestureDirection="horizontal"
    >
      <Stack.Screen name="index" options={{ title: 'Home' }} />
      <Stack.Screen name="details/[id]" options={{ title: 'Details' }} />
      <Stack.Screen name="modal/settings" options={{ presentation: 'modal' }} />
    </Transition.Stack>
  );
}

Per-Route Animations

Set different animations for different routes:
// app/_layout.tsx
import { Transition } from 'react-native-screen-transitions';
import { Stack, useRoute } from 'expo-router';

const fadeInterpolator = ({ progress }) => {
  "worklet";
  return {
    content: {
      style: {
        opacity: interpolate(progress, [0, 1], [0, 1]),
      },
    },
  };
};

const slideUpInterpolator = ({ progress }) => {
  "worklet";
  return {
    content: {
      style: {
        transform: [{
          translateY: interpolate(progress, [0, 1], [100, 0]),
        }],
      },
    },
  };
};

export default function RootLayout() {
  const route = useRoute();

  const getInterpolator = () => {
    if (route.name === 'modal/settings') {
      return slideUpInterpolator;
    }
    return fadeInterpolator;
  };

  return (
    <Transition.Stack screenStyleInterpolator={getInterpolator()}>
      <Stack.Screen name="index" />
      <Stack.Screen name="details/[id]" />
      <Stack.Screen name="modal/settings" />
    </Transition.Stack>
  );
}

Nested Layouts

Use nested layouts for tab-based navigation:
// app/(tabs)/_layout.tsx
import { Transition } from 'react-native-screen-transitions';
import { Tabs } from 'expo-router';

export default function TabsLayout() {
  return (
    <Tabs>
      <Tabs.Screen
        name="home"
        options={{
          title: 'Home',
          tabBarIcon: ({ color }) => <Icon name="home" color={color} />,
        }}
      />
      <Tabs.Screen
        name="settings"
        options={{
          title: 'Settings',
          tabBarIcon: ({ color }) => <Icon name="cog" color={color} />,
        }}
      />
    </Tabs>
  );
}

// app/(tabs)/home/_layout.tsx
export default function HomeLayout() {
  return (
    <Transition.Stack
      screenStyleInterpolator={homeInterpolator}
      gestureEnabled
    >
      <Stack.Screen name="index" options={{ title: 'Home' }} />
      <Stack.Screen name="details/[id]" options={{ title: 'Details' }} />
    </Transition.Stack>
  );
}

Shared Elements with Expo Router

Use boundary components for shared element animations across routes:
// app/index.tsx
import { Transition } from 'react-native-screen-transitions';
import { Link } from 'expo-router';
import { Image, Text } from 'react-native';

export default function HomeScreen() {
  return (
    <View style={{ flex: 1, padding: 20 }}>
      <Link href={{ pathname: '/details/[id]', params: { id: '1' } }} asChild>
        <Transition.Boundary.Pressable id="hero-image">
          <Image
            source={{ uri: 'https://...' }}
            style={{ width: 200, height: 300, borderRadius: 12 }}
          />
        </Transition.Boundary.Pressable>
      </Link>
    </View>
  );
}

// app/details/[id].tsx
import { Transition } from 'react-native-screen-transitions';
import { Image } from 'react-native';
import { useLocalSearchParams } from 'expo-router';

export default function DetailScreen() {
  const { id } = useLocalSearchParams();

  return (
    <View style={{ flex: 1 }}>
      <Transition.Boundary.View id="hero-image">
        <Image
          source={{ uri: 'https://...' }}
          style={{ width: '100%', height: 400, borderRadius: 0 }}
        />
      </Transition.Boundary.View>
      {/* Details content */}
    </View>
  );
}
Define the interpolator to animate the shared element:
// app/_layout.tsx
const sharedElementInterpolator = ({ progress, bounds }) => {
  "worklet";
  const b = bounds({ id: "hero-image" });

  return {
    content: {
      style: {
        opacity: interpolate(progress, [0, 1], [0, 1]),
      },
    },
    "hero-image": {
      style: {
        borderRadius: interpolate(progress, [0, 1], [12, 0]),
      },
    },
  };
};
Handle modal presentations using Expo Router’s modal syntax:
// app/modal/_layout.tsx
import { Stack } from 'expo-router';
import { Transition } from 'react-native-screen-transitions';

export default function ModalLayout() {
  const modalInterpolator = ({ progress }) => {
    "worklet";
    return {
      content: {
        style: {
          transform: [{
            translateY: interpolate(progress, [0, 1], [600, 0]),
          }],
        },
      },
      backdrop: {
        style: {
          opacity: interpolate(progress, [0, 1], [0, 0.7]),
        },
      },
    };
  };

  return (
    <Transition.Modal
      screenStyleInterpolator={modalInterpolator}
      snapPoints={[0.5, 1]}
      gestureEnabled
    >
      <Stack.Screen name="settings" options={{ title: 'Settings' }} />
    </Transition.Modal>
  );
}

Deep Linking with Animations

Expo Router automatically handles deep linking. Transitions work seamlessly:
// Navigation via link
<Link href="/details/123">
  <Text>Go to Detail</Text>
</Link>

// Programmatic navigation
import { useRouter } from 'expo-router';

function MyScreen() {
  const router = useRouter();

  return (
    <Pressable onPress={() => router.push('/details/123')}>
      <Text>Navigate</Text>
    </Pressable>
  );
}
Animations apply automatically based on your configured interpolators.

Dynamic Routes

Animations work with dynamic routes:
// app/[category]/[item].tsx
import { useLocalSearchParams } from 'expo-router';

export default function ItemScreen() {
  const { category, item } = useLocalSearchParams();

  return (
    <View>
      <Text>Category: {category}</Text>
      <Text>Item: {item}</Text>
    </View>
  );
}

// Navigating
router.push({
  pathname: '/[category]/[item]',
  params: { category: 'books', item: '42' },
});

Configuration Per Screen

Override animations per-screen with Expo Router options:
// app/index.tsx
import { Stack } from 'expo-router';

export const unstable_settings = {
  initialRouteName: 'index',
};

export default function HomeScreen() {
  return (
    <View>
      <Text>Home</Text>
    </View>
  );
}

// app/special.tsx with custom animation
export const unstable_settings = {
  animation: 'fade',  // Or custom interpolator
};

export default function SpecialScreen() {
  return (
    <View>
      <Text>Special Screen</Text>
    </View>
  );
}

Combining with Gesture Handler

Expo Router works seamlessly with gesture handler:
import { GestureDetector, Gesture } from 'react-native-gesture-handler';

function MyScreen() {
  const router = useRouter();

  const gesture = Gesture.Pan()
    .onEnd((e) => {
      if (e.translationX < -100) {
        router.push('/next');
      }
    });

  return (
    <GestureDetector gesture={gesture}>
      <View style={{ flex: 1 }}>
        {/* Swipe left to navigate */}
      </View>
    </GestureDetector>
  );
}

TypeScript Support

Add types for strong typing:
import type { ScreenStyleInterpolator } from 'react-native-screen-transitions';
import { interpolate } from 'react-native-reanimated';

const myInterpolator: ScreenStyleInterpolator = ({ progress }) => {
  "worklet";
  return {
    content: {
      style: {
        opacity: interpolate(progress, [0, 1], [0, 1]),
      },
    },
  };
};

Best Practices

  1. Define interpolators at layout level for consistency
  2. Use per-route options for route-specific customization
  3. Leverage shared elements for visual continuity
  4. Test navigation across different routes
  5. Use TypeScript for type safety
  6. Profile performance on low-end devices

Troubleshooting

Animations Not Applying

Ensure screenStyleInterpolator is set on the Stack:
<Transition.Stack screenStyleInterpolator={myInterpolator}>
  {/* screens */}
</Transition.Stack>

Shared Elements Not Animating

Verify boundary component IDs match:
// Source
<Transition.Boundary.Pressable id="avatar" href="/details">
  <Image {...} />
</Transition.Boundary.Pressable>

// Destination
<Transition.Boundary.View id="avatar">
  <Image {...} />
</Transition.Boundary.View>
Ensure animations are optimized:
// Avoid expensive operations in interpolator
const fastInterpolator: ScreenStyleInterpolator = ({ progress }) => {
  "worklet";
  // Use simple interpolations
  return {
    content: {
      style: {
        opacity: interpolate(progress, [0, 1], [0, 1]),
      },
    },
  };
};

Next Steps