Liquid Glass Tab Bar in React Native and Expo
Updated
A React Native Liquid Glass tab bar combines real backdrop blur, translucent tint, a bright edge, and scroll-linked geometry to evoke the Liquid Glass look of iOS 26 without using platform-owned assets. This tutorial builds that material in Expo SDK 57 with expo-blur, expo-linear-gradient, and Reanimated 4.5.1, while providing a practical Android fallback and accessible tabs.
What you'll build
You will create a floating five-tab navigation surface that begins wide and translucent, then condenses as content scrolls. One normalized shared value drives width, height, tab opacity, highlight opacity, and lift, keeping the material coherent and interruptible if the user reverses direction.
The sample uses abstract text glyphs and a generic demo identity. “Liquid Glass” describes the visual material style associated with iOS 26; this implementation is independent, uses no Apple artwork, and does not imply affiliation or endorsement.
Prerequisites
Use Expo SDK 57, React Native 0.86, Reanimated 4.5.1, react-native-worklets 0.10.1, expo-blur, and expo-linear-gradient. Gesture Handler ~2.32, Skia 2.6.2, and Haptics are not required for this scroll-driven version.
Install packages with Expo so their native versions match SDK 57:
npx expo install expo-blur expo-linear-gradient react-native-reanimated react-native-workletsexpo-blur contains native code. Expo Go for SDK 57 includes supported Expo modules, but a custom native dependency would require a development build. Review the versioned Expo BlurView documentation and Reanimated scroll handler reference before adapting the sample.
Build the material in layers
Convincing glass is not just low-opacity white. It needs content to blur, a tint that stabilizes contrast, a fine highlight around the upper edge, and a shadow that separates the surface from bright backgrounds. Keep these layers inside one clipped rounded shape so they move as one object.
On Android, SDK 57's BlurView accepts blurMethod="dimezisBlurViewSdk31Plus". It uses the native blur implementation on Android SDK 31+ and falls back to a translucent view on older versions, avoiding the higher cost of the legacy path. BlurTargetView provides the explicit source required by the Android implementation.
Build the collapsing glass tab bar
Create LiquidGlassTabs.tsx:
import { useRef, useState } from 'react';
import {
Pressable,
StyleSheet,
Text,
View,
type View as NativeView,
} from 'react-native';
import { BlurTargetView, BlurView } from 'expo-blur';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
Extrapolation,
interpolate,
useAnimatedScrollHandler,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
type ScrollEvent,
} from 'react-native-reanimated';
const TABS = ['Home', 'Discover', 'Saved', 'Activity', 'Profile'];
const GLYPHS = ['H', 'D', 'S', 'A', 'P'];
export function LiquidGlassTabs() {
const [active, setActive] = useState(0);
const blurTarget = useRef<NativeView>(null);
const reducedMotion = useReducedMotion();
const progress = useSharedValue(0);
const onScroll = useAnimatedScrollHandler((event: ScrollEvent) => {
const next = Math.min(Math.max(event.contentOffset.y / 120, 0), 1);
progress.set(reducedMotion ? Math.round(next) : next);
});
const shellStyle = useAnimatedStyle(() => ({
height: interpolate(progress.get(), [0, 1], [68, 50]),
width:
interpolate(
progress.get(),
[0, 1],
[100, 82],
Extrapolation.CLAMP,
) + '%',
transform: [
{ translateY: interpolate(progress.get(), [0, 1], [0, -8]) },
{ scale: interpolate(progress.get(), [0, 1], [1, 0.98]) },
],
}));
const tabsStyle = useAnimatedStyle(() => ({
opacity: interpolate(
progress.get(),
[0, 0.9],
[1, 0.82],
Extrapolation.CLAMP,
),
}));
const highlightStyle = useAnimatedStyle(() => ({
opacity: interpolate(progress.get(), [0, 1], [0.72, 0.42]),
transform: [
{ translateX: interpolate(progress.get(), [0, 1], [-30, 48]) },
],
}));
return (
<View style={styles.screen}>
<BlurTargetView ref={blurTarget} style={StyleSheet.absoluteFill}>
<LinearGradient
colors={['#E9E5FF', '#D8F4F1', '#FFF1E2']}
style={StyleSheet.absoluteFill}
/>
<Animated.ScrollView
contentContainerStyle={styles.content}
onScroll={onScroll}
scrollEventThrottle={16}
>
<Text style={styles.heading}>Today</Text>
{Array.from({ length: 10 }, (_, index) => (
<View key={index} style={styles.feedCard}>
<Text style={styles.feedTitle}>Collection {index + 1}</Text>
<Text style={styles.feedCopy}>
A calm surface behind the floating navigation material.
</Text>
</View>
))}
</Animated.ScrollView>
</BlurTargetView>
<Animated.View style={[styles.shell, shellStyle]}>
<BlurView
blurMethod="dimezisBlurViewSdk31Plus"
blurTarget={blurTarget}
intensity={46}
tint="systemThinMaterial"
style={styles.blur}
>
<LinearGradient
colors={['rgba(255,255,255,0.58)', 'rgba(255,255,255,0.08)']}
pointerEvents="none"
style={styles.edge}
/>
<Animated.View pointerEvents="none" style={[styles.glint, highlightStyle]} />
<Animated.View
accessibilityLabel="Primary navigation"
accessibilityRole="tablist"
style={[styles.tabs, tabsStyle]}
>
{TABS.map((tab, index) => (
<Pressable
key={tab}
accessibilityLabel={tab}
accessibilityRole="tab"
accessibilityState={{ selected: active === index }}
onPress={() => setActive(index)}
style={styles.tab}
>
<View style={[styles.lens, active === index && styles.activeLens]}>
<Text
style={[styles.glyph, active === index && styles.activeGlyph]}
>
{GLYPHS[index]}
</Text>
</View>
</Pressable>
))}
</Animated.View>
</BlurView>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
screen: { backgroundColor: '#E9E5FF', flex: 1 },
content: { gap: 14, padding: 20, paddingBottom: 130 },
heading: { color: '#1D1A29', fontSize: 34, fontWeight: '700', marginTop: 40 },
feedCard: {
backgroundColor: 'rgba(255,255,255,0.58)',
borderRadius: 24,
minHeight: 130,
padding: 20,
},
feedTitle: { color: '#242031', fontSize: 19, fontWeight: '600' },
feedCopy: { color: '#686173', fontSize: 14, marginTop: 8 },
shell: {
alignSelf: 'center',
borderRadius: 34,
bottom: 24,
boxShadow: '0px 14px 38px rgba(36,30,58,0.18)',
overflow: 'hidden',
position: 'absolute',
},
blur: { flex: 1, overflow: 'hidden' },
edge: {
borderColor: 'rgba(255,255,255,0.62)',
borderRadius: 34,
borderWidth: 1,
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
top: 0,
},
glint: {
backgroundColor: 'rgba(255,255,255,0.74)',
borderRadius: 2,
height: 2,
left: '18%',
position: 'absolute',
top: 1,
width: '38%',
},
tabs: { alignItems: 'center', flex: 1, flexDirection: 'row' },
tab: { alignItems: 'center', flex: 1, justifyContent: 'center', minHeight: 48 },
lens: {
alignItems: 'center',
borderRadius: 21,
height: 42,
justifyContent: 'center',
width: 42,
},
activeLens: { backgroundColor: 'rgba(117,103,255,0.18)' },
glyph: { color: '#5F596B', fontSize: 14, fontWeight: '600' },
activeGlyph: { color: '#5947E8' },
});Why one collapse value is enough
The scroll handler converts vertical offset into a clamped zero-to-one progress. Shell size, lift, scale, tab opacity, and the glint position all derive from that number. If the user scrolls upward mid-collapse, every layer reverses together because there is no collection of competing timing animations.
Writing directly from useAnimatedScrollHandler keeps scroll-linked work on the UI thread. Do not call a React state setter with every scroll event. React state is appropriate for the discrete active tab because it changes only when the user makes a selection.
Reduced Motion rounds the material to an expanded or condensed state instead of continuously morphing with every point of scroll. Navigation remains in the same location and no information disappears. You can also choose to keep the bar permanently expanded under Reduce Motion if that is clearer for your layout.
Tune blur, tint, and contrast
Blur intensity is not comparable across every platform and background. Start near 40–50, test above both light and dark content, and add a tint layer rather than raising blur until text becomes legible. The border and glint should be subtle: they describe thickness, not a glowing outline.
systemThinMaterial follows the platform material family on supported Apple platforms. On Android, the explicit blur method and target make the native path predictable. Older Android versions receive a translucent surface, so the gradient, border, and shadow must still produce sufficient separation without blur.
Do not use transparency as the only contrast strategy. Active glyph color, lens tint, and at least 3:1 contrast for meaningful graphical controls should survive the busiest background in your product. Test real photos and video if they can pass beneath the bar.
Connect it to navigation
The tutorial keeps selection local so the material code is self-contained. In an Expo Router application, make the active index derive from the current route and call router.push or render the bar inside the appropriate layout. Keep route files in src/app/ and this visual component outside the route tree.
Avoid placing five nested navigation stacks behind one custom bar unless the product needs preserved stack state. The bar is a presentation layer; route ownership and back behavior should remain with Expo Router. See the official Expo Router introduction for the current routing model.
If the compact state changes which actions are available, ensure that every route remains reachable. A visual collapse should not silently remove navigation options from assistive technology.
Accessibility
The tab container uses the tab-list role, each item uses the tab role, and accessibilityState.selected communicates the active destination. Labels use full words instead of the decorative one-letter glyphs. Touch targets remain at least 48 points high even when the visible lens is smaller.
Blur and translucency can reduce contrast unpredictably, so test both color schemes and varied content. Do not rely on the purple lens alone; selected glyph color and semantic state reinforce it. If you replace glyphs with icons, hide decorative copies and retain labels.
Reduce Motion is handled at the shared progress source. That is safer than trying to remove transforms independently because the material cannot land in a mismatched half-collapsed state.
Performance
Use one BlurView for the bar rather than one blur per tab. Native blur can be expensive, particularly over animated or video content. The single target and single clipped surface match the strategy used by the catalog piece.
The scroll event writes one shared value and all geometry derives on the UI thread. scrollEventThrottle={16} provides responsive native events without React renders. Avoid animated React props for width when a Reanimated style can update directly.
On long feeds, optimize the content list independently with Animated.FlatList. Attach the Reanimated handler to the animated list component, not the plain React Native version. Profile blur on real lower-end Android hardware and be willing to choose blurMethod="none" with a stronger tint for a performance tier.
Common pitfalls
- Using a translucent white view and calling it blur. Real material needs a blurred backdrop plus tint, edge, shadow, and a no-blur fallback.
- Creating one BlurView per tab. Multiple native blur surfaces increase cost and can produce visible seams.
- Updating React state on scroll. Write offset-derived progress to a shared value and keep per-frame work on the UI thread.
- Forgetting the Android blur method. The default may render only a translucent fallback; choose and test the SDK 57 method deliberately.
- No
BlurTargetViewon Android. The modern API needs an explicit target for the content being blurred. - Collapsing the hit targets with the artwork. Visible icons can shrink, but interactive areas must remain comfortably tappable.
- Assuming blur guarantees contrast. Busy backgrounds still require tint and contrast testing.
- Using proprietary assets or implying affiliation. Recreate the general material behavior with your own identity and icons.
Skip the build: production-ready version
The production-ready Collapsing Glass Tab Bar adds a reusable scroll hook, spring-driven active lens, expanded and compact navigation states, configurable glass controls, responsive geometry, imperative selection, and a tuned single-target Android blur path. It includes reduced-motion and tab semantics and is tested on iOS, Android, and web. The Native Animation CLI guide explains how catalog pieces are added to a project.
FAQ
How do I create a Liquid Glass effect in React Native?
Combine BlurView with a translucent tint gradient, a fine bright edge, restrained shadow, and rounded clipping. Animate the surface as one material and provide a tinted fallback for platforms where native blur is unavailable or too expensive.
Does expo-blur work on Android?
Yes, but you must choose the Android blur method deliberately and provide a BlurTargetView for the modern native implementation. With dimezisBlurViewSdk31Plus, Android SDK 31+ uses native blur and older versions fall back to no blur.
How do I animate a tab bar based on scroll in React Native?
Use an animated scroll component and useAnimatedScrollHandler to normalize the offset into a shared value. Derive width, height, opacity, and transforms from that value in animated styles.
Is this an official Apple component?
No. It is an independent React Native implementation inspired by the general Liquid Glass look of iOS 26, built with Expo APIs and original generic visuals rather than Apple assets.