Veltrix

text input

Contextual Input Bar

A multiline composer that reveals suggestions, reply context, attachments, and state-aware actions without abrupt layout jumps.

AppleiOSAndroidAndroidExpoExpo Go
reanimated
GitHubOpen in GitHub

Installation

Install the dependencies, then copy the files for your preferred renderer.

pnpm add react-native-reanimated

Anatomy

1 file

anatomy.tsx
<ContextualInputBar>  <ContextualInputBar.Attachment />  <ContextualInputBar.Attachments />  <ContextualInputBar.Context />  <ContextualInputBar.EmptyAction />  <ContextualInputBar.Helper />  <ContextualInputBar.Input />  <ContextualInputBar.Leading />  <ContextualInputBar.LoadingAction />  <ContextualInputBar.SubmitAction /></ContextualInputBar>

Keyboard dismissal

KeyboardDismissalExample.tsx
import {  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.tsx
import { 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-variants

Usage

Composer.tsx
import { 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>  );}

Component files

6 files

contextual-input-bar/config.ts
export const CONTEXTUAL_INPUT_BAR_CONFIG = {  actionDuration: 160,  colors: {    activeAction: "#171713",    focusedBorder: "#6f5cff",    idleAction: "#deddd6",    idleBorder: "#deddd6",  },  layoutDuration: 220,} as const;
contextual-input-bar/types.ts
import 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.tsx
import {  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.ts
import { 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.ts
import {  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.ts
import { 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,  };}

API reference

Generated directly from the exported component props.

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.