Prompt Input
Compound ChatGPT/Claude-style AI composer — auto-growing textarea on top, a toolbar slot below for attach, model, and voice buttons, and a send arrow that becomes a stop button while streaming.
Type to swap the voice icon for the send arrow.
import { Text } from "react-native";
import { Plus, ChevronDown, Mic, AudioLines } from "lucide-react-native";
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
<PromptInput onSend={(text) => sendMessage(text)}>
<PromptInputTextarea />
<PromptInputToolbar>
<PromptInputButton onPress={() => pickImage()} accessibilityLabel="Add attachment">
<Plus size={20} color="#71717a" />
</PromptInputButton>
<PromptInputSpacer />
{/* Model selector — any AniUI overlay works inside the toolbar */}
<DropdownMenu>
<DropdownMenuTrigger>
<PromptInputButton accessibilityLabel="Choose model">
<Text className="text-sm text-muted-foreground">Opus 4.8</Text>
<ChevronDown size={14} color="#71717a" />
</PromptInputButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end">
<DropdownMenuItem onPress={() => setModel("opus-4.8")}>Opus 4.8</DropdownMenuItem>
<DropdownMenuItem onPress={() => setModel("sonnet-4.9")}>Sonnet 4.9</DropdownMenuItem>
<DropdownMenuItem onPress={() => setModel("haiku-4.5")}>Haiku 4.5</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<PromptInputButton onPress={() => startDictation()} accessibilityLabel="Dictate">
<Mic size={20} color="#71717a" />
</PromptInputButton>
{/* Voice mode while empty — the send arrow appears as soon as you type */}
<PromptInputSend
emptyFallback={
<PromptInputButton onPress={() => startVoiceChat()} accessibilityLabel="Voice mode">
<AudioLines size={20} color="#71717a" />
</PromptInputButton>
}
/>
</PromptInputToolbar>
</PromptInput>PromptInputButton's onPress can open a Dropdown Menu, Action Sheet, Bottom Sheet, or navigate to another screen.Installation#
npx @aniui/cli add prompt-inputUsage#
The composer is a compound component: PromptInput owns the draft (and the send/stop logic), PromptInputTextarea grows with the message up to maxHeight (120 by default) then scrolls, and PromptInputToolbar is the bottom action row — leading tools first, then PromptInputSpacer, then trailing tools. Wire onSend and you have a working composer; it clears itself after sending unless you turn clearOnSend off.
import { Plus } from "lucide-react-native";
import {
PromptInput,
PromptInputTextarea,
PromptInputToolbar,
PromptInputSpacer,
PromptInputButton,
PromptInputSend,
} from "@/components/ui/prompt-input";
export function Composer() {
return (
<PromptInput onSend={(text) => sendMessage(text)}>
<PromptInputTextarea />
<PromptInputToolbar>
<PromptInputButton onPress={() => pickImage()} accessibilityLabel="Add attachment">
<Plus size={20} color="#71717a" />
</PromptInputButton>
<PromptInputSpacer />
<PromptInputSend />
</PromptInputToolbar>
</PromptInput>
);
}Claude-style toolbar#
The full composer from the preview above: a + attachment button, a model selector wired to Dropdown Menu, a mic for dictation, and PromptInputSend with an emptyFallback — the voice button shows while the draft is empty, and the send arrow takes its place as soon as you type.
import { Text } from "react-native";
import { Plus, ChevronDown, Mic, AudioLines } from "lucide-react-native";
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
<PromptInput onSend={(text) => sendMessage(text)}>
<PromptInputTextarea />
<PromptInputToolbar>
<PromptInputButton onPress={() => pickImage()} accessibilityLabel="Add attachment">
<Plus size={20} color="#71717a" />
</PromptInputButton>
<PromptInputSpacer />
{/* Model selector — any AniUI overlay works inside the toolbar */}
<DropdownMenu>
<DropdownMenuTrigger>
<PromptInputButton accessibilityLabel="Choose model">
<Text className="text-sm text-muted-foreground">Opus 4.8</Text>
<ChevronDown size={14} color="#71717a" />
</PromptInputButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end">
<DropdownMenuItem onPress={() => setModel("opus-4.8")}>Opus 4.8</DropdownMenuItem>
<DropdownMenuItem onPress={() => setModel("sonnet-4.9")}>Sonnet 4.9</DropdownMenuItem>
<DropdownMenuItem onPress={() => setModel("haiku-4.5")}>Haiku 4.5</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<PromptInputButton onPress={() => startDictation()} accessibilityLabel="Dictate">
<Mic size={20} color="#71717a" />
</PromptInputButton>
{/* Voice mode while empty — the send arrow appears as soon as you type */}
<PromptInputSend
emptyFallback={
<PromptInputButton onPress={() => startVoiceChat()} accessibilityLabel="Voice mode">
<AudioLines size={20} color="#71717a" />
</PromptInputButton>
}
/>
</PromptInputToolbar>
</PromptInput>Attachment action sheet#
The mobile-first pattern: on phones, wire the + button's onPress to an Action Sheet — a thumb-reachable bottom sheet feels native and gives each option a full-width touch target.
Add to your message
Tap + to present the action sheet — the thumb-friendly menu on phones.
// npx @aniui/cli add action-sheet
import { useRef } from "react";
import { Plus } from "lucide-react-native";
import { BottomSheetModal } from "@gorhom/bottom-sheet";
import { ActionSheet } from "@/components/ui/action-sheet";
const sheetRef = useRef<BottomSheetModal>(null);
<>
<PromptInput onSend={(text) => sendMessage(text)}>
<PromptInputTextarea />
<PromptInputToolbar>
{/* Any PromptInputButton onPress can present an overlay */}
<PromptInputButton onPress={() => sheetRef.current?.present()} accessibilityLabel="Add attachment">
<Plus size={20} color="#71717a" />
</PromptInputButton>
<PromptInputSpacer />
<PromptInputSend />
</PromptInputToolbar>
</PromptInput>
<ActionSheet
ref={sheetRef}
title="Add to your message"
actions={[
{ label: "Add photos", onPress: () => pickPhotos() },
{ label: "Take a screenshot", onPress: () => takeScreenshot() },
{ label: "Files", onPress: () => pickFiles() },
]}
onCancel={() => sheetRef.current?.dismiss()}
/>
</>Attachment menu#
Toolbar buttons compose with any AniUI overlay. Here the + button is the trigger for a Dropdown Menu that opens upward, ChatGPT-style. Prefer this anchored menu on larger screens and tablets, where a pointer or a wide layout makes it feel natural — and reach for the action sheet above on phones.
// npx @aniui/cli add dropdown-menu
import { Plus } from "lucide-react-native";
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
<PromptInput onSend={(text) => sendMessage(text)}>
<PromptInputTextarea />
<PromptInputToolbar>
<DropdownMenu>
<DropdownMenuTrigger>
<PromptInputButton accessibilityLabel="Open attachment menu">
<Plus size={20} color="#71717a" />
</PromptInputButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
<DropdownMenuItem onPress={() => pickFiles()}>Add files or photos</DropdownMenuItem>
<DropdownMenuItem onPress={() => takeScreenshot()}>Take a screenshot</DropdownMenuItem>
<DropdownMenuItem onPress={() => setWebSearch(true)}>Web search</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<PromptInputSpacer />
<PromptInputSend />
</PromptInputToolbar>
</PromptInput>Voice recording#
The toolbar is just a slot, so state can swap its contents entirely. While recording, replace the tools with an animated Waveform, an X to cancel, and a check to confirm. The Waveform can also follow real mic metering — pass expo-av amplitudes via its levels prop (see Waveform).
Tap the mic to enter the recording state.
// npx @aniui/cli add waveform
import { Mic, X, Check } from "lucide-react-native";
import { Waveform } from "@/components/ui/waveform";
const [recording, setRecording] = useState(false);
<PromptInput onSend={(text) => sendMessage(text)}>
{!recording && <PromptInputTextarea />}
<PromptInputToolbar>
{recording ? (
<>
<Waveform active size="sm" className="flex-1" />
<PromptInputButton onPress={() => setRecording(false)} accessibilityLabel="Cancel recording">
<X size={20} color="#71717a" />
</PromptInputButton>
<PromptInputButton
onPress={() => { setRecording(false); finishRecording(); }}
accessibilityLabel="Finish recording"
className="bg-primary"
>
<Check size={20} color="#fafafa" />
</PromptInputButton>
</>
) : (
<>
<PromptInputButton onPress={() => setRecording(true)} accessibilityLabel="Record voice message">
<Mic size={20} color="#71717a" />
</PromptInputButton>
<PromptInputSpacer />
<PromptInputSend />
</>
)}
</PromptInputToolbar>
</PromptInput>Streaming#
While the model is responding, set streaming on PromptInput — PromptInputSend turns into a stop button that fires onStop, so the user can cancel generation.
Send a message to enter the streaming state.
// While `streaming` is true, PromptInputSend renders a stop
// square that fires `onStop` instead of `onSend`.
const [streaming, setStreaming] = useState(false);
const controller = useRef<AbortController | null>(null);
<PromptInput
streaming={streaming}
onSend={async (text) => {
setStreaming(true);
controller.current = new AbortController();
await streamCompletion(text, { signal: controller.current.signal });
setStreaming(false);
}}
onStop={() => {
controller.current?.abort();
setStreaming(false);
}}
>
<PromptInputTextarea />
<PromptInputToolbar>
<PromptInputSpacer />
<PromptInputSend />
</PromptInputToolbar>
</PromptInput>Keyboard handling#
On mobile, wrap the screen in Keyboard View so the composer rises above the keyboard when the textarea focuses.
// npx @aniui/cli add keyboard-view
import { KeyboardView } from "@/components/ui/keyboard-view";
<KeyboardView className="flex-1">
<MessageList className="flex-1" />
<PromptInput onSend={(text) => sendMessage(text)} className="mx-4 mb-4">
<PromptInputTextarea />
<PromptInputToolbar>
<PromptInputSpacer />
<PromptInputSend />
</PromptInputToolbar>
</PromptInput>
</KeyboardView>Props#
PromptInput#
onSend(text: string) => void-onStop() => void-Fired by PromptInputSend while streaming — it renders as a stop button.
streamingbooleanfalsevaluestring-Controlled draft — pair with onChangeText.
onChangeText(text: string) => void-clearOnSendbooleantrueAuto-clear after send (uncontrolled mode only).
classNamestring-PromptInputTextarea#
maxHeightnumber120Max height the textarea grows to before scrolling.
placeholderstring"How can I help you today?"classNamestring-PromptInputToolbar#
classNamestring-childrenReact.ReactNode-PromptInputSpacer#
No props — a flex spacer that pushes trailing tools to the right edge of the toolbar.
PromptInputButton#
classNamestring-childrenReact.ReactNode-PromptInputSend#
emptyFallbackReact.ReactNode-Rendered instead of the send arrow while the draft is empty (e.g. a voice button).
classNamestring-PromptInputTextarea also accepts all TextInput props except multiline, value, and onChangeText (owned by the context); buttons accept all Pressable props. For custom toolbar pieces, the usePromptInput() hook exposes { text, setText, send, streaming }. Needs lucide-react-native.
Accessibility#
PromptInputButtonandPromptInputSendsetaccessibilityRole="button"— give each toolbar button a descriptiveaccessibilityLabel.- The send button announces “Stop generating” while streaming and exposes its disabled state via
accessibilityStatewhen the draft is empty. - Toolbar buttons meet the 44dp minimum touch target; keyboard appearance, selection, and cursor colors follow the system color scheme.
Source#
import React, { createContext, useContext, useState } from "react";
import { View, TextInput, Pressable, useColorScheme } from "react-native";
import { ArrowUp, Square } from "lucide-react-native";
import { cn } from "@/lib/utils";
// Compound composer (ChatGPT/Claude-style): textarea on top, toolbar below.
// <PromptInput onSend={…}><PromptInputTextarea /><PromptInputToolbar>…</PromptInputToolbar></PromptInput>
type PromptInputCtx = {
text: string;
setText: (t: string) => void;
send: () => void;
streaming?: boolean;
dark: boolean;
};
const Ctx = createContext<PromptInputCtx | null>(null);
export function usePromptInput(): PromptInputCtx {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("PromptInput.* components must be used inside <PromptInput>");
return ctx;
}
export interface PromptInputProps extends React.ComponentPropsWithoutRef<typeof View> {
className?: string;
value?: string;
onChangeText?: (text: string) => void;
onSend?: (text: string) => void;
/** While `streaming`, PromptInputSend becomes a stop button firing this. */
onStop?: () => void;
streaming?: boolean;
clearOnSend?: boolean;
}
export function PromptInput({
className, value, onChangeText, onSend, onStop, streaming, clearOnSend = true, children, ...props
}: PromptInputProps) {
const [internal, setInternal] = useState("");
const dark = useColorScheme() === "dark";
const text = value ?? internal;
const setText = (t: string) => {
if (value === undefined) setInternal(t);
onChangeText?.(t);
};
const send = () => {
if (streaming) return onStop?.();
const t = text.trim();
if (!t) return;
onSend?.(t);
if (clearOnSend && value === undefined) setInternal("");
};
return (
<Ctx.Provider value={{ text, setText, send, streaming, dark }}>
<View className={cn("rounded-3xl border border-input bg-background px-3 pt-3 pb-2", className)} {...props}>
{children}
</View>
</Ctx.Provider>
);
}
export interface PromptInputTextareaProps
extends Omit<React.ComponentPropsWithoutRef<typeof TextInput>, "multiline" | "value" | "onChangeText"> {
className?: string;
/** Max height before the textarea scrolls (default 120). */
maxHeight?: number;
}
export const PromptInputTextarea = React.forwardRef<
React.ElementRef<typeof TextInput>,
PromptInputTextareaProps
>(function PromptInputTextarea({ className, maxHeight = 120, style, ...props }, ref) {
const { text, setText, dark } = usePromptInput();
const [height, setHeight] = useState(0);
return (
<TextInput
ref={ref}
multiline
value={text}
onChangeText={setText}
onContentSizeChange={(e) => setHeight(e.nativeEvent.contentSize.height)}
// Grows with content up to maxHeight, then scrolls. Font size is inline
// so the cursor stays centered on iOS (same convention as input.tsx).
style={[{ fontSize: 16, maxHeight, height: Math.min(Math.max(24, height), maxHeight) }, style]}
className={cn("p-0 text-foreground placeholder:text-muted-foreground", className)}
placeholder="How can I help you today?"
placeholderTextColor={dark ? "#a1a1aa" : "#71717a"}
keyboardAppearance={dark ? "dark" : "light"}
selectionColor={dark ? "#fafafa" : "#18181b"}
cursorColor={dark ? "#fafafa" : "#18181b"}
{...props}
/>
);
});
export interface PromptInputToolbarProps extends React.ComponentPropsWithoutRef<typeof View> {
className?: string;
}
/** Bottom action row — put leading tools first, then <PromptInputSpacer />, then trailing tools. */
export function PromptInputToolbar({ className, ...props }: PromptInputToolbarProps) {
return <View className={cn("flex-row items-center gap-1 pt-2", className)} {...props} />;
}
export function PromptInputSpacer() {
return <View className="flex-1" />;
}
export interface PromptInputButtonProps extends React.ComponentPropsWithoutRef<typeof Pressable> {
className?: string;
}
/** Ghost icon button for toolbar actions (+, mic, model selector, …). */
export function PromptInputButton({ className, ...props }: PromptInputButtonProps) {
return (
<Pressable
className={cn("h-11 min-w-11 flex-row items-center justify-center gap-1 rounded-full px-2 active:bg-muted", className)}
accessible={true}
accessibilityRole="button"
{...props}
/>
);
}
export interface PromptInputSendProps extends React.ComponentPropsWithoutRef<typeof Pressable> {
className?: string;
/** Shown when there is no text (e.g. a voice/waveform button); default hides into the send arrow. */
emptyFallback?: React.ReactNode;
}
export function PromptInputSend({ className, emptyFallback, ...props }: PromptInputSendProps) {
const { text, send, streaming, dark } = usePromptInput();
const canSend = text.trim().length > 0;
const fg = dark ? "#18181b" : "#fafafa";
if (!canSend && !streaming && emptyFallback) return <>{emptyFallback}</>;
return (
<Pressable
onPress={send}
disabled={!canSend && !streaming}
className={cn("h-11 w-11 items-center justify-center rounded-full bg-primary", !canSend && !streaming && "opacity-40", className)}
accessible={true}
accessibilityRole="button"
accessibilityLabel={streaming ? "Stop generating" : "Send message"}
accessibilityState={{ disabled: !canSend && !streaming }}
{...props}
>
{streaming ? <Square size={14} color={fg} fill={fg} /> : <ArrowUp size={20} color={fg} strokeWidth={2.5} />}
</Pressable>
);
}