How the Qaf mobile app builds its native, glass-morphic UI — the composer, nav buttons, icons, headers, sheets and menus — and the libraries, transitions, blur, morphing and mount/unmount patterns behind them.
The app pursues a truly native “Liquid Glass” aesthetic on iOS 26 while degrading gracefully everywhere else. It does this through a small set of abstractions rather than scattering platform checks across the codebase.
UniversalGlassView wraps Apple’s native glass (expo-glass-effect’s GlassView) and, when glass isn’t available, falls back to a plain styled View. Almost every glass surface in the app routes through it or its sibling GlassIconButton.isGlassAvailable = isLiquidGlassAvailable() decides the branch — so the same binary runs on older iOS / Android with blur or solid fallbacks.@expo/ui (SwiftUI / Jetpack Compose bridges) — not JS re-implementations.react-native-reanimated for worklet-driven physics (springs, sensors, frame callbacks, shared-value choreography) and react-native-ease (EaseView) for simple declarative opacity/scale state transitions.MaskedView + LinearGradient + BlurView into a ProgressiveBlurView.@shopify/react-native-skia with gyroscope-driven tilt + glare and a 3D flip.On “tabs”: the app has no tab bar / tab navigator. Top-level navigation is an expo-router Drawer (chat history) plus a floating glass header with a glass “options” pill. Settings/Referrals are presented as formSheet modals. The closest things to “tab-like” glass surfaces are the header GlassIconButtons and the ChatOptionsMenu pill — both covered below.
Every package that contributes to native components, glass, blur, icons, animation or transitions. (Application/data libs like auth, storage and markdown are intentionally omitted.)
| Package | Version | Role in the UI |
|---|---|---|
| expo-glass-effect | ~56.0.4 | Native iOS Liquid Glass. GlassView + isLiquidGlassAvailable(); props isInteractive, tintColor, glassEffectStyle. |
| expo-blur | ~56.0.3 | Native BlurView — the building block for progressive/animated blur fallbacks. |
| @expo/ui | ~56.0.16 | SwiftUI / Jetpack Compose bridge. Native MenuView (context menus), BottomSheet, Icon.select (SF Symbols ↔ Material Symbols). |
| @react-native-masked-view/masked-view | ^0.3.2 | Alpha masking — powers progressive blur and the shimmer sweep. |
| expo-linear-gradient | ~56.0.4 | Gradients used as blur masks, tactile-button sheen and shimmer gradient. |
| Package | Version | Role |
|---|---|---|
| lucide-react-native | ^1.14.0 | Primary icon set. ~70 icons in a central registry (icons.tsx), Tailwind-styled via Uniwind. |
| @expo/material-symbols | ^0.1.1 | Material Symbols XML — Android side of native menu icons (via Icon.select). |
| react-native-svg | 15.15.5 | Hand-drawn brand/social icons (Apple, Instagram, TikTok, X) and the Qaf wordmark. |
SF Symbols are used only through @expo/ui’s Icon.select for native menus — there is no expo-symbols/SymbolView in the app. | ||
| Package | Version | Role |
|---|---|---|
| react-native-reanimated | ~4.4.1 | Worklet-driven animation engine: shared values, springs/timings, useAnimatedProps/Style, sensors, frame callbacks. |
| react-native-worklets | 0.9.1 | Worklet runtime; scheduleOnRN to call JS from gesture worklets. |
| react-native-ease | ^0.7.2 | Declarative EaseView — opacity/scale state transitions (mic↔send swap, list fade-in). |
| react-native-gesture-handler | ^2.31.2 | Native gestures — drawer swipe, swipe-to-send / dismiss-keyboard. |
| react-native-keyboard-controller | 1.21.11 | Keyboard-aware layout: KeyboardStickyView, KeyboardController.dismiss(), reanimated keyboard height. |
| @shopify/react-native-skia | ^2.6.4 | GPU canvas — the membership card (pattern, glare, text, flip). |
| react-native-qrcode-skia | ^0.4.0 | QR code rendered onto the Skia card back. |
| expo-haptics | ~56.0.3 | Haptic feedback (wrapped by lib/haptics, respects a settings toggle). |
| Package | Version | Role |
|---|---|---|
| expo-router | ~56.2.9 | File-based routing — Stack + Drawer, Stack.Protected guards, useDrawerProgress. |
| react-native-screens | 4.25.2 | Native navigation primitives (native stack transitions, freezeOnBlur). |
| heroui-native | ^1.0.4 | Base component lib + theme system: Button, ListGroup, Switch, useThemeColor, cn, colorKit. |
| uniwind | ^1.8.0 | Tailwind v4 for RN: withUniwind HOC + className on native components. |
| tailwindcss · tailwind-variants · tailwind-merge | v4 | Utility styling + variant composition + class merging. |
| @legendapp/list | 3.0.4 | High-performance chat message list (keyboard-aware, recycling). |
| @shopify/flash-list | 2.3.1 | List virtualization (drawer / other lists). |
Everything glass funnels through universal-glass-view.tsx. The platform decision is made once at module load, so the dead branch is eliminated by the bundler and no per-render feature checks are needed.
className, props)→
UniversalGlassView→
iOS 26: GlassView native
View + viewClassName fallback
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { withUniwind } from "uniwind";
export const StyledGlassView = withUniwind(GlassView); // className support
export const isGlassAvailable = isLiquidGlassAvailable(); // computed once
export function UniversalGlassView({ glassViewProps, className, viewClassName, ...props }) {
if (isGlassAvailable) {
return <StyledGlassView className={className} {...props} {...glassViewProps} />;
}
return <View className={cn(className, viewClassName)} {...props} />;
}
isInteractive (native press highlight/morph), tintColor, and glassEffectStyle ("regular" / "none") — the last one is the lever for animating glass on/off.
Glass surfaces are wrapped with Animated.createAnimatedComponent(StyledGlassView) so glassEffectStyle can be driven from a shared value via useAnimatedProps.
When glass is unavailable the component renders a plain View with a separate viewClassName (e.g. bg-accent-soft / border bg-field) so styling never conflicts with glass-only props.
The opacity-animation workaround. expo-glass-effect has a documented bug animating a GlassView’s opacity. Instead of fading opacity, the app toggles glassEffectStyle between "regular" and "none" from a derived value — see scroll-to-bottom-button.tsx in §05.
composedchat-input.tsx — a glass-surfaced input that morphs its trailing button between mic → send → stop, hosts an inline voice recorder, and stays glued to the keyboard.
UniversalGlassView (rounded-3xl, isInteractive) with a border bg-field fallback for non-glass devices.TextInput; submission dismisses the keyboard via KeyboardController.dismiss() (react-native-keyboard-controller).EaseView spring transitions (opacity + scale 0.01→1). The “stop / submitting” state swaps in with Reanimated FadeIn/FadeOut.haptics.tap() on submit and stop.// mic ⇄ send share one slot and spring between states
const SEND_BUTTON_SPRING = { type: "spring", damping: 18, stiffness: 247, mass: 1 };
<EaseView animate={{ opacity: showMic ? 1 : 0, scale: showMic ? 1 : 0.01 }} transition={SEND_BUTTON_SPRING}>
<IconButton icon="mic" onPress={voice.start} variant="secondary" />
</EaseView>
<EaseView animate={{ opacity: showSend ? 1 : 0, scale: showSend ? 1 : 0.01 }} style={absoluteFill}>
<IconButton icon="arrowUp" onPress={handleSubmit} tactile /> // tactile = raised primary
</EaseView>
{isActive && (
<Animated.View entering={FadeIn.duration(150)} exiting={FadeOut.duration(150)}>
{status === "submitted" ? <Spinner/> : <IconButton icon="square" onPress={handleStop} tactile/>}
</Animated.View>
)}
voice-input.tsx overlays the text row when recording. The waveform is 40 bars whose heights are driven entirely on the UI thread: a single SharedValue<number[]> holds the rolling sample buffer and each bar reads its own slot inside a worklet, so metering updates at 10–30 Hz never trigger a React render.
// one buffer, each bar is a worklet reading its slot
const samples = useSharedValue<number[]>(new Array(BAR_COUNT).fill(SILENT_DB));
const animatedStyle = useAnimatedStyle(() => ({
height: BAR_MIN_HEIGHT + normalizeMetering(samples.value[index] ?? SILENT_DB) * BAR_MAX_HEIGHT,
}));
State machine: showMic / showSend / isActive / isVoiceActive derive the trailing control; the recorder UI fades in with Reanimated FadeIn/FadeOut and the text field dims to opacity-0 while recording.
Day-to-day UI icons are Lucide vectors behind a central registry; native menus use real SF Symbols / Material Symbols via @expo/ui.
icons.tsx imports ~70 Lucide icons into a typed map and exposes a single LucideIcon (wrapped with withUniwind so it takes Tailwind classes like text-foreground). A missing key falls back to a help icon. Sizes are standardized in iconButtonSizes.
const icons = { home: HomeIcon, search: SearchIcon, mic: MicIcon, arrowUp: ArrowUpIcon, /* …~70 */ }
satisfies Record<string, React.ComponentType<LucideProps>>;
const BaseIcon = ({ name, strokeWidth = 1.5, ...props }) => {
const Icon = icons[name] ?? CircleHelpIcon; // graceful fallback
return <Icon strokeWidth={strokeWidth} {...props} />;
};
export const LucideIcon = withUniwind(BaseIcon);
Inside native MenuViews, icons are chosen per-platform with Icon.select — SF Symbol strings on iOS, lazily-imported Material Symbols XML on Android:
const upvoteIcon = Icon.select({
ios: "hand.thumbsup",
android: import("@expo/material-symbols/thumb_up.xml"),
});
react-native-svg draws Apple, Instagram, TikTok and X icons plus the Qaf wordmark (themed via useThemeColor; a tinted variant for the membership card).
There is no expo-symbols/SymbolView — SF Symbols enter the app exclusively through @expo/ui menus. Everything in the rendered UI is Lucide or hand-drawn SVG.
nativeContext menus, the bottom sheet and settings rows are real native components bridged through @expo/ui (SwiftUI on iOS, Jetpack Compose on Android) — not JS look-alikes.
MenuView)Used for chat message actions (upvote/downvote), drawer list items (rename / share / destructive delete) and the settings appearance picker. Long-press or tap opens the system menu; icons come from Icon.select.
<MenuView
actions={[
{ id: "rename", title: t`Rename`, image: editIcon },
{ id: "delete", title: t`Delete`, image: deleteIcon, attributes: { destructive: true } },
]}
onPressAction={(e) => handle(e.nativeEvent.event)}
shouldOpenOnLongPress>
<Link asChild href={`/chat/${chat.id}`}>…</Link>
</MenuView>
ui/bottom-sheet.tsx wraps @expo/ui/community/bottom-sheet and exposes an ergonomic compound API (Header / Title / Description / Close / Action / Footer) using slot extraction (it walks children and routes them by type). Snap points, dynamic sizing and pan-to-close are native; the close button is a GlassIconButton. The citation modal and message-status sheet build on this same component.
Correction worth noting: @gorhom/bottom-sheet is listed in package.json but is never imported directly in the app — every sheet uses the native @expo/ui one above. Settings rows likewise use heroui’s native-styled ListGroup / ListGroup.Item / Switch.
Canvas draws a tiled pattern (built via PathBuilder + Matrix), a mihrab motif, the wordmark, Arabic text (ParagraphBuilder, RTL) and monospace labels; the QR back uses react-native-qrcode-skia.use-card-tilt.ts reads the gyroscope with useAnimatedSensor and a per-frame useFrameCallback applying exponential decay; the glare is a RadialGradient whose center is a useDerivedValue of the tilt, blended in screen mode.withTiming rotates rotateY 0→180° (340 ms, Easing.out(Easing.cubic)) with front/back opacity swapping at the 50% mark; a font-gated fade-in avoids FOUT.Where real glass can’t produce a soft directional fade, the app composes blur by hand. Two reusable primitives do most of the work.
A MaskedView applies a 14-stop ease curve (custom easeGradient) as an alpha mask over a BlurView + tint gradient, so the blur ramps from transparent to opaque with no hard edge. Directional (top-to-bottom / bottom-to-top), memoized on its inputs. Used behind headers, the drawer footer and the citation modal.
A masked, moving LinearGradient sweep (Reanimated withRepeat(withSequence(withTiming…)), linear easing), RTL-aware, gradient 2.5× the content width. A stopped prop pauses the loop without unmounting — important for recycled list rows. Drives loading skeletons and the “thinking” status icons.
// progressive-blur-view.tsx — mask a blur with an eased gradient
const { colors, locations } = useMemo(() => easeGradient({
colorStops: {
[isTopToBottom ? 1 : 0]: { color: colorKit.setAlpha(color, 0).hex() },
[isTopToBottom ? 0 : 1]: { color: colorKit.setAlpha(color, opacity).hex() },
},
}), [color, opacity, isTopToBottom]);
<MaskedView maskElement={<LinearGradient colors={colors} locations={locations}/>}>
<BlurView intensity={intensity} tint={tint} />
</MaskedView>
Wraps BlurView in Animated.createAnimatedComponent and drives its intensity from a SharedValue via useAnimatedProps — for interpolating between blur states.
glassEffectStyle "regular" ⇄ "none" (FAB, scroll-to-bottom).BlurView (headers, footers).EaseView cross-fade + scale (mic ⇄ send) and FadeIn/FadeOut swaps (send ⇄ stop ⇄ spinner).borderRadius + overlay (the screen folds into a card).rotateY flip + gyro tilt on the Skia card.The app deliberately runs two animation libraries. Knowing which is which explains nearly every transition in the codebase.
Used whenever animation needs to live on the UI thread or be coordinated across components: springs (withSpring), timings (withTiming), useDerivedValue, useAnimatedProps/Style, useAnimatedSensor, useFrameCallback, and shared-value state shared via React context.
EaseView animates opacity/scale straight from props with an easing curve — no shared values, no boilerplate. Used for the mic↔send swap, the message-list fade-in gate, suggestions and empty-state reveals.
When a chat’s first message is sent, the user bubble springs up from behind the keyboard while fading in; the assistant bubble then fades in only after that animation reports complete. Coordination flows through a ChatScrollContext of shared values (isMessageSendAnimating, isFirstMsgAnimComplete, isAtBottom, isListReady) — not prop drilling.
// use-first-message-animation.ts — instant set, then spring + timed fade
translateY.value = withTiming(start.translateY, { duration: 0 }, () => {
translateY.value = withSpring(end.translateY, { damping: 28, stiffness: 200, mass: 0.7 });
});
progress.value = withTiming(start.progress, { duration: 0 }, () => {
progress.value = withTiming(end.progress, { duration, easing: Easing.out(Easing.cubic) });
});
@legendapp/list’s KeyboardAwareLegendList with getItemType recycling; the whole container’s opacity is gated by an EaseView tied to the list’s onLoad (existing chats render at the bottom first, then fade in).react-native-keyboard-controller supplies height as a shared value and a scrollMessageToEnd/freeze pair so the list stays pinned while the keyboard animates.react-native-gesture-handler drives swipe-to-send / swipe-to-dismiss in the empty state; the worklet calls back to JS via scheduleOnRN (react-native-worklets).FadeIn/FadeOut (durations ~150 ms) for the composer’s active state and voice sub-states.EaseView opacity/scale for controls and list fade-in.height + overflow:hidden) so it truly leaves layout on search focus, not just fades.react-native-screens native transitions; freezeOnBlur halts off-screen renders.detachInactiveScreens on Android (enables the rounded iOS drawer look elsewhere).Stack.Protected mounts/unmounts the entire auth vs drawer tree on sign-in state.formSheet modals (native bottom presentation).Performance-minded unmount avoidance: the Shimmer’s stopped prop and the waveform’s single shared buffer both keep components mounted while halting work — so recycled rows and high-frequency updates don’t churn the React tree.
| Goal | Technique | Where |
|---|---|---|
| Native liquid glass | expo-glass-effect GlassView, runtime-gated | UniversalGlassView, GlassIconButton |
| Animate glass on/off | toggle glassEffectStyle via useAnimatedProps (opacity bug workaround) | scroll-to-bottom FAB |
| Soft directional blur fade | MaskedView + eased LinearGradient + BlurView | ProgressiveBlurView |
| Loading skeleton | masked moving gradient, RTL-aware, pausable | Shimmer |
| Button morph (mic⇄send) | stacked EaseView spring (opacity+scale) | composer |
| State swap (send⇄stop) | Reanimated FadeIn/FadeOut | composer |
| Raised primary button | gradient sheen + dual shadow + circular curve | TactileButton |
| Native context menu | @expo/ui MenuView + Icon.select | message/drawer/settings |
| Native bottom sheet | @expo/ui/community/bottom-sheet + slot-extraction compound API | BottomSheet |
| Drawer “card slide” | useDrawerProgress → borderRadius + overlay | ScreenBg |
| First-message entrance | spring translateY + timed fade, context-coordinated | use-first-message-animation |
| Gyro tilt + glare + flip | Skia + useAnimatedSensor + useFrameCallback + rotateY | membership card |
| Real-time waveform | one shared buffer, per-bar worklet, zero re-renders | voice input |
Runtime-gated, not iOS-26-locked. The app is not hard-coded to iOS 26. isLiquidGlassAvailable() picks the branch at launch; unsupported OSes get blur/solid fallbacks from the same binary.
Build context. Expo SDK 56 / React Native 0.85.3 / React 19, with reactCompiler and typedRoutes enabled in app.config.ts. The New Architecture is the SDK 56 default (no opt-out present).
Dependency vs. usage. @gorhom/bottom-sheet appears in package.json but is not imported anywhere in src — the live sheet is the native @expo/ui one. Likewise, @shopify/flash-list is present, but chat uses @legendapp/list.