Skip to content
pixelfor Expo
Ctrl K

Input

Prompt Input

An expanding composer with model choices and submission states

Try it on your phone

Prompt Input

Scan to open Prompt Input in Expo Go

Install Expo Go first, then scan this QR to run the native component.

GET EXPO GO ONGoogle Play

This demo uses Expo SDK 54. If your installed Expo Go supports a different SDK, get the compatible build. iOS needs a build supporting this SDK.

Expo code

Original web component

Be UI

Our Expo component

React Native Web

Component 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.

Back to Be UI