Input
Prompt Input
An expanding composer with model choices and submission states
Expo code
Original web component
Be UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
prompt-input.tsx
import React, { useRef, useState } from "react"
import {
Platform,
Pressable,
Text,
TextInput,
View,
useWindowDimensions,
} from "react-native"
import Animated, {
FadeIn,
FadeOut,
LinearTransition,
ReduceMotion,
ZoomIn,
ZoomOut,
} from "react-native-reanimated"
import Svg, { Path } from "react-native-svg"
export interface PromptInputModel {
value: string
label: string
icon?: React.ReactNode
disabled?: boolean
}
export interface PromptInputAction {
value: string
label: string
description?: string
icon?: React.ReactNode
disabled?: boolean
}
export interface PromptInputProps {
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
models?: PromptInputModel[]
model?: string
defaultModel?: string
onModelChange?: (value: string) => void
actions?: PromptInputAction[]
onAction?: (action: string) => void
onSubmit?: (value: string, model?: string) => void | Promise<void>
onStop?: () => void
loading?: boolean
disabled?: boolean
placeholder?: string
minRows?: number
maxRows?: number
leadingAction?: React.ReactNode
backgroundColor?: string
textColor?: string
mutedColor?: string
accentColor?: string
borderColor?: string
radius?: number
testID?: string
}
export function PromptInput({
value,
defaultValue = "",
onValueChange,
models = [],
model,
defaultModel,
onModelChange,
actions = [],
onAction,
onSubmit,
onStop,
loading = false,
disabled = false,
placeholder = "Ask the agent to do something…",
minRows = 2,
maxRows = 8,
leadingAction,
backgroundColor = "#151515",
textColor = "#f1f1f1",
mutedColor = "#868686",
accentColor = "#0285f7",
borderColor = "rgba(255,255,255,0.04)",
radius = 16,
testID,
}: PromptInputProps) {
const [local, setLocal] = useState(defaultValue),
[localModel, setLocalModel] = useState(defaultModel ?? models[0]?.value)
const [menu, setMenu] = useState<"actions" | "models" | null>(null),
[height, setHeight] = useState(minRows * 24),
[focused, setFocused] = useState(false),
[modelsAbove, setModelsAbove] = useState(false)
const modelTrigger = useRef<View>(null)
const { height: viewportHeight } = useWindowDimensions()
const text = value ?? local,
chosen = model ?? localModel,
busy = loading
const setText = (next: string) => {
if (value === undefined) setLocal(next)
onValueChange?.(next)
}
function submit() {
if (!text.trim() || busy || disabled) return
onSubmit?.(text.trim(), chosen)
if (value === undefined) setLocal("")
}
const entries: PromptInputAction[] = menu === "actions" ? actions : models
return (
<View
testID={testID}
style={{
width: "100%",
}}
>
{menu && (
<Animated.View
entering={ZoomIn.springify().stiffness(420).damping(40).mass(0.5).reduceMotion(ReduceMotion.System)}
exiting={ZoomOut.duration(100).reduceMotion(ReduceMotion.System)}
style={{
position: "absolute",
bottom: menu === "actions" || modelsAbove ? 48 : -Math.min(entries.length, 5) * 36 - 16,
left: menu === "actions" ? 8 : actions.length ? 44 : 8,
width: menu === "actions" ? 224 : 208,
padding: menu === "actions" ? 6 : 4,
zIndex: 2,
backgroundColor,
borderRadius: 12,
borderWidth: 1,
borderColor,
transformOrigin: menu === "actions" || modelsAbove ? "left bottom" : "left top",
}}
>
{entries.map((entry) => (
<Pressable
key={entry.value}
disabled={entry.disabled}
accessibilityRole="button"
onPress={() => {
if (menu === "models") {
if (model === undefined) setLocalModel(entry.value)
onModelChange?.(entry.value)
} else onAction?.(entry.value)
setMenu(null)
}}
style={({ pressed }) => ({
paddingHorizontal: menu === "actions" ? 10 : 8,
paddingVertical: 8,
borderRadius: 8,
flexDirection: "row",
alignItems: menu === "actions" ? "flex-start" : "center",
gap: 10,
backgroundColor: pressed ? "#ffffff0a" : "transparent",
opacity: entry.disabled ? 0.4 : 1,
})}
>
{entry.icon && <View style={{ width: 20, height: 20, marginTop: menu === "actions" ? 2 : 0, alignItems: "center", justifyContent: "center" }}>{entry.icon}</View>}
<View style={{ flex: 1 }}>
<Text style={{ color: textColor, fontSize: 14, lineHeight: 20, fontFamily: Platform.OS === "web" ? '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif' : undefined }}>
{entry.label}
</Text>
{entry.description && (
<Text style={{ color: mutedColor, fontSize: 12, lineHeight: 16, marginTop: 2, fontFamily: Platform.OS === "web" ? '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif' : undefined }}>
{entry.description}
</Text>
)}
</View>
{menu === "models" && entry.value === chosen && <Svg width={14} height={14} viewBox="0 0 24 24"><Path d="m20 6-11 11-5-5" fill="none" stroke={textColor} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /></Svg>}
</Pressable>
))}
</Animated.View>
)}
<Animated.View
layout={LinearTransition.springify()
.damping(28)
.stiffness(300)
.reduceMotion(ReduceMotion.System)}
style={{ padding: 8, backgroundColor, borderRadius: radius, borderWidth: 1, borderColor: focused ? "rgba(255,255,255,0.25)" : borderColor, gap: 4, opacity: disabled ? 0.6 : 1 }}
>
<TextInput
accessibilityLabel="Prompt"
multiline
editable={!disabled}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
value={text}
onChangeText={setText}
placeholder={placeholder}
placeholderTextColor={mutedColor === "#868686" ? "rgba(134,134,134,0.55)" : mutedColor}
onKeyPress={event => {
const modifiers = event.nativeEvent as typeof event.nativeEvent & { shiftKey?: boolean; isComposing?: boolean }
if (Platform.OS === "web" && modifiers.key === "Enter" && !modifiers.shiftKey && !modifiers.isComposing) { event.preventDefault(); submit() }
}}
onContentSizeChange={(e) =>
setHeight(
Math.max(
minRows * 24,
Math.min(maxRows * 24, e.nativeEvent.contentSize.height)
)
)
}
style={{
height,
fontSize: 14,
fontFamily: Platform.OS === "web" ? '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif' : undefined,
lineHeight: 24,
color: textColor,
textAlignVertical: "top",
padding: 0,
paddingHorizontal: 8,
paddingTop: 6,
}}
/>
<View style={{ flexDirection: "row", alignItems: "center", gap: 4, minHeight: 32 }}>
{actions.length > 0 && (
<Pressable
accessibilityLabel="Add attachment or action"
accessibilityRole="button"
accessibilityState={{ expanded: menu === "actions" }}
aria-expanded={menu === "actions"}
disabled={disabled || busy}
onPress={() => setMenu(menu === "actions" ? null : "actions")}
hitSlop={8}
style={{ width: 32, height: 32, alignItems: "center", justifyContent: "center" }}
>
<Svg width={16} height={16} viewBox="0 0 24 24" style={{ transform: [{ rotate: menu === "actions" ? "45deg" : "0deg" }] }}><Path d="M12 5v14M5 12h14" fill="none" stroke={mutedColor} strokeWidth={2} strokeLinecap="round" /></Svg>
</Pressable>
)}
{leadingAction}
{models.length > 0 && (
<Pressable
ref={modelTrigger}
accessibilityRole="button"
accessibilityLabel="Choose model"
accessibilityState={{ expanded: menu === "models" }}
aria-expanded={menu === "models"}
disabled={disabled || busy}
onPress={() => {
if (menu === "models") { setMenu(null); return }
modelTrigger.current?.measureInWindow((_, y, __, h) => {
const below = viewportHeight - y - h
setModelsAbove(below < Math.min(models.length, 5) * 36 + 24 && y > below)
setMenu("models")
})
}}
style={{
flexDirection: "row",
gap: 6,
alignItems: "center",
paddingVertical: 6,
paddingHorizontal: 8,
}}
>
{models.find(m => m.value === chosen)?.icon && <View style={{ width: 16, height: 16, alignItems: "center", justifyContent: "center" }}>{models.find(m => m.value === chosen)?.icon}</View>}
<Text style={{ color: mutedColor, fontSize: 12, fontFamily: Platform.OS === "web" ? '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif' : undefined }}>
{models.find((m) => m.value === chosen)?.label ?? "Model"}
</Text>
<Svg width={14} height={14} viewBox="0 0 24 24" style={{ transform: [{ rotate: menu === "models" ? "180deg" : "0deg" }] }}><Path d="m6 9 6 6 6-6" fill="none" stroke={mutedColor} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /></Svg>
</Pressable>
)}
<View style={{ flex: 1 }} />
<Pressable
disabled={!busy && (!text.trim() || disabled)}
accessibilityRole="button"
accessibilityLabel={busy ? "Stop response" : "Send prompt"}
onPress={busy ? onStop : () => void submit()}
style={({ pressed }) => ({
width: 32,
height: 32,
borderRadius: 16,
alignItems: "center",
justifyContent: "center",
backgroundColor: accentColor,
opacity: pressed ? 0.7 : !busy && !text.trim() ? 0.4 : 1,
})}
>
<Svg width={16} height={16} viewBox="0 0 24 24">
<Path
d={busy ? "M7 7h10v10H7z" : "M12 19V5m-6 6 6-6 6 6"}
stroke="#fff"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
fill={busy ? "#fff" : "none"}
/>
</Svg>
</Pressable>
</View>
</Animated.View>
</View>
)
}
Give the gestures and motion a run on a real Android phone.