Mobile App · apps/native · Expo SDK 56

Liquid Glass & Native Component Architecture

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.

Expo ~56 React Native 0.85.3 React 19 New Architecture (SDK 56 default) React Compiler on Target iOS 26 Liquid Glass + fallback
3
Glass tiers: native → fallback, runtime-gated
2
Animation engines: Reanimated + Ease
@expo/ui
SwiftUI / Jetpack Compose bridge
Skia
GPU canvas for the member card

Section 01

01Executive overview

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.

  • One glass primitive. 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.
  • Runtime-gated, not version-locked. A single module-level constant isGlassAvailable = isLiquidGlassAvailable() decides the branch — so the same binary runs on older iOS / Android with blur or solid fallbacks.
  • Native components where it counts. Context menus, the bottom sheet, settings rows and menu icons are real native UI via @expo/ui (SwiftUI / Jetpack Compose bridges) — not JS re-implementations.
  • Two animation engines on purpose. 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.
  • Blur ≠ glass. Where real glass can’t do the job (soft directional fades behind headers/footers), the app composes MaskedView + LinearGradient + BlurView into a ProgressiveBlurView.
  • Skia for the showpiece. The membership card is rendered entirely in @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.

Section 02

02The stack at a glance

Every package that contributes to native components, glass, blur, icons, animation or transitions. (Application/data libs like auth, storage and markdown are intentionally omitted.)

Glass, blur & native UI

PackageVersionRole in the UI
expo-glass-effect~56.0.4Native iOS Liquid Glass. GlassView + isLiquidGlassAvailable(); props isInteractive, tintColor, glassEffectStyle.
expo-blur~56.0.3Native BlurView — the building block for progressive/animated blur fallbacks.
@expo/ui~56.0.16SwiftUI / Jetpack Compose bridge. Native MenuView (context menus), BottomSheet, Icon.select (SF Symbols ↔ Material Symbols).
@react-native-masked-view/masked-view^0.3.2Alpha masking — powers progressive blur and the shimmer sweep.
expo-linear-gradient~56.0.4Gradients used as blur masks, tactile-button sheen and shimmer gradient.

Icons

PackageVersionRole
lucide-react-native^1.14.0Primary icon set. ~70 icons in a central registry (icons.tsx), Tailwind-styled via Uniwind.
@expo/material-symbols^0.1.1Material Symbols XML — Android side of native menu icons (via Icon.select).
react-native-svg15.15.5Hand-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.

Animation, gesture & layout

PackageVersionRole
react-native-reanimated~4.4.1Worklet-driven animation engine: shared values, springs/timings, useAnimatedProps/Style, sensors, frame callbacks.
react-native-worklets0.9.1Worklet runtime; scheduleOnRN to call JS from gesture worklets.
react-native-ease^0.7.2Declarative EaseView — opacity/scale state transitions (mic↔send swap, list fade-in).
react-native-gesture-handler^2.31.2Native gestures — drawer swipe, swipe-to-send / dismiss-keyboard.
react-native-keyboard-controller1.21.11Keyboard-aware layout: KeyboardStickyView, KeyboardController.dismiss(), reanimated keyboard height.
@shopify/react-native-skia^2.6.4GPU canvas — the membership card (pattern, glare, text, flip).
react-native-qrcode-skia^0.4.0QR code rendered onto the Skia card back.
expo-haptics~56.0.3Haptic feedback (wrapped by lib/haptics, respects a settings toggle).

Navigation, styling & lists

PackageVersionRole
expo-router~56.2.9File-based routing — Stack + Drawer, Stack.Protected guards, useDrawerProgress.
react-native-screens4.25.2Native navigation primitives (native stack transitions, freezeOnBlur).
heroui-native^1.0.4Base component lib + theme system: Button, ListGroup, Switch, useThemeColor, cn, colorKit.
uniwind^1.8.0Tailwind v4 for RN: withUniwind HOC + className on native components.
tailwindcss · tailwind-variants · tailwind-mergev4Utility styling + variant composition + class merging.
@legendapp/list3.0.4High-performance chat message list (keyboard-aware, recycling).
@shopify/flash-list2.3.1List virtualization (drawer / other lists).

Section 03

03The glass core — one abstraction, three tiers

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.

native real OS surfacecomposed RN composition
consumer (className, props) UniversalGlassView iOS 26: GlassView native
↳ otherwise plain 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} />;
}

Props exposed

isInteractive (native press highlight/morph), tintColor, and glassEffectStyle ("regular" / "none") — the last one is the lever for animating glass on/off.

Reanimated bridge

Glass surfaces are wrapped with Animated.createAnimatedComponent(StyledGlassView) so glassEffectStyle can be driven from a shared value via useAnimatedProps.

Fallback strategy

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.

Section 04

04The composer (chat input bar)

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.

How it’s built

// 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>
)}

The voice waveform — UI-thread, zero re-renders

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.

Section 05

05Buttons — glass, tactile & icon

Three button families cover the app: a glass button for floating/nav surfaces, a tactile raised button for primary actions, and a plain icon button.

nativeGlassIconButton

ui/glass-icon-button.tsx

Circular PressableAnimatedGlassView (isInteractive, optional tintColor). Falls back to a bg-accent-soft pressable. Accepts animatedGlassViewProps so callers can drive the glass from a shared value.

composedTactileButton

ui/tactile-button.tsx

Skeuomorphic depth — not glass. A heroui Button + a LinearGradient sheen (theme-aware: white highlight in light mode, black in dark) + a dual-layer drop shadow, with borderCurve:"circular" to keep iOS anti-aliasing clean.

composedIconButton

ui/icon-button.tsx

The workhorse. Wraps heroui Button (isIconOnly), a size map (xl…xs), variants (ghost/secondary/…), and an optional tactile flag that layers in the gradient sheen.

Animated glass + the opacity workaround

The scroll-to-bottom FAB is the clearest example of animating native glass. Opacity isn’t animated on the glass directly — instead a derived value fades a wrapper while glassEffectStyle snaps between "regular" and "none":

// scroll-to-bottom-button.tsx — glass on/off, not opacity
const targetOpacity = useDerivedValue(() => {
  if (!(hasMessages && isListReady.value)) return 0;
  return withTiming(isAtBottom.value ? 0 : 1, { duration: 150 });
});
const glassViewProps = useAnimatedProps((): GlassViewProps => ({
  glassEffectStyle: targetOpacity.value > 0.01 ? "regular" : "none",
}));

Native press feedback: glass buttons set isInteractive so iOS handles the press morph/highlight natively — no JS scale animation needed. The raised/tactile buttons rely on heroui’s built-in Pressable feedback plus the static sheen + shadow for depth.

Section 06

06Icons — one registry, native where it matters

Day-to-day UI icons are Lucide vectors behind a central registry; native menus use real SF Symbols / Material Symbols via @expo/ui.

composed Lucide registry — the default path

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);

native Platform symbols — for native menus only

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"),
});

Brand / social

react-native-svg draws Apple, Instagram, TikTok and X icons plus the Qaf wordmark (themed via useThemeColor; a tinted variant for the membership card).

No SF Symbols library

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.

Section 08

08Native UI primitives — @expo/ui

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.

Native context menus (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>

The bottom sheet — native, as a compound component

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.

skia The membership card — a GPU canvas showpiece

Section 09

09Blur, masking & morphing

Where real glass can’t produce a soft directional fade, the app composes blur by hand. Two reusable primitives do most of the work.

composedProgressiveBlurView

ui/progressive-blur-view.tsx

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.

composedShimmer

ui/shimmer.tsx

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>

composedAnimatedBlurView

Wraps BlurView in Animated.createAnimatedComponent and drives its intensity from a SharedValue via useAnimatedProps — for interpolating between blur states.

Morphing techniques in one place

Section 10

10The animation system — two engines, by design

The app deliberately runs two animation libraries. Knowing which is which explains nearly every transition in the codebase.

Reanimated 4.4 — physics & coordination

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.

react-native-ease 0.7 — declarative state

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.

First-message choreography (the marquee transition)

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) });
});

Lists & keyboard

Section 11

11Mounting, unmounting & screen transitions

Enter / exit animations

  • Reanimated FadeIn/FadeOut (durations ~150 ms) for the composer’s active state and voice sub-states.
  • EaseView opacity/scale for controls and list fade-in.
  • Drawer header does a measured height-collapse (animated height + overflow:hidden) so it truly leaves layout on search focus, not just fades.

Screen-level lifecycle

  • 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.
  • Settings/Referrals enter as 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.

Section 12

12Technique cheat-sheet
GoalTechniqueWhere
Native liquid glassexpo-glass-effect GlassView, runtime-gatedUniversalGlassView, GlassIconButton
Animate glass on/offtoggle glassEffectStyle via useAnimatedProps (opacity bug workaround)scroll-to-bottom FAB
Soft directional blur fadeMaskedView + eased LinearGradient + BlurViewProgressiveBlurView
Loading skeletonmasked moving gradient, RTL-aware, pausableShimmer
Button morph (mic⇄send)stacked EaseView spring (opacity+scale)composer
State swap (send⇄stop)Reanimated FadeIn/FadeOutcomposer
Raised primary buttongradient sheen + dual shadow + circular curveTactileButton
Native context menu@expo/ui MenuView + Icon.selectmessage/drawer/settings
Native bottom sheet@expo/ui/community/bottom-sheet + slot-extraction compound APIBottomSheet
Drawer “card slide”useDrawerProgressborderRadius + overlayScreenBg
First-message entrancespring translateY + timed fade, context-coordinateduse-first-message-animation
Gyro tilt + glare + flipSkia + useAnimatedSensor + useFrameCallback + rotateYmembership card
Real-time waveformone shared buffer, per-bar worklet, zero re-rendersvoice input

Corrections & clarifications

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.