Custom Input
EasyA highly flexible text input component with 5 visual variants (default, outlined, filled, underlined, search), 3 size presets, built-in password toggle, multiline support with auto-expanding on Android, clearable mode, error/success validation states, and full customization via style overrides and custom color themes. Zero external dependencies — uses only core React Native APIs.
Live Preview
Open in SnackInstallation
Copy CustomInput.tsx to your components folder
Import and use in your screen
No external npm packages required
Source Code
import React, { useState, forwardRef, useCallback } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Platform,
type ViewStyle,
type TextStyle,
} from 'react-native';
type TextInputProps = React.ComponentProps<typeof TextInput>;
// ─── Types ───────────────────────────────────────────────────────────────────
type InputVariant = 'default' | 'outlined' | 'filled' | 'underlined' | 'search';
type InputSize = 'small' | 'medium' | 'large';
interface CustomInputProps extends Omit<TextInputProps, 'style'> {
/** Label displayed above the input */
label?: string;
/** Placeholder text */
placeholder?: string;
/** Current value */
value?: string;
/** Text change handler */
onChangeText?: (text: string) => void;
// ─── Input types ─────────────────────────────────
/** Secure text entry for passwords */
secureTextEntry?: boolean;
/** Show password visibility toggle (works with secureTextEntry) */
autoPassword?: boolean;
/** Enable multiline input */
multiline?: boolean;
/** Number of visible lines (multiline) */
numberOfLines?: number;
/** Max lines before scrolling (Android) */
maxNumberOfLines?: number;
/** Maximum character length */
maxLength?: number;
// ─── Icons ───────────────────────────────────────
/** Custom left icon element */
leftIcon?: React.ReactNode;
/** Custom right icon element */
rightIcon?: React.ReactNode;
/** Show search icon on the left (defaults to true for 'search' variant) */
showSearchIcon?: boolean;
// ─── Styling and variants ────────────────────────
/** Visual variant */
variant?: InputVariant;
/** Size preset */
size?: InputSize;
/** Disabled state */
disabled?: boolean;
/** Whether the input is editable */
editable?: boolean;
// ─── Validation ──────────────────────────────────
/** Error message (shows red border + text) */
error?: string;
/** Success message (shows green border + text) */
success?: string;
/** Helper text below input */
helperText?: string;
/** Show required asterisk on label */
required?: boolean;
// ─── Style overrides ─────────────────────────────
/** Container style override */
containerStyle?: ViewStyle;
/** Input text style override */
inputStyle?: TextStyle;
/** Label style override */
labelStyle?: TextStyle;
// ─── Clearable ───────────────────────────────────
/** Show clear button when input has value */
clearable?: boolean;
/** Callback when clear button is pressed */
onClear?: () => void;
// ─── Custom colors (optional theme override) ─────
colors?: {
primary?: string;
background?: string;
border?: string;
text?: string;
placeholder?: string;
error?: string;
success?: string;
disabled?: string;
};
}
// ─── Default Colors ──────────────────────────────────────────────────────────
const DEFAULT_COLORS = {
primary: '#1a1a2e',
background: '#f5f5f5',
border: '#c0c0c0',
text: '#1a1a2e',
placeholder: '#a0a0a0',
error: '#e74c3c',
success: '#2ecc71',
disabled: '#e0e0e0',
white: '#ffffff',
};
// ─── Size Config ─────────────────────────────────────────────────────────────
const SIZE_CONFIG = {
small: { height: 40, fontSize: 13, px: 12, iconSize: 18 },
medium: { height: 48, fontSize: 15, px: 16, iconSize: 20 },
large: { height: 56, fontSize: 17, px: 20, iconSize: 22 },
};
// ─── Component ───────────────────────────────────────────────────────────────
const CustomInput = forwardRef<any, CustomInputProps>(
(
{
label,
placeholder,
value,
onChangeText,
secureTextEntry = false,
autoPassword = false,
multiline = false,
numberOfLines = 1,
maxNumberOfLines = 4,
maxLength,
leftIcon,
rightIcon,
showSearchIcon,
variant = 'default',
size = 'medium',
disabled = false,
editable = true,
error,
success,
helperText,
required = false,
containerStyle,
inputStyle,
labelStyle,
placeholderTextColor,
onFocus,
onBlur,
clearable = false,
onClear,
colors: customColors,
...rest
},
ref
) => {
const COLORS = { ...DEFAULT_COLORS, ...customColors };
const sizeConfig = SIZE_CONFIG[size as InputSize] || SIZE_CONFIG.medium;
const shouldShowSearchIcon =
showSearchIcon !== undefined ? showSearchIcon : variant === 'search';
const [isSecureVisible, setIsSecureVisible] = useState(secureTextEntry);
const [isFocused, setIsFocused] = useState(false);
const [androidHeight, setAndroidHeight] = useState<number | null>(null);
// ─── Handlers ────────────────────────────────
const handleFocus = useCallback(
(e: any) => {
setIsFocused(true);
onFocus?.(e);
},
[onFocus]
);
const handleBlur = useCallback(
(e: any) => {
setIsFocused(false);
onBlur?.(e);
},
[onBlur]
);
const toggleSecure = useCallback(() => {
setIsSecureVisible((prev: boolean) => !prev);
}, []);
const handleContentSizeChange = useCallback(
(e: any) => {
if (!multiline || Platform.OS !== 'android') return;
const { height } = e.nativeEvent.contentSize;
const lineH = sizeConfig.fontSize * 1.4;
const minH = lineH + 8;
const maxH = lineH * maxNumberOfLines + 8;
const newH = Math.min(Math.max(height + 8, minH), maxH);
if (newH !== androidHeight) setAndroidHeight(newH);
},
[multiline, sizeConfig.fontSize, maxNumberOfLines, androidHeight]
);
// ─── Styles ──────────────────────────────────
const showPasswordToggle = autoPassword && secureTextEntry;
const getVariantStyle = (): ViewStyle => {
switch (variant) {
case 'outlined':
return {
borderWidth: 1,
borderColor: COLORS.border,
backgroundColor: 'transparent',
};
case 'filled':
return {
backgroundColor: COLORS.background,
borderWidth: 0,
};
case 'underlined':
return {
borderRadius: 0,
borderBottomWidth: 1.5,
borderBottomColor: COLORS.border,
backgroundColor: 'transparent',
borderWidth: 0,
};
case 'search':
return {
borderRadius: 24,
backgroundColor: COLORS.background,
borderWidth: 0,
};
default:
return {
borderWidth: 0.5,
borderColor: COLORS.border,
};
}
};
const getStateStyle = (): ViewStyle => {
if (error) {
return {
borderColor: COLORS.error,
borderWidth: variant === 'underlined' ? 0 : 1,
borderBottomWidth: variant === 'underlined' ? 2 : 1,
};
}
if (success) {
return {
borderColor: COLORS.success,
borderWidth: variant === 'underlined' ? 0 : 1,
borderBottomWidth: variant === 'underlined' ? 2 : 1,
};
}
if (isFocused) {
return {
borderColor: COLORS.primary,
borderWidth: variant === 'underlined' ? 0 : 1,
borderBottomWidth: variant === 'underlined' ? 2 : 1,
};
}
return {};
};
const containerBaseStyle: ViewStyle = {
flexDirection: 'row',
alignItems: multiline ? 'flex-start' : 'center',
borderRadius: 8,
backgroundColor: COLORS.background,
height: multiline ? undefined : sizeConfig.height,
minHeight: multiline ? sizeConfig.height : undefined,
paddingHorizontal: sizeConfig.px,
...getVariantStyle(),
...getStateStyle(),
...(disabled ? { backgroundColor: COLORS.disabled, opacity: 0.6 } : {}),
...(multiline && Platform.OS === 'android' && androidHeight
? { height: androidHeight }
: {}),
};
// ─── Render ──────────────────────────────────
return (
<View style={styles.wrapper}>
{/* Label */}
{label && (
<Text style={[styles.label, { color: COLORS.text }, labelStyle]}>
{label}
{required && <Text style={{ color: COLORS.error }}> *</Text>}
</Text>
)}
{/* Input Container */}
<View style={[containerBaseStyle, containerStyle]}>
{/* Left Icon / Search Icon */}
{(leftIcon || shouldShowSearchIcon) && (
<View style={styles.iconLeft}>
{leftIcon || (
<Text style={{ fontSize: sizeConfig.iconSize, color: COLORS.placeholder }}>
🔍
</Text>
)}
</View>
)}
{/* TextInput */}
<TextInput
ref={ref}
style={[
styles.input,
{
fontSize: sizeConfig.fontSize,
color: COLORS.text,
textAlignVertical: multiline ? 'top' : 'center',
paddingVertical: multiline ? 12 : 4,
},
inputStyle,
] as any}
placeholder={placeholder}
placeholderTextColor={placeholderTextColor || COLORS.placeholder}
value={value}
onChangeText={onChangeText}
secureTextEntry={isSecureVisible}
multiline={multiline}
numberOfLines={numberOfLines}
maxLength={maxLength}
editable={editable && !disabled}
onFocus={handleFocus}
onBlur={handleBlur}
onContentSizeChange={
Platform.OS === 'android' ? handleContentSizeChange : undefined
}
{...rest}
/>
{/* Password Toggle */}
{showPasswordToggle && (
<TouchableOpacity
onPress={toggleSecure}
style={styles.iconRight}
disabled={disabled}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Text style={{ fontSize: sizeConfig.iconSize }}>
{isSecureVisible ? '👁️🗨️' : '👁️'}
</Text>
</TouchableOpacity>
)}
{/* Right Icon */}
{!showPasswordToggle && rightIcon && (
<View style={styles.iconRight}>{rightIcon}</View>
)}
{/* Clear Button */}
{clearable && (value?.length ?? 0) > 0 && !showPasswordToggle && (
<TouchableOpacity
onPress={onClear}
style={styles.iconRight}
disabled={disabled}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Text style={{ fontSize: sizeConfig.iconSize, color: COLORS.placeholder }}>
✕
</Text>
</TouchableOpacity>
)}
</View>
{/* Feedback Text */}
{error && <Text style={[styles.feedbackText, { color: COLORS.error }]}>{error}</Text>}
{success && !error && (
<Text style={[styles.feedbackText, { color: COLORS.success }]}>{success}</Text>
)}
{helperText && !error && !success && (
<Text style={[styles.feedbackText, { color: COLORS.placeholder }]}>
{helperText}
</Text>
)}
</View>
);
}
);
CustomInput.displayName = 'CustomInput';
// ─── Styles ──────────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
wrapper: {
marginVertical: 6,
},
label: {
fontSize: 14,
fontWeight: '600',
marginBottom: 6,
},
input: {
flex: 1,
padding: 0,
margin: 0,
outlineStyle: 'none' as any, // removes default blue web focus outline
borderWidth: 0, // removes any default TextInput border on web
},
iconLeft: {
marginRight: 10,
justifyContent: 'center',
alignItems: 'center',
},
iconRight: {
marginLeft: 10,
justifyContent: 'center',
alignItems: 'center',
},
feedbackText: {
fontSize: 12,
marginTop: 4,
marginLeft: 4,
},
});
export default CustomInput;
Usage Examples
Default Input
Basic text input with label
const [name, setName] = useState('');
<CustomInput
label="Full Name"
placeholder="Enter your name"
value={name}
onChangeText={setName}
/>Outlined Variant
Input with outlined border style
<CustomInput
variant="outlined"
label="Email"
placeholder="you@example.com"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>Password Input
Secure input with visibility toggle
<CustomInput
label="Password"
placeholder="Enter password"
secureTextEntry
autoPassword
value={password}
onChangeText={setPassword}
/>Search Input
Rounded search bar variant
<CustomInput
variant="search"
placeholder="Search..."
value={query}
onChangeText={setQuery}
clearable
onClear={() => setQuery('')}
/>Underlined Variant
Minimal underlined style input
<CustomInput
variant="underlined"
label="Username"
placeholder="@username"
value={username}
onChangeText={setUsername}
/>With Validation
Input showing error and success states
<CustomInput
label="Email"
placeholder="you@example.com"
value={email}
onChangeText={setEmail}
error={!isValid ? 'Please enter a valid email' : ''}
success={isValid ? 'Looks good!' : ''}
required
/>Multiline Input
Auto-expanding text area
<CustomInput
label="Bio"
placeholder="Tell us about yourself..."
multiline
numberOfLines={3}
size="large"
value={bio}
onChangeText={setBio}
/>Filled Variant with Icons
Filled background with custom left icon
import Icon from 'react-native-vector-icons/MaterialIcons';
<CustomInput
variant="filled"
label="Location"
placeholder="Enter city"
leftIcon={<Icon name="location-on" size={20} color="#666" />}
value={city}
onChangeText={setCity}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | - | Label text displayed above the input |
placeholder | string | - | Placeholder text shown when empty |
value | string | - | Current value of the input |
onChangeText | (text: string) => void | - | Callback fired when text changes |
variant | 'default' | 'outlined' | 'filled' | 'underlined' | 'search' | default | Visual variant of the input |
size | 'small' | 'medium' | 'large' | medium | Size preset affecting height, font size, and padding |
secureTextEntry | boolean | false | Hide text for password fields |
autoPassword | boolean | false | Show password visibility toggle icon (use with secureTextEntry) |
multiline | boolean | false | Enable multiline text input |
numberOfLines | number | 1 | Initial number of visible lines (multiline) |
maxNumberOfLines | number | 4 | Maximum lines before scrolling (Android) |
leftIcon | ReactNode | - | Custom icon element on the left side |
rightIcon | ReactNode | - | Custom icon element on the right side |
error | string | - | Error message (shows red border and text) |
success | string | - | Success message (shows green border and text) |
helperText | string | - | Helper text displayed below the input |
required | boolean | false | Show required asterisk on label |
disabled | boolean | false | Disable the input |
clearable | boolean | false | Show clear × button when input has value |
onClear | () => void | - | Callback when clear button is pressed |
colors | object | - | Custom color theme override (primary, background, border, text, placeholder, error, success, disabled) |
Features
- 5 visual variants: default, outlined, filled, underlined, search
- 3 size presets: small, medium, large
- Password field with visibility toggle
- Multiline with auto-expand on Android
- Clearable input with × button
- Error, success, and helper text states
- Left and right icon slots
- Built-in search icon for search variant
- Label with required asterisk indicator
- forwardRef support for programmatic focus
- Fully customizable colors via theme override
- Zero external dependencies
- TypeScript support with full type definitions
Dependencies
Required:
None