Chat input with emoji
A chat screen with message bubbles, a composer with an emoji button next to Send that opens the emojis in a bottom sheet, emojis inserted at the cursor (not just appended), and a send button. Switch between the iPhone, Pixel and browser above it; the colours come from usePickerTheme(), so it follows dark mode and your theme.
Loadingβ¦
import { useRef, useState } from 'react'; import type { ComponentRef } from 'react'; import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; import { EmojiPickerSheet, usePickerTheme } from '@elik56a/react-native-pickers'; import type { SkinTone } from '@elik56a/react-native-pickers'; type Message = { id: number; text: string; mine: boolean; time: string }; const FIRST: Message[] = [ { id: 1, text: 'Are we still on for tonight? π', mine: false, time: '6:02 PM' }, { id: 2, text: 'Yes! 8 at the usual place', mine: true, time: '6:04 PM' }, { id: 3, text: "Perfect, I'll book a table π", mine: false, time: '6:05 PM' }, ]; const timeNow = () => new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); export default function Chat() { const { colors, radius } = usePickerTheme(); const [messages, setMessages] = useState(FIRST); const [text, setText] = useState(''); const [showEmoji, setShowEmoji] = useState(false); const [tone, setTone] = useState<SkinTone>(0); const [recent, setRecent] = useState<string[]>(['π', 'β€οΈ', 'π']); const selection = useRef({ start: 0, end: 0 }); const list = useRef<ComponentRef<typeof ScrollView>>(null); const canSend = text.trim().length > 0; const insert = (emoji: string) => { const { start, end } = selection.current; setText((t) => t.slice(0, start) + emoji + t.slice(end)); const caret = start + emoji.length; selection.current = { start: caret, end: caret }; }; const send = () => { setMessages((m) => [...m, { id: Date.now(), text: text.trim(), mine: true, time: timeNow() }]); setText(''); selection.current = { start: 0, end: 0 }; }; return ( <View style={[styles.screen, { backgroundColor: colors.background }]}> <View style={[styles.header, { borderColor: colors.border }]}> <View style={[styles.avatar, { backgroundColor: colors.primaryMuted }]}> <Text style={styles.avatarText}>π§βπ³</Text> </View> <View> <Text style={[styles.name, { color: colors.text }]}>Maya</Text> <Text style={[styles.status, { color: colors.textSecondary }]}>online</Text> </View> </View> <ScrollView ref={list} style={styles.messages} contentContainerStyle={styles.messagesContent} onContentSizeChange={() => list.current?.scrollToEnd()} > {messages.map((m) => ( <View key={m.id} style={[ styles.bubble, m.mine ? styles.mine : styles.theirs, { borderRadius: radius.lg, backgroundColor: m.mine ? colors.primary : colors.surface }, ]} > <Text style={[styles.text, { color: m.mine ? colors.onPrimary : colors.text }]}>{m.text}</Text> <Text style={[styles.time, { color: m.mine ? colors.onPrimary : colors.textSecondary }]}>{m.time}</Text> </View> ))} </ScrollView> <View style={[styles.composer, { borderColor: colors.border }]}> <TextInput value={text} onChangeText={setText} onSelectionChange={(e) => (selection.current = e.nativeEvent.selection)} onSubmitEditing={() => canSend && send()} placeholder="Message" placeholderTextColor={colors.textSecondary} style={[styles.input, { backgroundColor: colors.surface, color: colors.text }]} /> <Pressable onPress={() => setShowEmoji(true)} accessibilityRole="button" accessibilityLabel="Emoji" style={styles.iconButton} > <Text style={styles.iconText}>π</Text> </Pressable> <Pressable onPress={send} disabled={!canSend} accessibilityRole="button" accessibilityLabel="Send" style={[styles.send, { backgroundColor: canSend ? colors.primary : colors.surface }]} > <Text style={[styles.sendText, { color: canSend ? colors.onPrimary : colors.textDisabled }]}>β</Text> </Pressable> </View> <EmojiPickerSheet visible={showEmoji} onClose={() => setShowEmoji(false)} onSelect={insert} skinTone={tone} onSkinToneChange={setTone} recent={recent} onRecentChange={setRecent} /> </View> ); } const styles = StyleSheet.create({ screen: { flex: 1 }, header: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 14, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, }, avatar: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, avatarText: { fontSize: 20 }, name: { fontSize: 16, fontWeight: '600' }, status: { fontSize: 12 }, messages: { flex: 1 }, messagesContent: { padding: 12, gap: 6 }, bubble: { maxWidth: '78%', paddingHorizontal: 12, paddingVertical: 8 }, mine: { alignSelf: 'flex-end' }, theirs: { alignSelf: 'flex-start' }, text: { fontSize: 15, lineHeight: 20 }, time: { fontSize: 11, opacity: 0.7, alignSelf: 'flex-end', marginTop: 2 }, composer: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: StyleSheet.hairlineWidth, }, iconButton: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' }, iconText: { fontSize: 22 }, input: { flex: 1, height: 36, borderRadius: 18, paddingHorizontal: 14, fontSize: 15 }, send: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, sendText: { fontSize: 18, fontWeight: '700' }, });
In productionβ
- Persist
recentandtone(AsyncStorage, MMKV) so they survive restarts; see Recently used. - Picking an emoji adds it at the cursor and closes the sheet. For several at once, pass
closeOnSelect={false}; the sheet then closes on a drag down or a tap on the backdrop. - Dismiss the keyboard with
Keyboard.dismiss()when the sheet opens, so the two don't stack. - To show the emojis in the keyboard's place instead, render an inline
EmojiPickerunder the composer and size it to the last keyboard height (Keyboard.addListener('keyboardDidShow', e => β¦)). - On older Android, set
maxEmojiVersionto avoid emojis the device can't render.