text input
Contextual Input Bar
A multiline composer that reveals suggestions, reply context, attachments, and state-aware actions without abrupt layout jumps.
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add react-native-reanimatedanatomy.tsx<ContextualInputBar> <ContextualInputBar.Attachment /> <ContextualInputBar.Attachments /> <ContextualInputBar.Context /> <ContextualInputBar.EmptyAction /> <ContextualInputBar.Helper /> <ContextualInputBar.Input /> <ContextualInputBar.Leading /> <ContextualInputBar.LoadingAction /> <ContextualInputBar.SubmitAction /></ContextualInputBar>Keyboard dismissal
KeyboardDismissalExample.tsximport { Keyboard, Pressable, ScrollView, Text, View,} from "react-native";export function KeyboardDismissalExample() { return ( <View> <Pressable onPress={Keyboard.dismiss}> <Text>Dismiss keyboard</Text> </Pressable> <ScrollView keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" > <Text>Screen content</Text> </ScrollView> </View> );}Controlled expansion
By default, focusing the input expands the contextual panel and blurring it
collapses the panel. Use expanded when another part of the screen controls
that state:
ControlledExpansionExample.tsximport { useState } from "react";import { Pressable, Text } from "react-native";export function ControlledExpansionExample() { const [suggestionsVisible, setSuggestionsVisible] = useState(false); return ( <ContextualInputBar expanded={suggestionsVisible} onExpandedChange={setSuggestionsVisible} onSubmit={(message) => console.log(message)} > <ContextualInputBar.Context> <Text>Suggested reply: Thanks for the update.</Text> </ContextualInputBar.Context> <ContextualInputBar.Input placeholder="Write an update…" /> <ContextualInputBar.SubmitAction> {({ submit }) => ( <Pressable onPress={submit}> <Text>Send</Text> </Pressable> )} </ContextualInputBar.SubmitAction> </ContextualInputBar> );}Install tailwind variants
pnpm add tailwind-variantsUsage
Composer.tsximport { Feather } from "@expo/vector-icons";import { Pressable, Text } from "react-native";import { ContextualInputBar } from "@animations/ui/components/text-input/contextual-input-bar/uniwind";export function Composer() { return ( <ContextualInputBar onSubmit={(value) => console.log(value)}> <ContextualInputBar.Context> {({ value }) => ( <Text className="text-xs text-neutral-500"> {value.length === 0 ? "Replying to launch review" : "Drafting a contextual reply"} </Text> )} </ContextualInputBar.Context> <ContextualInputBar.Attachments> <ContextualInputBar.Attachment className="rounded-full bg-violet-50 px-3 py-2"> <Text>launch-notes.pdf</Text> </ContextualInputBar.Attachment> </ContextualInputBar.Attachments> <ContextualInputBar.EmptyAction> <Pressable onPress={() => console.log("Start recording")}> <Feather color="#77776f" name="mic" size={18} /> </Pressable> </ContextualInputBar.EmptyAction> <ContextualInputBar.Helper> {({ value }) => ( <Text className="text-xs text-neutral-500"> {value.length}/240 </Text> )} </ContextualInputBar.Helper> <ContextualInputBar.Leading> <Pressable onPress={() => console.log("Open attachments")}> <Feather color="#6f5cff" name="plus" size={20} /> </Pressable> </ContextualInputBar.Leading> <ContextualInputBar.Input maxLength={240} placeholder="Write an update…" /> <ContextualInputBar.SubmitAction> {({ submit }) => ( <Pressable onPress={submit}> <Feather color="#ffffff" name="arrow-up" size={18} /> </Pressable> )} </ContextualInputBar.SubmitAction> </ContextualInputBar> );}contextual-input-bar/config.tsexport const CONTEXTUAL_INPUT_BAR_CONFIG = { actionDuration: 160, colors: { activeAction: "#171713", focusedBorder: "#6f5cff", idleAction: "#deddd6", idleBorder: "#deddd6", }, layoutDuration: 220,} as const;contextual-input-bar/types.tsimport type { ReactNode } from "react";export type ContextualInputBarRenderState = { canSubmit: boolean; disabled: boolean; expanded: boolean; focused: boolean; loading: boolean; submit: () => void; value: string;};/** Static content or a render function with draft state. */export type ContextualInputBarSlot = | ReactNode | ((state: ContextualInputBarRenderState) => ReactNode);export type ContextualInputBarBaseProps = { children?: ReactNode; clearOnSubmit?: boolean; /** Initial expansion state for uncontrolled usage. */ defaultExpanded?: boolean; defaultValue?: string; disabled?: boolean; /** Expansion state controlled by the parent. */ expanded?: boolean; loading?: boolean; onChangeText?: (value: string) => void; onExpandedChange?: (expanded: boolean) => void; onSubmit?: (value: string) => void; value?: string;};contextual-input-bar/uniwind/index.tsximport { Children, createContext, isValidElement, type ReactElement, type ReactNode, useContext,} from "react";import { TextInput, View, type TextInputProps, type ViewProps,} from "react-native";import Animated, { Easing, FadeIn, FadeOut, LinearTransition,} from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import { CONTEXTUAL_INPUT_BAR_CONFIG } from "../config";import type { ContextualInputBarBaseProps, ContextualInputBarRenderState, ContextualInputBarSlot,} from "../types";import { useContextualInputBar } from "../use-contextual-input-bar";import { useContextualInputBarAnimation } from "../use-contextual-input-bar-animation";import { contextualInputBarVariants, type ContextualInputBarSlotName,} from "./variants";const contextualInputBarLayoutTransition = LinearTransition.duration( CONTEXTUAL_INPUT_BAR_CONFIG.layoutDuration, ).easing(Easing.out(Easing.cubic));const ContextualInputBarControllerContext = createContext<ReturnType<typeof useContextualInputBar> | null>( null, );type ContextualInputBarRootSlotName = Extract< ContextualInputBarSlotName, "actionContent" | "panel" | "row" | "surface">;type ContextualInputBarPartProps = Omit< ViewProps, "children"> & { children: ContextualInputBarSlot; className?: string;};type ContextualInputBarAttachmentProps = ViewProps & { children?: ReactNode; className?: string;};type ContextualInputBarActionProps = { children: ContextualInputBarSlot; className?: string;};type ContextualInputBarInputProps = Omit< TextInputProps, | "defaultValue" | "editable" | "multiline" | "onChangeText" | "value"> & { className?: string;};export type ContextualInputBarProps = ContextualInputBarBaseProps & { className?: string; classNames?: SlotsToClasses<ContextualInputBarRootSlotName>; };function useContextualInputBarState() { return useContextualInputBarController().renderState;}function useContextualInputBarController() { const controller = useContext( ContextualInputBarControllerContext, ); if (!controller) { throw new Error( "ContextualInputBar.Input must be used inside ContextualInputBar.", ); } return controller;}function renderPart( children: ContextualInputBarSlot, state: ContextualInputBarRenderState,) { return typeof children === "function" ? children(state) : children;}/** Suggestions, reply context, mentions, or validation content displayed in the expanded panel. */function ContextualInputBarContext({ children, className, ...viewProps}: ContextualInputBarPartProps) { const state = useContextualInputBarState(); const slots = contextualInputBarVariants(); return ( <View {...viewProps} className={slots.context({ className, })} > {renderPart(children, state)} </View> );}/** Groups attachments inside the expanded panel. */function ContextualInputBarAttachments({ children, className, ...viewProps}: ContextualInputBarPartProps) { const state = useContextualInputBarState(); const slots = contextualInputBarVariants(); return ( <View {...viewProps} className={slots.attachments({ className, })} > {renderPart(children, state)} </View> );}/** Styles one attachment, file, image, or related chip. */function ContextualInputBarAttachment({ children, className, ...viewProps}: ContextualInputBarAttachmentProps) { const slots = contextualInputBarVariants(); return ( <View {...viewProps} className={slots.attachment({ className, })} > {children} </View> );}/** Leading control or status supplied by the consumer. */function ContextualInputBarLeading({ children, className, ...viewProps}: ContextualInputBarPartProps) { const state = useContextualInputBarState(); const slots = contextualInputBarVariants(); return ( <Animated.View {...viewProps} className={slots.leading({ className, })} layout={contextualInputBarLayoutTransition} > {renderPart(children, state)} </Animated.View> );}/** Character counts, validation, or helper content rendered below the input surface. */function ContextualInputBarHelper({ children, className, ...viewProps}: ContextualInputBarPartProps) { const state = useContextualInputBarState(); const slots = contextualInputBarVariants(); return ( <View {...viewProps} className={slots.helper({ className, })} > {renderPart(children, state)} </View> );}/** Draft input; blur collapses uncontrolled expansion. */function ContextualInputBarInput({ className, onBlur, onFocus, onSubmitEditing, placeholderTextColor = "#96958d", ...inputProps}: ContextualInputBarInputProps) { const controller = useContextualInputBarController(); const slots = contextualInputBarVariants(); return ( <TextInput {...inputProps} blurOnSubmit={false} className={slots.input({ className, })} editable={ !controller.renderState.disabled && !controller.renderState.loading } multiline onBlur={(event) => { controller.handleBlur(); onBlur?.(event); }} onChangeText={controller.updateValue} onFocus={(event) => { controller.handleFocus(); onFocus?.(event); }} onSubmitEditing={(event) => { onSubmitEditing?.(event); controller.submit(); }} placeholderTextColor={placeholderTextColor} value={controller.resolvedValue} /> );}/** Action shown while the draft cannot be submitted. */function ContextualInputBarEmptyAction({ children,}: ContextualInputBarActionProps) { return renderPart(children, useContextualInputBarState());}/** Action shown while the draft can be submitted. */function ContextualInputBarSubmitAction({ children,}: ContextualInputBarActionProps) { return renderPart(children, useContextualInputBarState());}/** Action content shown while the root `loading` prop is true. */function ContextualInputBarLoadingAction({ children,}: ContextualInputBarActionProps) { return renderPart(children, useContextualInputBarState());}/** Contextual composer for keyboard-aware screens. */function ContextualInputBarRoot({ children, className, classNames, clearOnSubmit = true, defaultExpanded, defaultValue, disabled = false, expanded, loading = false, onChangeText, onExpandedChange, onSubmit, value,}: ContextualInputBarProps) { const behavior = useContextualInputBar({ clearOnSubmit, defaultExpanded, defaultValue, disabled, expanded, loading, onChangeText, onExpandedChange, onSubmit, value, }); const parts = Children.toArray(children); const context = parts.find( (part) => isValidElement(part) && part.type === ContextualInputBarContext, ); const attachments = parts.find( (part) => isValidElement(part) && part.type === ContextualInputBarAttachments, ); const leading = parts.find( (part) => isValidElement(part) && part.type === ContextualInputBarLeading, ); const input = parts.find( (part) => isValidElement(part) && part.type === ContextualInputBarInput, ); const helper = parts.find( (part) => isValidElement(part) && part.type === ContextualInputBarHelper, ); const emptyAction = parts.find( (part) => isValidElement(part) && part.type === ContextualInputBarEmptyAction, ) as ReactElement<ContextualInputBarActionProps> | undefined; const submitAction = parts.find( (part) => isValidElement(part) && part.type === ContextualInputBarSubmitAction, ) as ReactElement<ContextualInputBarActionProps> | undefined; const loadingAction = parts.find( (part) => isValidElement(part) && part.type === ContextualInputBarLoadingAction, ) as ReactElement<ContextualInputBarActionProps> | undefined; const actionContent = loading ? loadingAction : behavior.renderState.canSubmit ? submitAction : emptyAction; const actionState = loading ? "loading" : behavior.renderState.canSubmit ? "submit" : "empty"; const hasPanel = behavior.resolvedExpanded && (context !== undefined || attachments !== undefined); const slots = contextualInputBarVariants({ disabled }); const { actionAnimatedStyle, surfaceAnimatedStyle, } = useContextualInputBarAnimation({ canSubmit: behavior.renderState.canSubmit, focused: behavior.renderState.focused, loading, }); return ( <ContextualInputBarControllerContext.Provider value={behavior} > <View className={slots.root({ className, })} > <Animated.View className={slots.surface({ className: classNames?.surface, })} layout={contextualInputBarLayoutTransition} style={surfaceAnimatedStyle} > {hasPanel && ( <View className={slots.panel({ className: classNames?.panel, })} > {context} {attachments} </View> )} <View className={slots.row({ className: classNames?.row, })} > {leading} {input} {actionContent !== undefined && ( <Animated.View className={slots.action({ className: actionContent.props.className, })} layout={contextualInputBarLayoutTransition} > <Animated.View className={slots.actionContent({ className: classNames?.actionContent, })} pointerEvents="none" style={actionAnimatedStyle} /> <Animated.View className={slots.actionContent({ className: classNames?.actionContent, })} entering={FadeIn.duration( CONTEXTUAL_INPUT_BAR_CONFIG.actionDuration, )} exiting={FadeOut.duration( CONTEXTUAL_INPUT_BAR_CONFIG.actionDuration, )} key={actionState} > {actionContent} </Animated.View> </Animated.View> )} </View> </Animated.View> {helper} </View> </ContextualInputBarControllerContext.Provider> );}export const ContextualInputBar = Object.assign( ContextualInputBarRoot, { Attachment: ContextualInputBarAttachment, Attachments: ContextualInputBarAttachments, Context: ContextualInputBarContext, EmptyAction: ContextualInputBarEmptyAction, Helper: ContextualInputBarHelper, Input: ContextualInputBarInput, Leading: ContextualInputBarLeading, LoadingAction: ContextualInputBarLoadingAction, SubmitAction: ContextualInputBarSubmitAction, },);contextual-input-bar/uniwind/variants.tsimport { tv } from "tailwind-variants";const contextualInputBarSlots = { action: "size-9.5 items-center justify-center overflow-hidden rounded-[19px]", actionContent: "absolute inset-0 items-center justify-center", attachment: "self-start flex-row items-center", attachments: "px-2.5 pb-2", context: "px-2.5", helper: "px-3 pt-1.25", input: "min-h-9.5 max-h-21 flex-1 px-0.5 py-2 text-[15px] font-semibold leading-5 text-[#171713]", leading: "h-9.5 w-8.5 items-center justify-center", panel: "gap-1.5 pt-2", root: "w-full", row: "flex-row items-end gap-1.5 p-1.5", surface: "overflow-hidden rounded-[20px] border border-[#deddd6] bg-white",} as const;export type ContextualInputBarSlotName = keyof typeof contextualInputBarSlots;export const contextualInputBarVariants = tv({ slots: contextualInputBarSlots, variants: { disabled: { true: { root: "opacity-50", }, }, },});contextual-input-bar/use-contextual-input-bar-animation.tsimport { interpolateColor, useAnimatedStyle, useDerivedValue, withTiming,} from "react-native-reanimated";import { CONTEXTUAL_INPUT_BAR_CONFIG } from "./config";export function useContextualInputBarAnimation({ canSubmit, focused, loading,}: { canSubmit: boolean; focused: boolean; loading: boolean;}) { const actionProgress = useDerivedValue( () => withTiming(canSubmit || loading ? 1 : 0, { duration: CONTEXTUAL_INPUT_BAR_CONFIG.actionDuration, }), ); const focusProgress = useDerivedValue( () => withTiming(focused ? 1 : 0, { duration: CONTEXTUAL_INPUT_BAR_CONFIG.layoutDuration, }), ); const actionAnimatedStyle = useAnimatedStyle(() => ({ backgroundColor: interpolateColor( actionProgress.get(), [0, 1], [ CONTEXTUAL_INPUT_BAR_CONFIG.colors.idleAction, CONTEXTUAL_INPUT_BAR_CONFIG.colors.activeAction, ], ), })); const surfaceAnimatedStyle = useAnimatedStyle(() => ({ borderColor: interpolateColor( focusProgress.get(), [0, 1], [ CONTEXTUAL_INPUT_BAR_CONFIG.colors.idleBorder, CONTEXTUAL_INPUT_BAR_CONFIG.colors.focusedBorder, ], ), })); return { actionAnimatedStyle, surfaceAnimatedStyle, };}contextual-input-bar/use-contextual-input-bar.tsimport { useState } from "react";import type { ContextualInputBarBaseProps, ContextualInputBarRenderState,} from "./types";type ContextualInputBarBehaviorProps = Pick< ContextualInputBarBaseProps, | "clearOnSubmit" | "defaultExpanded" | "defaultValue" | "disabled" | "expanded" | "loading" | "onChangeText" | "onExpandedChange" | "onSubmit" | "value">;export function useContextualInputBar({ clearOnSubmit = true, defaultExpanded = false, defaultValue = "", disabled = false, expanded, loading = false, onChangeText, onExpandedChange, onSubmit, value,}: ContextualInputBarBehaviorProps) { const [internalValue, setInternalValue] = useState(defaultValue); const [internalExpanded, setInternalExpanded] = useState(defaultExpanded); const [focused, setFocused] = useState(false); const resolvedValue = value ?? internalValue; const resolvedExpanded = expanded ?? internalExpanded; const canSubmit = !disabled && !loading && resolvedValue.trim().length > 0; const renderState = { canSubmit, disabled, expanded: resolvedExpanded, focused, loading, submit, value: resolvedValue, } satisfies ContextualInputBarRenderState; function updateExpanded(nextExpanded: boolean) { if (expanded === undefined) { setInternalExpanded(nextExpanded); } onExpandedChange?.(nextExpanded); } function updateValue(nextValue: string) { if (value === undefined) { setInternalValue(nextValue); } onChangeText?.(nextValue); } function handleFocus() { setFocused(true); updateExpanded(true); } function handleBlur() { setFocused(false); updateExpanded(false); } function submit() { if (!canSubmit) { return; } const submittedValue = resolvedValue.trim(); onSubmit?.(submittedValue); if (clearOnSubmit) { updateValue(""); } } return { handleBlur, handleFocus, renderState, resolvedExpanded, resolvedValue, submit, updateValue, };}ContextualInputBar
Contextual composer for keyboard-aware screens.
ContextualInputBar.Attachment
Styles one attachment, file, image, or related chip.
ContextualInputBar.Attachment extends all props from ViewProps, with the additional component-specific props shown below.
ContextualInputBar.Attachments
Groups attachments inside the expanded panel.
ContextualInputBar.Context
Suggestions, reply context, mentions, or validation content displayed in the expanded panel.
ContextualInputBar.EmptyAction
Action shown while the draft cannot be submitted.
ContextualInputBar.Helper
Character counts, validation, or helper content rendered below the input surface.
ContextualInputBar.Input
Draft input; blur collapses uncontrolled expansion.
ContextualInputBar.Leading
Leading control or status supplied by the consumer.
ContextualInputBar.LoadingAction
Action content shown while the root `loading` prop is true.
ContextualInputBar.SubmitAction
Action shown while the draft can be submitted.
