AniUI

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.

Web preview — components render natively on iOS & Android
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>
Every toolbar control is an optional slot. Compose only what you need — a textarea and a send arrow is already a complete composer. And any PromptInputButton's onPress can open a Dropdown Menu, Action Sheet, Bottom Sheet, or navigate to another screen.

Installation#

npx @aniui/cli add prompt-input

Usage#

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.

app/chat.tsx
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.

Claude-style composer
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.

Tap + to present the action sheet — the thumb-friendly menu on phones.

Web preview — components render natively on iOS & Android
// 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.

Attachment menu
// 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.

Web preview — components render natively on iOS & Android
// 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 PromptInputPromptInputSend turns into a stop button that fires onStop, so the user can cancel generation.

Send a message to enter the streaming state.

Web preview — components render natively on iOS & Android
// 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.

app/chat.tsx
// 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#

PropTypeDefault
onSend
(text: string) => void
-
onStop
() => void
-

Fired by PromptInputSend while streaming — it renders as a stop button.

streaming
boolean
false
value
string
-

Controlled draft — pair with onChangeText.

onChangeText
(text: string) => void
-
clearOnSend
boolean
true

Auto-clear after send (uncontrolled mode only).

className
string
-

PromptInputTextarea#

PropTypeDefault
maxHeight
number
120

Max height the textarea grows to before scrolling.

placeholder
string
"How can I help you today?"
className
string
-

PromptInputToolbar#

PropTypeDefault
className
string
-
children
React.ReactNode
-

PromptInputSpacer#

No props — a flex spacer that pushes trailing tools to the right edge of the toolbar.

PromptInputButton#

PropTypeDefault
className
string
-
children
React.ReactNode
-

PromptInputSend#

PropTypeDefault
emptyFallback
React.ReactNode
-

Rendered instead of the send arrow while the draft is empty (e.g. a voice button).

className
string
-

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#

  • PromptInputButton and PromptInputSend set accessibilityRole="button" — give each toolbar button a descriptive accessibilityLabel.
  • The send button announces “Stop generating” while streaming and exposes its disabled state via accessibilityState when the draft is empty.
  • Toolbar buttons meet the 44dp minimum touch target; keyboard appearance, selection, and cursor colors follow the system color scheme.

Source#

components/ui/prompt-input.tsx
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>
  );
}