AI-style prompt composer with auto-resizing textarea and a submit button that swaps between send and stop states. Haptic feedback on submit, reduced-motion aware.
components/ui/prompt-input/prompt-input.tsx@lumioui/core@latest@lumioui/ui@latestreact-native-reanimated@^4.0.0expo-haptics@*Live preview — the real component, driven by the props below.
import { PromptInput } from "@/components/ui/prompt-input/prompt-input";
export function Example() {
return (
<PromptInput />
);
} import * as React from "react";
import { forwardRef, useEffect, useRef, useState } from "react";
import { Platform, Text, View, TextInput, type ViewProps, } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withSequence,
withTiming,
interpolate,
} from "react-native-reanimated";
import { cn, useHaptic, useReducedMotion, spring, timing, AnimatedTextInput, AnimatedPressable, usePressAnimation } from "@lumioui/core";
import { Glass, Icon, type IconName } from "@lumioui/ui";
/**
* PromptInput — AI-style prompt composer with auto-resizing textarea, a
* submit button that swaps between send/stop states, and optional action menu.
* <PromptInput
* value={text}
* onValueChange={setText}
* onSubmit={(v) => send(v)}
* loading={isSending}
* onStop={stop}
* />
*/
export interface PromptAction {
value: string;
label: string;
description?: string;
icon?: IconName;
disabled?: boolean;
onPress?: () => void;
}
export interface PromptInputProps extends Omit<ViewProps, "children"> {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
onSubmit?: (value: string) => void | Promise<void>;
loading?: boolean;
onStop?: () => void;
/** Trailing action chips rendered left of the send button. */
actions?: PromptAction[];
/** Fired after an action's own `onPress` — handy for analytics/navigation. */
onAction?: (action: PromptAction) => void;
/** Placeholder text. */
placeholder?: string;
/** Min textarea rows. Default 1. */
minRows?: number;
/** Max textarea rows before scrolling. Default 6. */
maxRows?: number;
/** Accessible label. Default "Prompt". */
accessibilityLabel?: string;
disabled?: boolean;
className?: string;
}
const LINE_HEIGHT = 24;
/** Trailing action chip — owns a usePressAnimation so every action squashes
* on touch like the send button does. */
function PromptActionButton({
action,
disabled,
onPress,
}: {
action: PromptAction;
disabled?: boolean;
onPress: () => void;
}) {
const press = usePressAnimation({ pressedScale: 0.9 });
const isDisabled = disabled || action.disabled;
return (
<AnimatedPressable
onPress={onPress}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
disabled={isDisabled}
style={press.animatedStyle}
hitSlop={4}
className={cn(
"h-8 min-w-8 flex-row items-center justify-center rounded-full px-2",
isDisabled && "opacity-40"
)}
accessibilityRole="button"
accessibilityLabel={action.label}
accessibilityHint={action.description}
accessibilityState={{ disabled: isDisabled }}
>
{action.icon ? (
<Icon name={action.icon} size="sm" color="mutedForeground" />
) : (
<Text className="text-xs font-medium text-muted-foreground">{action.label}</Text>
)}
</AnimatedPressable>
);
}
export const PromptInput = forwardRef<React.ElementRef<typeof View>, PromptInputProps>(
function PromptInput(
{
value,
defaultValue = "",
onValueChange,
onSubmit,
loading = false,
onStop,
actions,
onAction,
placeholder = "Ask the agent to do something…",
minRows = 1,
maxRows = 6,
accessibilityLabel = "Prompt",
disabled,
className,
...props
},
ref
) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const inputRef = useRef<TextInput>(null);
const [internalValue, setInternalValue] = useState(defaultValue);
// Composer height springs between row counts — the field breathes rather
// than snapping as lines are added.
const inputH = useSharedValue(minRows * LINE_HEIGHT);
const currentValue = value ?? internalValue;
const canSubmit = Boolean(currentValue.trim()) && !disabled && !loading;
const sendDisabled = loading ? !onStop : !canSubmit;
const setValue = (next: string) => {
if (value === undefined) setInternalValue(next);
onValueChange?.(next);
};
const submit = () => {
const prompt = currentValue.trim();
if (!prompt || disabled || loading) return;
haptics.light();
onSubmit?.(prompt);
// Clear via setValue so controlled parents see onValueChange("") —
// otherwise the field snaps back to the stale prop on next render.
setValue("");
};
// Submit button swap animation (send <-> stop)
const swapProgress = useSharedValue(loading ? 1 : 0);
/** Button-level pulse on the swap — a quick shrink + bouncy return makes
* the mode change read as a physical morph, not just an icon crossfade. */
const morphPulse = useSharedValue(1);
const wasLoading = useRef(loading);
useEffect(() => {
if (wasLoading.current !== loading) {
// Send↔stop swap — a selection tick marks the mode morph, on top
// of the light press haptic that fired on submit.
haptics.selection();
wasLoading.current = loading;
}
if (reduce) {
swapProgress.value = loading ? 1 : 0;
morphPulse.value = 1;
return;
}
swapProgress.value = withSpring(loading ? 1 : 0, spring.press);
morphPulse.value = withSequence(
withTiming(0.82, timing.fast),
withSpring(1, spring.bouncy)
);
}, [loading, reduce, swapProgress, morphPulse, haptics]);
const sendStyle = useAnimatedStyle(() => ({
opacity: 1 - swapProgress.value,
transform: [
// Send arrow lifts and spins out as the stop glyph takes over.
{ scale: interpolate(swapProgress.value, [0, 1], [1, 0.5]) },
{ translateY: interpolate(swapProgress.value, [0, 1], [0, -5]) },
{ rotate: `${interpolate(swapProgress.value, [0, 1], [0, -45])}deg` },
],
}));
const stopStyle = useAnimatedStyle(() => ({
opacity: swapProgress.value,
transform: [
{ scale: interpolate(swapProgress.value, [0, 1], [0.5, 1]) },
{ translateY: interpolate(swapProgress.value, [0, 1], [5, 0]) },
],
}));
const pulseStyle = useAnimatedStyle(() => ({
transform: [{ scale: morphPulse.value }],
}));
const heightStyle = useAnimatedStyle(() => ({ height: inputH.value }));
// Send buttons dip harder than surfaces — the 32px target needs a
// quick, deeper squash to read as a press.
const press = usePressAnimation({ pressedScale: 0.9 });
const handleSendPressIn = () => {
press.onPressIn?.();
};
const handleSendPressOut = () => {
press.onPressOut?.();
};
return (
<View
ref={ref}
className={cn(disabled && "opacity-60", className)}
{...props}
>
{/* Composer floats on liquid glass — real iOS 26 material when
installed, frosted elsewhere. */}
<Glass effect="regular" className="rounded-2xl p-2">
<AnimatedTextInput
ref={inputRef}
value={currentValue}
onChangeText={setValue}
placeholder={placeholder}
multiline
// Native: return key sends instead of inserting a newline (the
// submit event fires without blurring the field).
submitBehavior="submit"
onSubmitEditing={submit}
onKeyPress={(e) => {
if (Platform.OS !== "web") return;
// Web: RNW ignores submitBehavior — Enter→submit only fires when
// blurOnSubmit is set, which would also drop focus. Handle it
// here: Enter sends, Shift+Enter stays a newline, and IME
// composition (isComposing) never submits mid-conversion.
const ne = e.nativeEvent as {
key: string;
shiftKey?: boolean;
isComposing?: boolean;
preventDefault?: () => void;
};
if (ne.key === "Enter" && !ne.shiftKey && !ne.isComposing && canSubmit) {
ne.preventDefault?.();
submit();
}
}}
editable={!disabled}
accessibilityLabel={accessibilityLabel}
onContentSizeChange={(e) => {
const h = e.nativeEvent.contentSize.height;
const clamped = Math.min(Math.max(h, minRows * LINE_HEIGHT), maxRows * LINE_HEIGHT);
inputH.value = reduce ? clamped : withSpring(clamped, spring.layout);
}}
style={[{ paddingHorizontal: 8, paddingTop: 6, paddingBottom: 6 }, heightStyle]}
className="text-sm leading-6 text-foreground placeholder:text-muted-foreground"
/>
<View className="mt-1 flex-row items-center justify-end gap-1">
{actions?.map((a) => (
<PromptActionButton
key={a.value}
action={a}
disabled={disabled}
onPress={() => {
haptics.light();
a.onPress?.();
onAction?.(a);
}}
/>
))}
<AnimatedPressable
disabled={sendDisabled}
onPress={
loading
? () => {
haptics.light();
onStop?.();
}
: submit
}
onPressIn={handleSendPressIn}
onPressOut={handleSendPressOut}
style={press.animatedStyle}
className={cn("h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-primary", sendDisabled && "opacity-40")}
accessibilityRole="button"
accessibilityLabel={loading ? "Stop generating" : "Send prompt"}
accessibilityHint={loading ? "Stops the current response" : "Submits the prompt"}
accessibilityState={{ disabled: sendDisabled, busy: loading }}
hitSlop={8}
>
<Animated.View style={pulseStyle} className="h-full w-full items-center justify-center">
<Animated.View style={sendStyle} className="absolute">
<Icon name="arrow-up" size="sm" color="primaryForeground" />
</Animated.View>
<Animated.View style={stopStyle} className="absolute">
<Icon name="pause" size="sm" color="primaryForeground" filled />
</Animated.View>
</Animated.View>
</AnimatedPressable>
</View>
</Glass>
</View>
);
}
);
PromptInput.displayName = "PromptInput";
| value? | string | ||
| defaultValue? | string | ||
| onValueChange? | (value: string) => void | ||
| onSubmit? | (value: string) => void | Promise<void> | ||
| loading? | boolean | ||
| onStop? | () => void | ||
| actions? | PromptAction[] | Trailing action chips rendered left of the send button. | |
| onAction? | (action: PromptAction) => void | Fired after an action's own `onPress` — handy for analytics/navigation. | |
| placeholder? | string | Placeholder text. | |
| minRows? | number | Min textarea rows. Default 1. | |
| maxRows? | number | Max textarea rows before scrolling. Default 6. | |
| accessibilityLabel? | string | Accessible label. Default "Prompt". | |
| disabled? | boolean | ||
| className? | string |
AI-style prompt composer with auto-resizing textarea and a submit button that swaps between send and stop states. Haptic feedback on submit, reduced-motion aware.
import { PromptInput } from "@/components/ui/prompt-input/prompt-input";
export function Example() {
return (
<PromptInput />
);
} import * as React from "react";
import { forwardRef, useEffect, useRef, useState } from "react";
import { Platform, Text, View, TextInput, type ViewProps, } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withSequence,
withTiming,
interpolate,
} from "react-native-reanimated";
import { cn, useHaptic, useReducedMotion, spring, timing, AnimatedTextInput, AnimatedPressable, usePressAnimation } from "@lumioui/core";
import { Glass, Icon, type IconName } from "@lumioui/ui";
/**
* PromptInput — AI-style prompt composer with auto-resizing textarea, a
* submit button that swaps between send/stop states, and optional action menu.
* <PromptInput
* value={text}
* onValueChange={setText}
* onSubmit={(v) => send(v)}
* loading={isSending}
* onStop={stop}
* />
*/
export interface PromptAction {
value: string;
label: string;
description?: string;
icon?: IconName;
disabled?: boolean;
onPress?: () => void;
}
export interface PromptInputProps extends Omit<ViewProps, "children"> {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
onSubmit?: (value: string) => void | Promise<void>;
loading?: boolean;
onStop?: () => void;
/** Trailing action chips rendered left of the send button. */
actions?: PromptAction[];
/** Fired after an action's own `onPress` — handy for analytics/navigation. */
onAction?: (action: PromptAction) => void;
/** Placeholder text. */
placeholder?: string;
/** Min textarea rows. Default 1. */
minRows?: number;
/** Max textarea rows before scrolling. Default 6. */
maxRows?: number;
/** Accessible label. Default "Prompt". */
accessibilityLabel?: string;
disabled?: boolean;
className?: string;
}
const LINE_HEIGHT = 24;
/** Trailing action chip — owns a usePressAnimation so every action squashes
* on touch like the send button does. */
function PromptActionButton({
action,
disabled,
onPress,
}: {
action: PromptAction;
disabled?: boolean;
onPress: () => void;
}) {
const press = usePressAnimation({ pressedScale: 0.9 });
const isDisabled = disabled || action.disabled;
return (
<AnimatedPressable
onPress={onPress}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
disabled={isDisabled}
style={press.animatedStyle}
hitSlop={4}
className={cn(
"h-8 min-w-8 flex-row items-center justify-center rounded-full px-2",
isDisabled && "opacity-40"
)}
accessibilityRole="button"
accessibilityLabel={action.label}
accessibilityHint={action.description}
accessibilityState={{ disabled: isDisabled }}
>
{action.icon ? (
<Icon name={action.icon} size="sm" color="mutedForeground" />
) : (
<Text className="text-xs font-medium text-muted-foreground">{action.label}</Text>
)}
</AnimatedPressable>
);
}
export const PromptInput = forwardRef<React.ElementRef<typeof View>, PromptInputProps>(
function PromptInput(
{
value,
defaultValue = "",
onValueChange,
onSubmit,
loading = false,
onStop,
actions,
onAction,
placeholder = "Ask the agent to do something…",
minRows = 1,
maxRows = 6,
accessibilityLabel = "Prompt",
disabled,
className,
...props
},
ref
) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const inputRef = useRef<TextInput>(null);
const [internalValue, setInternalValue] = useState(defaultValue);
// Composer height springs between row counts — the field breathes rather
// than snapping as lines are added.
const inputH = useSharedValue(minRows * LINE_HEIGHT);
const currentValue = value ?? internalValue;
const canSubmit = Boolean(currentValue.trim()) && !disabled && !loading;
const sendDisabled = loading ? !onStop : !canSubmit;
const setValue = (next: string) => {
if (value === undefined) setInternalValue(next);
onValueChange?.(next);
};
const submit = () => {
const prompt = currentValue.trim();
if (!prompt || disabled || loading) return;
haptics.light();
onSubmit?.(prompt);
// Clear via setValue so controlled parents see onValueChange("") —
// otherwise the field snaps back to the stale prop on next render.
setValue("");
};
// Submit button swap animation (send <-> stop)
const swapProgress = useSharedValue(loading ? 1 : 0);
/** Button-level pulse on the swap — a quick shrink + bouncy return makes
* the mode change read as a physical morph, not just an icon crossfade. */
const morphPulse = useSharedValue(1);
const wasLoading = useRef(loading);
useEffect(() => {
if (wasLoading.current !== loading) {
// Send↔stop swap — a selection tick marks the mode morph, on top
// of the light press haptic that fired on submit.
haptics.selection();
wasLoading.current = loading;
}
if (reduce) {
swapProgress.value = loading ? 1 : 0;
morphPulse.value = 1;
return;
}
swapProgress.value = withSpring(loading ? 1 : 0, spring.press);
morphPulse.value = withSequence(
withTiming(0.82, timing.fast),
withSpring(1, spring.bouncy)
);
}, [loading, reduce, swapProgress, morphPulse, haptics]);
const sendStyle = useAnimatedStyle(() => ({
opacity: 1 - swapProgress.value,
transform: [
// Send arrow lifts and spins out as the stop glyph takes over.
{ scale: interpolate(swapProgress.value, [0, 1], [1, 0.5]) },
{ translateY: interpolate(swapProgress.value, [0, 1], [0, -5]) },
{ rotate: `${interpolate(swapProgress.value, [0, 1], [0, -45])}deg` },
],
}));
const stopStyle = useAnimatedStyle(() => ({
opacity: swapProgress.value,
transform: [
{ scale: interpolate(swapProgress.value, [0, 1], [0.5, 1]) },
{ translateY: interpolate(swapProgress.value, [0, 1], [5, 0]) },
],
}));
const pulseStyle = useAnimatedStyle(() => ({
transform: [{ scale: morphPulse.value }],
}));
const heightStyle = useAnimatedStyle(() => ({ height: inputH.value }));
// Send buttons dip harder than surfaces — the 32px target needs a
// quick, deeper squash to read as a press.
const press = usePressAnimation({ pressedScale: 0.9 });
const handleSendPressIn = () => {
press.onPressIn?.();
};
const handleSendPressOut = () => {
press.onPressOut?.();
};
return (
<View
ref={ref}
className={cn(disabled && "opacity-60", className)}
{...props}
>
{/* Composer floats on liquid glass — real iOS 26 material when
installed, frosted elsewhere. */}
<Glass effect="regular" className="rounded-2xl p-2">
<AnimatedTextInput
ref={inputRef}
value={currentValue}
onChangeText={setValue}
placeholder={placeholder}
multiline
// Native: return key sends instead of inserting a newline (the
// submit event fires without blurring the field).
submitBehavior="submit"
onSubmitEditing={submit}
onKeyPress={(e) => {
if (Platform.OS !== "web") return;
// Web: RNW ignores submitBehavior — Enter→submit only fires when
// blurOnSubmit is set, which would also drop focus. Handle it
// here: Enter sends, Shift+Enter stays a newline, and IME
// composition (isComposing) never submits mid-conversion.
const ne = e.nativeEvent as {
key: string;
shiftKey?: boolean;
isComposing?: boolean;
preventDefault?: () => void;
};
if (ne.key === "Enter" && !ne.shiftKey && !ne.isComposing && canSubmit) {
ne.preventDefault?.();
submit();
}
}}
editable={!disabled}
accessibilityLabel={accessibilityLabel}
onContentSizeChange={(e) => {
const h = e.nativeEvent.contentSize.height;
const clamped = Math.min(Math.max(h, minRows * LINE_HEIGHT), maxRows * LINE_HEIGHT);
inputH.value = reduce ? clamped : withSpring(clamped, spring.layout);
}}
style={[{ paddingHorizontal: 8, paddingTop: 6, paddingBottom: 6 }, heightStyle]}
className="text-sm leading-6 text-foreground placeholder:text-muted-foreground"
/>
<View className="mt-1 flex-row items-center justify-end gap-1">
{actions?.map((a) => (
<PromptActionButton
key={a.value}
action={a}
disabled={disabled}
onPress={() => {
haptics.light();
a.onPress?.();
onAction?.(a);
}}
/>
))}
<AnimatedPressable
disabled={sendDisabled}
onPress={
loading
? () => {
haptics.light();
onStop?.();
}
: submit
}
onPressIn={handleSendPressIn}
onPressOut={handleSendPressOut}
style={press.animatedStyle}
className={cn("h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-primary", sendDisabled && "opacity-40")}
accessibilityRole="button"
accessibilityLabel={loading ? "Stop generating" : "Send prompt"}
accessibilityHint={loading ? "Stops the current response" : "Submits the prompt"}
accessibilityState={{ disabled: sendDisabled, busy: loading }}
hitSlop={8}
>
<Animated.View style={pulseStyle} className="h-full w-full items-center justify-center">
<Animated.View style={sendStyle} className="absolute">
<Icon name="arrow-up" size="sm" color="primaryForeground" />
</Animated.View>
<Animated.View style={stopStyle} className="absolute">
<Icon name="pause" size="sm" color="primaryForeground" filled />
</Animated.View>
</Animated.View>
</AnimatedPressable>
</View>
</Glass>
</View>
);
}
);
PromptInput.displayName = "PromptInput";