Smooth Border Text Input

TextInput with a label and animated border that smoothly transitions colors based on focus, error state, and value presence, with optional start icon and error message display.

react-nativetextinputformanimationreanimatedui-componentinputvalidation

Installation

SmoothBorderTextInput.tsx
import {
  View,
  TextInput,
  StyleSheet,
  StyleProp,
  ViewStyle,
  TextInputProps,
  Text,
  PixelRatio
} from "react-native";
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  interpolateColor,
  withTiming,
  Easing,
  ReduceMotion,
} from "react-native-reanimated";
import { useRef } from "react";
const DEFAULT_INPUT_HEIGHT = 50; //you can change this according to your liking

type SmoothBorderTextInputProps = {
  containerStyle?: StyleProp<ViewStyle>;
  backgroundColor?: string;
  label: string;
  labelColor: string; // label color which is the top
  valueColor: string; // input value color
  isFocusBorderColor: string; // border color while editing
  isBlurBorderColor: string; //border color when there is no text value
  isBlurValueBorderColor: string; //border color when you finish entering the text
  startIcon?: React.ReactElement;
  isError?: boolean;
  errorMessage?: string;
  reduceMotion?: "never" | "always" | "system";
};

export default function SmoothBorderTextInput(
  props: React.JSX.IntrinsicAttributes &
  	React.JSX.IntrinsicClassAttributes<TextInput> &
  	Readonly<TextInputProps> &
  	SmoothBorderTextInputProps
) {
  const inputRef = useRef<TextInput>(null);
  const fontScale = PixelRatio.getFontScale();
  const animatedValue = useSharedValue(0);

  const motion =
  props?.reduceMotion === "never"
  		? ReduceMotion.Never
  		: props?.reduceMotion === "always"
  			? ReduceMotion.Always
  			: ReduceMotion.System;

  // Handle focus and blur events
  const handleFocus = () => {
  	animatedValue.value = withTiming(1, {
  		duration: 350,
  		easing: Easing.in(Easing.linear),
  		reduceMotion: motion,
  	});
  };

  const handleBlur = () => {
  	animatedValue.value = withTiming(0, {
  		duration: 250,
  		easing: Easing.out(Easing.linear),
  		reduceMotion: motion,
  	});
  };

  const BorderStyle = useAnimatedStyle(() => {
  	// Define the "from" color (unfocused state)
  	let fromColor;
  	// Define the "to" color (focused state)
  	let toColor;

  	if (props.isError) {
  		// Error state - always red regardless of focus
  		fromColor = "#F65936";
  		toColor = "#F65936";
  	} else {
  		// No error state - handle normal cases
  		if (props.value) {
  			// Has value
  			fromColor = props?.isBlurValueBorderColor;
  		} else {
  			// No value
  			fromColor = props?.isBlurBorderColor;
  		}

  		// Focus color is always the same
  		toColor = props.isFocusBorderColor;
  	}

  	return {
  		borderColor: interpolateColor(
  			animatedValue.value,
  			[0, 1],
  			[fromColor, toColor]
  		),
  		zIndex: 1,
  	};
  });

  return (
  	<View style={{ marginBottom: 16 }}>
  		<Text style={[styles.label, { color: props?.labelColor}]}>
  			{props?.label}
  		</Text>
  		<Animated.View
  			// onTouchStart={() => inputRef?.current?.focus()}
  			style={[
  				styles.container,
  				{
  					backgroundColor: props?.backgroundColor ?? "transparent",
  					height: DEFAULT_INPUT_HEIGHT * fontScale,
  				},
  				BorderStyle,
  			]}
  		>
  			{!!props?.startIcon && (
  				<View style={styles.iconContainer}>{props.startIcon}</View>
  			)}
  			<TextInput
  				ref={inputRef}
  				clearButtonMode="while-editing"
  				placeholderTextColor={props?.labelColor}
  				style={[
  					styles.input,
  					{
  						color: props?.valueColor,
  					},
  					props?.style,
  				]}
  				onFocus={handleFocus}
  				onBlur={handleBlur}
  				{...props}
  			/>
  		</Animated.View>
  		{props?.isError && (
  			<Text style={[styles.errorText, { color: "#F65936" }]}>
  				{props?.errorMessage}
  			</Text>
  		)}
  	</View>
  );
}

const styles = StyleSheet.create({
  container: {
  	flex: 1,
  	flexDirection: "row",
  	// position: "relative",
  	alignItems: "center",
  	marginBlock: 4,
  	borderWidth: 1,
  	borderRadius: 12,
  },
  input: {
  	flex: 1,
  	fontSize: 14,
  	borderRadius: 12,
  	padding: 12,
  	height: "100%",
  	outline: "none",
  },
  label: {
  	fontSize: 14,
  	marginLeft: 8,
  	zIndex: 100,
  },
  errorText: {
  	fontSize: 12,
  },
  iconContainer: {
  	zIndex: 2
  },
});

Usage

import React, { useState } from 'react';
import { View, StyleSheet, SafeAreaView, Text } from 'react-native';
import SmoothBorderTextInput from './SmoothBorderTextInput';
import { useAppColors } from '@/hooks/useAppColors'; 

const App = () => {
const [inputValue, setInputValue] = useState('');
const colors = useAppColors(); 

return (
  <SafeAreaView style={[styles.safeArea, { backgroundColor: colors.BackgroundSystem }]}>
    <View style={styles.container}>
      <Text style={[styles.title, { color: colors.Neutral900 }]}>
        My Awesome Input
      </Text>

      <SmoothBorderTextInput
        label="Your Name"
        value={inputValue}
        onChangeText={setInputValue}
        placeholder="e.g., Jane Doe"
        labelColor={colors.Neutral500}
        valueColor={colors.Neutral700}
        isFocusBorderColor={colors.PrimaryNormal}
        isBlurBorderColor={colors.Neutral100}
        isBlurValueBorderColor={colors.SuccessfulNormal}
      />

      <Text style={[styles.displayValue, { color: colors.Neutral700 }]}>
        You typed: {inputValue}
      </Text>
    </View>
  </SafeAreaView>
);
};

const styles = StyleSheet.create({
safeArea: {
  flex: 1,
},
container: {
  flex: 1,
  padding: 20,
  justifyContent: 'center',
},
title: {
  fontSize: 24,
  fontWeight: 'bold',
  marginBottom: 20,
  textAlign: 'center',
},
displayValue: {
  marginTop: 20,
  fontSize: 16,
  textAlign: 'center',
},
});

export default App;

Props

PropTypeDefaultRequiredDescription
labelstringYesThe text to display as a label above the input field.
labelColorstringYesColor of the label text.
valueColorstringYesColor of the text entered into the input field.
isFocusBorderColorstringYesBorder color of the input field when it is focused (being edited).
isBlurBorderColorstringYesBorder color when the input is not focused and has no text value.
isBlurValueBorderColorstringYesBorder color when the input is not focused but has a text value.
containerStyleStyleProp<ViewStyle>NoCustom styles for the main container wrapping the input and icon.
backgroundColorstring'transparent'NoBackground color for the input field's container.
startIconReact.ReactElementNoAn optional icon component to display at the beginning of the input field.
isErrorbooleanfalseNoIf true, displays the input in an error state (e.g., red border).
errorMessagestringNoAn error message to display below the input when isError is true.
reduceMotion'never' | 'always' | 'system''system'NoControls animation behavior (border color transition).
...propsTextInputPropsVariesInherits all other props from the standard React Native TextInput.