npx skills add smithery/evanbacon --skill expo-chat-ui
evanbacon/evanbacon.dev · Archived
expo-chat-ui
Build a production-quality chat UI with keyboard handling, recycling lists, gestures, and platform-specific implementations
Installation
npx skills add evanbacon/evanbacon.dev --skill expo-chat-ui
Stronger alternatives
This repository is archived — consider an actively maintained alternative.
Set up and test universal links for iOS using setup-safari
3 installsSimilar popular skills
Related neighbors and high-traction skills in the same topics — useful to compare before installing.
Guidance for distinctive, intentional visual design when building new UI or reshaping an existi…
866.4K installsBrowser automation CLI for AI agents. Use when the user needs to interact with websites, includ…
810.4K installsReview UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "chec…
617.3K installsBuild, deploy, evaluate, optimize, fine-tune, and manage Microsoft Foundry agents, models, and …
576.5K installsDebug Azure production issues on Azure using AppLens, Azure Monitor, resource health, and safe …
568.9K installsAlso in this package
Other skills from evanbacon/evanbacon.dev.
npx skills add evanbacon/evanbacon.dev
More details
Agent compatibility
Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.
Also listed on
Alternate registries and mirrors of this skill.
Repository health
main
Package contents
Files included with this skill beyond the listing page.
-
skill md
SKILL.md21,162 B -
docs
SUMMARY.md143 B
History
- First seen on skills.sh
- First recorded snapshot · 12 installs
SKILL.md
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>;
}
// Sub-components attached to ChatView
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;
const rect = ref.current.getBoundingClientRect();
uiHeight.value = rect.height;
}, [ref, uiHeight]);
const onLayout = ({ nativeEvent: { layout } }) => {
uiHeight.set(layout.height);
};
return <View {...props} ref={ref} onLayout={onLayout} />;
};
MeasureNode.useHeightSharedValue = function useFrame() {
const context = React.use(MeasureNodeContext);
return context.uiHeight;
};
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";
// Handle interactive gesture detection
if (progress.value !== 1 && progress.value !== 0 && e.height !== 0) {
shouldUseOnMoveHandler.value = true;
return;
}
progress.value = e.progress;
height.value = e.height;
inset.value = Math.max(bottom, height.value);
offset.value = Math.max(Math.max(height.value, bottom) + scroll.value, 0);
},
onInteractive: (e) => {
"worklet";
progress.value = e.progress;
height.value = e.height;
},
onMove: (e) => {
"worklet";
if (shouldUseOnMoveHandler.value) {
progress.value = e.progress;
height.value = e.height;
}
},
onEnd: (e) => {
"worklet";
height.value = e.height;
progress.value = e.progress;
shouldUseOnMoveHandler.value = false;
},
});
const isCloseToEnd = useSharedValue(true);
const onScroll = useAnimatedScrollHandler(
{
onScroll: (e) => {
scroll.value = e.contentOffset.y - inset.value;
},
},
[]
);
return { 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, props.style]}
pointerEvents="box-none"
/>
);
}
ChatView.Toolbar = function ChatViewToolbar({
children,
...props
}: React.ComponentProps<typeof Animated.View>) {
return (
<ChatViewToolbarPadding {...props}>
<MeasureNode.Trigger>{children}</MeasureNode.Trigger>
</ChatViewToolbarPadding>
);
};
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();
// Custom ref with requestScrollToEnd
useImperativeHandle(
ref,
() => ({
...ref?.current,
requestScrollToEnd: (animated: boolean) => {
if (isTouching.value) return; // Skip if user is touching
ref.current?.scrollToEnd({ animated });
},
}),
[ref]
);
// Scroll indicator insets
const scrollIndicatorInsets = useDerivedValue(
() => ({
top: 56, // composer header height
bottom:
Math.max(0, Math.abs(height.value) - bottom) +
toolbarHeightSharedValue.value,
}),
[toolbarHeightSharedValue, bottom, height]
);
// Animated content insets
const animatedProps = useAnimatedProps(() => ({
contentInset: {
bottom:
Math.abs(controllerProps.inset.value) + toolbarHeightSharedValue.value,
},
contentOffset: {
x: 0,
y: controllerProps.offset.value,
},
}));
return (
<AnimatedLegendListComponent
{...props}
ref={ref}
animatedProps={animatedProps}
scrollIndicatorInsets={scrollIndicatorInsets}
scrollEventThrottle={16}
onScrollBeginDrag={(e) => {
isTouching.value = true;
props.onScrollBeginDrag?.(e);
}}
onScrollEndDrag={(e) => {
isTouching.value = false;
props.onScrollEndDrag?.(e);
}}
onScroll={controllerProps.onScroll}
keyboardDismissMode="interactive"
contentInsetAdjustmentBehavior="never"
onContentSizeChange={(width: number, height: number) => {
scrollViewContentHeight.value = height;
props.onContentSizeChange?.(width, height);
}}
onLayout={(e) => {
scrollViewHeight.value = e.nativeEvent.layout.height;
props.onLayout?.(e);
}}
/>
);
}
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" }),
]}
>
<SUI.HStack spacing={6} alignment="bottom">
<MultiButton disabled={!authenticated} />
<SUI.ZStack
modifiers={[
glassEffectId("composer", "container"),
glassEffect({
glass: { variant: "regular", interactive: authenticated },
shape: "roundedRectangle",
cornerRadius: 24,
}),
]}
alignment="bottom"
>
<SUI.TextField
multiline
placeholder="Generate code..."
allowNewlines={false}
numberOfLines={6}
ref={textInputRef}
onChangeFocus={(focus) => {
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"),
]}
/>
<SendButton canSend={canSend} onSend={onSend} />
</SUI.ZStack>
</SUI.HStack>
</SUI.GlassEffectContainer>
</Host>
</ChatView.Toolbar>
);
}
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);
});
// Stop button when AI is thinking
if (isThinking) {
return (
<SUI.Button variant="borderedProminent" onPress={stop}>
<SUI.Image systemName="stop.fill" />
</SUI.Button>
);
}
// Mic button when no text / recording
if (isRecording || !canSend) {
return (
<SUI.Button
variant="borderedProminent"
color={isRecording ? AC.systemRed : AC.label}
onPress={async () => {
if (isRecording) {
ExpoSpeechRecognitionModule.stop();
return;
}
haptics.impact("medium");
const permission =
await ExpoSpeechRecognitionModule.requestSpeechRecognizerPermissionsAsync();
if (permission.status === "denied") {
alert("Permission denied. Enable in App Settings.");
return;
}
ExpoSpeechRecognitionModule.start({
interimResults: true,
continuous: false,
addsPunctuation: true,
});
}}
>
<SUI.Image systemName={isRecording ? "stop.fill" : "mic.fill"} />
</SUI.Button>
);
}
// Send button
return (
<SUI.Button
variant="borderedProminent"
onPress={() => onSend(textInputText?.current)}
>
<SUI.Image systemName="arrow.up" />
</SUI.Button>
);
}
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); // Keep only last 3
});
};
const removeImage = (index: number) => {
setSelectedImages((current) => current.filter((_, i) => i !== index));
};
return (
<SelectedMediaContext
value={{ selectedImages, addImages, removeImage, clearImages, setImages }}
>
{children}
</SelectedMediaContext>
);
}
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),
error: () =>
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error),
};
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,
images: selectedImages.length ? selectedImages : undefined,
});
};
return (
<View className="flex-1">
<ComposerHeader />
<ChatView>
<MaskedView
style={{ flex: 1 }}
maskElement={
<View
style={{
flex: 1,
experimental_backgroundImage:
"linear-gradient(180deg,rgba(0,0,0,0) 0%, rgba(0,0,0,1) 80px)",
}}
/>
}
>
<ChatView.ScrollView
ref={scrollViewRef}
keyboardShouldPersistTaps="handled"
maintainScrollAtEnd={isThinking}
maintainVisibleContentPosition={isThinking}
estimatedItemSize={32}
recycleItems={true}
data={messages}
renderItem={({ item }) => <Message message={item} />}
/>
</MaskedView>
<ComposerTextInput
scrollViewRef={scrollViewRef}
textInputRef={textInputRef}
onSend={sendMessageWrapper}
/>
</ChatView>
</View>
);
}
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 SwiftUIcomposer.web.tsx- Web implementation with HTML/radixmulti-button.tsx- Native context menumulti-button.web.tsx- Web dropdown menuhaptic.ts- Native hapticshaptic.web.ts- Web haptics (no-op)
Tips
- Always use
keyboardShouldPersistTaps="handled"to allow button interactions while keyboard is open - Track
isTouchingstate to prevent auto-scroll while user is manually scrolling - Use
useImperativeHandleto extend refs with custom methods likerequestScrollToEnd - Worklet functions must include the
"worklet"directive for keyboard handlers - Use
useDerivedValuefor computed animations that depend on multiple shared values - Set
estimatedItemSizebased on your average message height for optimal recycling