diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx
index fd1dbda..5294d60 100644
--- a/src/app/(tabs)/_layout.tsx
+++ b/src/app/(tabs)/_layout.tsx
@@ -1,4 +1,7 @@
import { Tabs } from 'expo-router';
+// RN's Easing (not reanimated): the bottom-tabs scene transition runs on legacy
+// Animated.timing and may use the native driver, so the easing must be serializable.
+import { Easing } from 'react-native';
import { TabBar, type TabItem } from '@/components/TabBar';
import { colors } from '@/theme';
@@ -8,6 +11,12 @@ export default function TabsLayout() {
screenOptions={{
headerShown: false,
sceneStyle: { backgroundColor: colors.bgPrimary },
+ // Directional slide + cross-fade between tabs, following tab order.
+ animation: 'shift',
+ transitionSpec: {
+ animation: 'timing',
+ config: { duration: 200, easing: Easing.out(Easing.cubic) },
+ },
}}
tabBar={({ state, navigation }) => {
const items: TabItem[] = state.routes.map((route, index) => ({
diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx
index 8a99e65..4146ff9 100644
--- a/src/components/TabBar.tsx
+++ b/src/components/TabBar.tsx
@@ -1,9 +1,16 @@
-import { View, Pressable, StyleSheet } from 'react-native';
+import { useEffect } from 'react';
+import { View, Pressable, StyleSheet, type LayoutChangeEvent } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
-import { Text } from './Text';
+import Animated, {
+ interpolateColor,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated';
import { MiniPlayer } from './MiniPlayer';
-import { colors, layout, spacing } from '@/theme';
+import { colors, fonts, layout, spacing } from '@/theme';
+import { motion } from '@/theme/motion';
type IconName = keyof typeof Ionicons.glyphMap;
@@ -32,7 +39,34 @@ interface TabBarProps {
*/
export function TabBar({ items, onPress }: TabBarProps) {
const insets = useSafeAreaInsets();
+ const tabs = items.filter((item) => TAB_META[item.name]);
const homeFocused = items.some((item) => item.name === 'index' && item.focused);
+ const count = tabs.length;
+ const activeIndex = Math.max(
+ 0,
+ tabs.findIndex((item) => item.focused),
+ );
+
+ // The "playhead": a single accent bar that glides along the top edge to the
+ // active tab, travelling in the same direction as the scene transition.
+ const barWidth = useSharedValue(0);
+ const position = useSharedValue(activeIndex);
+
+ useEffect(() => {
+ position.value = withTiming(activeIndex, motion.snap);
+ }, [activeIndex, position]);
+
+ const indicatorStyle = useAnimatedStyle(() => {
+ const segment = count > 0 ? barWidth.value / count : 0;
+ return {
+ width: segment,
+ transform: [{ translateX: position.value * segment }],
+ };
+ });
+
+ const onBarLayout = (e: LayoutChangeEvent) => {
+ barWidth.value = e.nativeEvent.layout.width;
+ };
return (
@@ -42,25 +76,21 @@ export function TabBar({ items, onPress }: TabBarProps) {
styles.bar,
{ paddingBottom: insets.bottom, height: layout.tabBarHeight + insets.bottom },
]}
+ onLayout={onBarLayout}
>
- {items.map((item) => {
+
+
+
+ {tabs.map((item) => {
const meta = TAB_META[item.name];
if (!meta) return null;
- const color = item.focused ? colors.accent : colors.textTertiary;
return (
- onPress(item)}
- hitSlop={8}
- accessibilityRole="tab"
- accessibilityState={{ selected: item.focused }}
- >
-
-
- {meta.label}
-
-
+ />
);
})}
@@ -68,6 +98,67 @@ export function TabBar({ items, onPress }: TabBarProps) {
);
}
+interface TabButtonProps {
+ meta: { label: string; icon: IconName };
+ focused: boolean;
+ onPress: () => void;
+}
+
+/**
+ * A single tab. Selecting it cross-fades the icon/label colour to accent;
+ * pressing depresses the icon for tactile feedback. Reanimated only drives
+ * opacity/transform on plain Animated.Views — @expo/vector-icons' Icon is a
+ * class-wrapped Text that Animated can't drive, so colour is a cross-fade
+ * between a grey base icon and an accent one stacked on top. Spring-free per
+ * theme/motion.
+ */
+function TabButton({ meta, focused, onPress }: TabButtonProps) {
+ // 0 = inactive, 1 = active. Drives the accent fill, label colour, and bloom.
+ const progress = useSharedValue(focused ? 1 : 0);
+ // 0 = at rest, 1 = finger down.
+ const press = useSharedValue(0);
+
+ useEffect(() => {
+ progress.value = withTiming(focused ? 1 : 0, motion.quick);
+ }, [focused, progress]);
+
+ const depressStyle = useAnimatedStyle(() => ({
+ transform: [{ scale: 1 - press.value * 0.12 }],
+ }));
+ const accentStyle = useAnimatedStyle(() => ({ opacity: progress.value }));
+ const labelStyle = useAnimatedStyle(() => ({
+ color: interpolateColor(
+ progress.value,
+ [0, 1],
+ [colors.textTertiary, colors.accent],
+ ),
+ }));
+
+ return (
+ {
+ press.value = withTiming(1, motion.quick);
+ }}
+ onPressOut={() => {
+ press.value = withTiming(0, motion.quick);
+ }}
+ hitSlop={8}
+ accessibilityRole="tab"
+ accessibilityState={{ selected: focused }}
+ >
+
+
+
+
+
+
+ {meta.label}
+
+ );
+}
+
const styles = StyleSheet.create({
wrap: {
backgroundColor: colors.bgSecondary,
@@ -78,6 +169,18 @@ const styles = StyleSheet.create({
borderTopColor: colors.glassBorder,
borderTopWidth: StyleSheet.hairlineWidth,
},
+ indicator: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ alignItems: 'center',
+ },
+ indicatorBar: {
+ width: 28,
+ height: 3,
+ borderRadius: 999,
+ backgroundColor: colors.accent,
+ },
tab: {
flex: 1,
alignItems: 'center',
@@ -87,6 +190,7 @@ const styles = StyleSheet.create({
label: {
marginTop: 2,
fontSize: 10,
+ fontFamily: fonts.sans.regular,
},
});