| name | expo-chat-ui |
| description | Build a production-quality chat UI with keyboard handling, recycling lists, gestures, and platform-specific implementations |
Overview
This skill covers building a performant chat UI in Expo with:
- Smooth keyboard animations with interactive dismissal
- High-performance recycling list for messages
- Safe area and inset management
- Platform-specific implementations (native SwiftUI / web HTML)
- Image attachments and speech recognition
Required Dependencies
npx expo install react-native-reanimated react-native-safe-area-context react-native-keyboard-controller @legendapp/list @react-native-masked-view/masked-view expo-haptics expo-speech-recognition expo-glass-effect @expo/ui
Architecture
components/
├── keyboard-scroll-view.tsx # ChatView wrapper with keyboard handling
├── measure-node.tsx # Height measurement context
├── composer/
│ ├── composer.tsx # Native implementation
│ ├── composer.web.tsx # Web implementation
│ ├── composer-text-input.tsx # Text input with keyboard integration
│ ├── send-button.tsx # Send/Mic/Stop button states
│ ├── multi-button.tsx # Context menu for attachments
│ └── selected-media-context.tsx # Image attachment state
└── lib/
├── haptic.ts # Native haptics
└── haptic.web.ts # Web haptics (no-op)
Core Components
1. ChatView Wrapper
The ChatView component provides context for measuring toolbar height and coordinating keyboard animations.
import { MeasureNode } from "./measure-node";
export function ChatView(props: { children?: React.ReactNode }) {
return <MeasureNode>{props.children}</MeasureNode>;
}
ChatView.Toolbar = ChatViewToolbar;
ChatView.ScrollView = ChatScrollView;
2. MeasureNode Context
Tracks dynamic heights using shared values for smooth animations:
import { SharedValue, useSharedValue } from "react-native-reanimated";
import { View } from "react-native";
const MeasureNodeContext = React.createContext<{
uiHeight: SharedValue<number>;
} | null>(null);
export function MeasureNode(props: { children?: React.ReactNode }) {
const uiHeight = useSharedValue(0);
return (
<MeasureNodeContext value={{ uiHeight }}>
{props.children}
</MeasureNodeContext>
);
}
MeasureNode.Trigger = function Trigger(props: { children?: React.ReactNode }) {
const ref = useRef<View>(null);
const { uiHeight } = React.use(MeasureNodeContext);
useLayoutEffect(() => {
if (!ref.current) return;
rect = ref..();
uiHeight. = rect.;
}, [ref, uiHeight]);
= () => {
uiHeight.(layout.);
};
;
};
. = () {
context = .();
context.;
};
Keyboard Handling
useKeyboardAnimation Hook
Handles all keyboard lifecycle events for smooth animations:
import {
useReanimatedKeyboardAnimation,
useKeyboardHandler,
} from "react-native-keyboard-controller";
import {
useSharedValue,
useAnimatedScrollHandler,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const useKeyboardAnimation = () => {
const progress = useSharedValue(0);
const height = useSharedValue(0);
const inset = useSharedValue(0);
const offset = useSharedValue(0);
const scroll = useSharedValue(0);
const shouldUseOnMoveHandler = useSharedValue(false);
const { bottom } = useSafeAreaInsets();
useKeyboardHandler({
onStart: (e) => {
"worklet";
if (progress.value !== 1 && progress.value !== 0 && e.height !== 0) {
shouldUseOnMoveHandler.value = true;
return;
}
progress. = e.;
height. = e.;
inset. = .(bottom, height.);
offset. = .(.(height., bottom) + scroll., );
},
: {
;
progress. = e.;
height. = e.;
},
: {
;
(shouldUseOnMoveHandler.) {
progress. = e.;
height. = e.;
}
},
: {
;
height. = e.;
progress. = e.;
shouldUseOnMoveHandler. = ;
},
});
isCloseToEnd = ();
onScroll = (
{
: {
scroll. = e.. - inset.;
},
},
[]
);
{ height, onScroll, inset, offset, scroll, isCloseToEnd };
};
Toolbar Positioning
Position the composer toolbar above the keyboard:
function ChatViewToolbarPadding(
props: React.ComponentProps<typeof Animated.View>
) {
const { bottom: paddingBottom } = useSafeAreaInsets();
const { height, progress } = useReanimatedKeyboardAnimation();
const keyboard = useAnimatedKeyboard();
const toolbarHeightSharedValue = MeasureNode.useHeightSharedValue();
const bottomStyle = useAnimatedStyle(() => {
const absMax =
progress.value === 1
? Math.max(Math.abs(height.value), keyboard.height.value)
: Math.abs(height.value);
return {
bottom: Math.max(paddingBottom, absMax),
};
}, [paddingBottom, toolbarHeightSharedValue, height, keyboard, progress]);
return (
<Animated.View
{...props}
style={[{ position: "absolute" }, bottomStyle, ]}
=
/>
);
}
. = () {
(
);
};
Recycling List
ChatScrollView with Legend List
High-performance list using @legendapp/list:
import { LegendList, LegendListProps, LegendListRef } from "@legendapp/list";
const AnimatedLegendListComponent =
Animated.createAnimatedComponent(LegendList);
export type ChatScrollViewRef = AnimatedRef<LegendListRef>;
export type ChatScrollViewProps<T = any> = LegendListProps<T> & {
ref: ChatScrollViewRef;
};
function ChatScrollView<T>({
children,
ref,
...props
}: ChatScrollViewProps<T>) {
const toolbarHeightSharedValue = MeasureNode.useHeightSharedValue();
const scrollViewContentHeight = useSharedValue(0);
const { height } = useReanimatedKeyboardAnimation();
const { bottom } = useSafeAreaInsets();
const isTouching = useSharedValue(false);
const scrollViewHeight = useSharedValue(0);
const controllerProps = useKeyboardAnimation();
useImperativeHandle(
ref,
({
...ref?.,
: {
(isTouching.) ;
ref.?.({ animated });
},
}),
[ref]
);
scrollIndicatorInsets = (
({
: ,
:
.(, .(height.) - bottom) +
toolbarHeightSharedValue.,
}),
[toolbarHeightSharedValue, bottom, height]
);
animatedProps = ( ({
: {
:
.(controllerProps..) + toolbarHeightSharedValue.,
},
: {
: ,
: controllerProps..,
},
}));
(
);
}
Usage
<ChatView.ScrollView<MessageType>
ref={scrollViewRef}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
gap: 16,
paddingTop: 72,
alignItems: "stretch",
}}
maintainScrollAtEnd={isThinking}
maintainVisibleContentPosition={isThinking}
estimatedItemSize={32}
initialScrollIndex={messages.length - 1}
keyExtractor={(item) => item?.id}
recycleItems={true}
data={messages}
renderItem={({ item }) => <Message key={item.id} message={item} />}
ListFooterComponent={() =>
error ? <ChatErrorMessage error={error} /> : null
}
/>
Composer Text Input
Native Implementation with SwiftUI
import * as SUI from "@expo/ui/swift-ui";
import * as mod from "@expo/ui/swift-ui/modifiers";
import { ChatView } from "@/components/keyboard-scroll-view";
export function ComposerTextInput({
authenticated,
canSend,
scrollViewRef,
textInputRef,
onSend,
onChangeText,
setTextInput,
}: Props) {
return (
<ChatView.Toolbar style={{ width: "100%" }}>
<Host
style={{ minWidth: "100%", maxWidth: "100%" }}
matchContents={{ vertical: true }}
>
<SUI.GlassEffectContainer
spacing={4}
modifiers={[
mod.disabled(!authenticated),
frame({ idealWidth: 11111 }),
ignoreSafeArea({ regions: "all", edges: "all" }),
]}
>
{
if (focus) {
scrollViewRef?.current?.scrollToOffset({
offset: Number.MAX_SAFE_INTEGER - 1,
animated: true,
});
}
}}
onChangeText={onChangeText}
onSubmit={onSend}
autoFocus={authenticated}
modifiers={[
mod.disabled(!authenticated),
mod.padding({ leading: 12, top: 8, bottom: 8, trailing: 48 }),
mod.frame({
minHeight: 44,
maxHeight: 200,
idealWidth: 10000,
}),
mod.clipShape("roundedRectangle"),
]}
/>
);
}
Send Button with Speech Recognition
Three-state button: recording, ready to send, or thinking:
import {
ExpoSpeechRecognitionModule,
useSpeechRecognitionEvent,
} from "expo-speech-recognition";
import { haptics } from "@/lib/haptic";
export function SendButton({
disabled,
canSend,
onSend,
setTextInput,
textInputText,
}) {
const isThinking = useIsAgentThinking();
const { stop } = useAgentActions();
const [isRecording, setIsRecording] = useState(false);
useSpeechRecognitionEvent("start", () => setIsRecording(true));
useSpeechRecognitionEvent("end", () => {
setIsRecording(false);
const text = textInputText?.current?.trim();
if (text) {
haptics.success();
setTextInput(text, true);
}
});
useSpeechRecognitionEvent("result", (event) => {
const text = event.results[0]?.transcript || "";
if (text) setTextInput(text, false);
});
(isThinking) {
(
);
}
(isRecording || !canSend) {
(
);
}
(
);
}
Image Attachments
SelectedMediaContext
interface SelectedImage {
url: string;
}
const SelectedMediaContext =
createContext<SelectedMediaContextValue>(undefined);
export function SelectedMediaProvider({ children }) {
const [selectedImages, setSelectedImages] = useState<SelectedImage[]>([]);
const addImages = (images: SelectedImage[]) => {
setSelectedImages((current) => {
const combined = [...current, ...images];
const unique = Array.from(
new Map(combined.map((item) => [item.url, item])).values()
);
return unique.slice(-3);
});
};
const removeImage = (index: number) => {
setSelectedImages((current) => current.filter((_, i) => i !== index));
};
return (
{children}
);
}
Image Preview
function ImagePreviewSection() {
const { selectedImages, removeImage } = useSelectedMedia();
return (
<AnimatedFlatList
horizontal
entering={FadeIn}
exiting={FadeOut}
itemLayoutAnimation={LinearTransition}
keyboardShouldPersistTaps="handled"
contentContainerClassName="pt-2 px-3 gap-2"
data={selectedImages}
renderItem={({ item, index }) => (
<AnimatedView entering={FadeIn} exiting={FadeOut}>
<Image source={{ uri: item.url }} className="w-15 h-15 rounded-lg" />
<Pressable
onPress={() => removeImage(index)}
className="absolute -top-1 -right-1 w-6 h-6 rounded-full bg-black/80"
>
<SFIcon name="xmark" className="text-white" />
</Pressable>
</AnimatedView>
)}
/>
);
}
Haptic Feedback
Native (haptic.ts)
import * as Haptics from "expo-haptics";
export const haptics = {
impact(
type:
| "success"
| "warning"
| "error"
| "select"
| "light"
| "medium"
| "heavy"
) {
if (type === "success" || type === "warning" || type === "error") {
return Haptics.notificationAsync(type);
}
if (type === "select") {
return Haptics.selectionAsync();
}
return Haptics.impactAsync(type);
},
success: () =>
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success),
warning: () =>
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning),
:
.(..),
};
Web (haptic.web.ts)
export const haptics = {
impact: () => {},
success: () => {},
warning: () => {},
error: () => {},
};
Full Composer Usage
export function FullComposer() {
return (
<SelectedMediaProvider>
<View className="flex-1">
<ComposerModalBackdrop />
<Composer />
</View>
</SelectedMediaProvider>
);
}
function Composer() {
const { sendMessageAsync } = useAgentActions();
const { messages, error } = useAgentState();
const isThinking = useIsAgentThinking();
const textInputRef = useRef<SUI.TextFieldRef>(null);
const scrollViewRef = useRef<ChatScrollViewRef>(null);
const { selectedImages, clearImages } = useSelectedMedia();
const sendMessageWrapper = async (text: string) => {
if (!text.trim()) return;
textInputRef.current?.setText("");
clearImages();
sendMessageAsync({
text,
: selectedImages. ? selectedImages : ,
});
};
(
);
}
Key Props Reference
ChatView.ScrollView Props
| Prop | Type | Description |
|---|
keyboardShouldPersistTaps | "handled" | Allow taps on buttons while keyboard open |
keyboardDismissMode | "interactive" | Swipe-to-dismiss keyboard |
maintainScrollAtEnd | boolean | Auto-scroll when AI is responding |
maintainVisibleContentPosition | boolean | Keep position when content updates |
recycleItems | true | Enable view recycling for performance |
estimatedItemSize | number | Average item height for layout |
contentInsetAdjustmentBehavior | "never" | Disable automatic safe area handling |
Keyboard Animation Shared Values
| Value | Type | Description |
|---|
progress | 0-1 | Keyboard show/hide animation progress |
height | number | Current keyboard height in pixels |
inset | number | Max of bottom safe area or keyboard height |
offset | number | Cumulative offset for scroll positioning |
scroll | number | Current scroll position relative to inset |
Platform-Specific Files
Use file suffixes for platform-specific implementations:
composer.tsx - Native implementation with SwiftUI
composer.web.tsx - Web implementation with HTML/radix
multi-button.tsx - Native context menu
multi-button.web.tsx - Web dropdown menu
haptic.ts - Native haptics
haptic.web.ts - Web haptics (no-op)
Tips
- Always use
keyboardShouldPersistTaps="handled" to allow button interactions while keyboard is open
- Track
isTouching state to prevent auto-scroll while user is manually scrolling
- Use
useImperativeHandle to extend refs with custom methods like requestScrollToEnd
- Worklet functions must include the
"worklet" directive for keyboard handlers
- Use
useDerivedValue for computed animations that depend on multiple shared values
- Set
estimatedItemSize based on your average message height for optimal recycling