Input
Single-line text field with Sizes, a leading icon, error/success status, a focus ring and an automatic Show/Hide password toggle.
Light
Dark
Your app is ready.
Inter body text, Inter headings.
a0: Vega, neutral, neutral accent, default radius, Inter.Change PresetInstallation
npx nativecn-cli@latest add inputAlso installs: theme, form-field-context, focus-chain, pressable, icon, announce.
Packages: lucide-react-native@^1.51.0, react-native-svg.
Usage
Input demo
Inputs with an icon, Sizes, a status and a password toggle.
import { Mail } from "lucide-react-native";
import { View } from "react-native";
import { Input } from "@/components/input";
export function InputDemo() {
return (
<View>
<Input icon={Mail} placeholder="Email" aria-label="Email" keyboardType="email-address" />
<Input size="sm" placeholder="Small" aria-label="Small" />
<Input size="lg" placeholder="Large" aria-label="Large" />
<Input status="error" defaultValue="not-an-email" aria-label="Email" />
<Input secureTextEntry placeholder="Password" aria-label="Password" />
<Input disabled defaultValue="Read only" aria-label="Disabled" />
</View>
);
}InputOTP demo
A 6-digit verification code, checked when complete, with error and success.
import { useState } from "react";
import { View } from "react-native";
import { InputOTP, type InputOTPStatus } from "@/components/input-otp";
import { Text } from "@/components/text";
import { createStyles } from "@/theme";
const useStyles = createStyles((t) => ({ root: { gap: t.spacing[3] } }));
/** A 6-digit verification code: checked when complete; typing again clears the status. */
export function InputOTPDemo() {
const styles = useStyles();
const [code, setCode] = useState("");
const [status, setStatus] = useState<InputOTPStatus>();
return (
<View style={styles.root}>
<Text variant="label">Verification code</Text>
<InputOTP
aria-label="Verification code"
value={code}
onChangeText={(next) => {
setCode(next);
setStatus(undefined);
}}
onComplete={(full) => setStatus(full === "123456" ? "success" : "error")}
status={status}
/>
<Text variant="caption" color={status === "error" ? "destructive" : "mutedForeground"}>
{status === "error" ? "That code is not right. Try 123456." : "Enter the code we sent you."}
</Text>
</View>
);
}Input demo
Inputs with an icon, Sizes, a status and a password toggle.
import { Mail } from "lucide-react-native";
import { View } from "react-native";
import { Input } from "@/components/input";
export function InputDemo() {
return (
<View>
<Input icon={Mail} placeholder="Email" aria-label="Email" keyboardType="email-address" />
<Input size="sm" placeholder="Small" aria-label="Small" />
<Input size="lg" placeholder="Large" aria-label="Large" />
<Input status="error" defaultValue="not-an-email" aria-label="Email" />
<Input secureTextEntry placeholder="Password" aria-label="Password" />
<Input disabled defaultValue="Read only" aria-label="Disabled" />
</View>
);
}InputOTP demo
A 6-digit verification code, checked when complete, with error and success.
import { useState } from "react";
import { View } from "react-native";
import { InputOTP, type InputOTPStatus } from "@/components/input-otp";
import { Text } from "@/components/text";
import { createStyles } from "@/theme";
const useStyles = createStyles((t) => ({ root: { gap: t.spacing[3] } }));
/** A 6-digit verification code: checked when complete; typing again clears the status. */
export function InputOTPDemo() {
const styles = useStyles();
const [code, setCode] = useState("");
const [status, setStatus] = useState<InputOTPStatus>();
return (
<View style={styles.root}>
<Text variant="label">Verification code</Text>
<InputOTP
aria-label="Verification code"
value={code}
onChangeText={(next) => {
setCode(next);
setStatus(undefined);
}}
onComplete={(full) => setStatus(full === "123456" ? "success" : "error")}
status={status}
/>
<Text variant="caption" color={status === "error" ? "destructive" : "mutedForeground"}>
{status === "error" ? "That code is not right. Try 123456." : "Enter the code we sent you."}
</Text>
</View>
);
}Props
| Prop | Type and description |
|---|---|
...TextInputProps | React Native TextInput props pass through (value, defaultValue, onChangeText, keyboardType, ...) |
size | 'sm' | 'md' | 'lg' (default 'md') |
icon | LucideIcon: leading, decorative |
status | 'error' | 'success': colours and a screen-reader announcement |
disabled | boolean: not editable, dimmed, skipped by FocusChain |
secureTextEntry | boolean: hides the text and adds a Show/Hide password toggle |
ref | Ref<TextInput> |
style | ViewStyle, layout only, merged last onto the frame |
Variants
size:sm,md,lgstatus:error,success
Accessibility
- The accessible name is aria-label, else the FormField label; inside a FormField the error, or else the description, is the hint.
- A new status is announced once ("Email: error"), unless a FormField is already announcing its error.
- disabled sets aria-disabled and makes the field read-only.
- The frame extends the tap area to 48 above and below, and a tap there focuses the field; screen readers skip the frame and reach the TextInput directly.
- With secureTextEntry, the toggle is a button named "Show password" or "Hide password", with its tap area extended to 48.
Notes
Inside FormField, status and disabled come from the field. Inside FocusChain the return key reads Next/Done automatically; a field's own returnKeyType/onSubmitEditing wins. Precedence: disabled > status.
Source
The Vega Style's code, exactly as add copies it into your app.
{components}/input.tsx
import { Eye, EyeOff, type LucideIcon } from "lucide-react-native";
import { useCallback, useEffect, useRef, useState, type Ref } from "react";
import {
Pressable as RNPressable,
StyleSheet,
TextInput,
type StyleProp,
type TextInputProps,
type ViewStyle,
} from "react-native";
import { Icon, type IconSize } from "@/components/icon";
import { useFocusChainField } from "@/components/primitives/focus-chain";
import {
useFormField,
type FormFieldState,
type FormFieldStatus,
} from "@/components/primitives/form-field-context";
import { Pressable, touchTargetHitSlop } from "@/components/primitives/pressable";
import { createStyles, useTheme } from "@/theme";
import { announce } from "@/utils/announce";
export type InputSize = "sm" | "md" | "lg";
export type InputStatus = FormFieldStatus;
const iconSizes = { sm: "sm", md: "md", lg: "lg" } as const satisfies Record<InputSize, IconSize>;
export type InputProps = Omit<TextInputProps, "style"> & {
/** Default `md`. */
size?: InputSize;
/** A Lucide icon shown before the text (decorative). */
icon?: LucideIcon;
/** Error or success colours, announced once. Inside a FormField it defaults to the field's. */
status?: InputStatus;
/** Not editable, dimmed and skipped by FocusChain. Inside a FormField it defaults to the field's. */
disabled?: boolean;
/** Layout only, merged last onto the root (the bordered frame). */
style?: StyleProp<ViewStyle>;
ref?: Ref<TextInput>;
};
/** The frame's styles, shared by Input and Textarea (same Style Slots, same Colour Roles). */
export const useInputStyles = createStyles((t) => {
const { fontSize, lineHeight, fontFamily, letterSpacing, ...frame } = {
height: t.controlHeight.md,
borderRadius: t.radius.md,
paddingHorizontal: t.spacing[3],
borderWidth: t.borderWidth.default,
...t.type.body,
};
return {
root: {
...frame,
flexDirection: "row",
alignItems: "center",
gap: t.spacing[2],
borderCurve: "continuous",
backgroundColor: t.colors.background,
borderColor: t.colors.input,
},
sm: { height: t.controlHeight.sm, paddingHorizontal: t.scaleValue(10) },
md: {},
lg: { height: t.controlHeight.lg, paddingHorizontal: t.spacing[4] },
text: {
flex: 1,
alignSelf: "stretch",
margin: 0,
padding: 0,
color: t.colors.foreground,
fontSize,
fontFamily,
letterSpacing,
},
/** Textarea: the frame grows with its content. */
autoHeight: { height: "auto", alignItems: "stretch" },
multilineText: { lineHeight, paddingVertical: t.spacing[2], textAlignVertical: "top" },
// The focus ring: the `ring` Colour Role, drawn outside the border.
focused: {
borderColor: t.colors.ring,
outlineColor: t.colors.ring,
outlineStyle: "solid",
outlineWidth: t.borderWidth.default * 2,
},
error: { borderColor: t.colors.destructive, outlineColor: t.colors.destructive },
success: { borderColor: t.colors.success, outlineColor: t.colors.success },
disabled: { opacity: t.opacity.disabled },
pressed: { filter: [{ brightness: 0.88 }] },
};
});
/**
* The extra tap area above and below a text field's frame so it reaches `min` (the 48 touch
* target), from the frame's height. The height is known before layout (a Size's Style Slot, or
* a height set through `style`), so the tap area is right from the first frame. Width is left
* alone: fields are wide.
*/
function fieldHitSlop(frameStyle: StyleProp<ViewStyle>, min: number) {
const { height } = StyleSheet.flatten(frameStyle) ?? {};
return touchTargetHitSlop(undefined, typeof height === "number" ? height : undefined, min);
}
export type TextFieldOptions = Pick<
TextInputProps,
"placeholder" | "aria-label" | "accessibilityHint" | "onFocus" | "onBlur"
> & {
disabled?: boolean;
status?: InputStatus;
};
export type TextField = {
/** The enclosing FormField, or `null`. */
field: FormFieldState | null;
disabled: boolean;
status: InputStatus | undefined;
focused: boolean;
/** Spread onto the TextInput: accessible name and hint, disabled state and focus tracking. */
inputProps: Pick<
TextInputProps,
"aria-label" | "aria-disabled" | "accessibilityHint" | "onFocus" | "onBlur"
>;
};
/**
* The shared behaviour of text fields (Input, Textarea): reads the enclosing FormField,
* tracks focus for the ring and announces a new `status`. Precedence: `disabled` > `status`. A FormField's error is announced by the FormField itself.
*/
export function useTextField({
disabled: disabledProp,
status: statusProp,
placeholder,
"aria-label": ariaLabel,
accessibilityHint,
onFocus,
onBlur,
}: TextFieldOptions): TextField {
const field = useFormField();
const disabled = disabledProp ?? field?.disabled ?? false;
const status = disabled ? undefined : (statusProp ?? field?.status);
const [focused, setFocused] = useState(false);
const name = ariaLabel ?? field?.label ?? placeholder;
const fieldError = field?.error;
const announced = useRef<InputStatus | undefined>(undefined);
useEffect(() => {
if (status && status !== announced.current && !fieldError) {
announce(name ? `${name}: ${status}` : status);
}
announced.current = status;
}, [status, name, fieldError]);
const a11y = field?.accessibilityProps;
return {
field,
disabled,
status,
focused,
inputProps: {
"aria-label": ariaLabel ?? a11y?.["aria-label"],
accessibilityHint: accessibilityHint ?? a11y?.accessibilityHint,
"aria-disabled": disabled,
onFocus: (e) => {
setFocused(true);
onFocus?.(e);
},
onBlur: (e) => {
setFocused(false);
onBlur?.(e);
},
},
};
}
/**
* A single-line text field. Colours come from the Colour Roles (`input` border, `ring` focus
* ring, `destructive`/`success` status); Sizes from the `input.*` Style Slots. With
* `secureTextEntry` it adds a Show/Hide password toggle. Inside a FormField it takes the label as
* its name and the error as its hint, and inside a FocusChain its return key reads Next/Done.
* TextInput props pass through; `style` is merged last onto the frame.
*/
export function Input({
size = "md",
icon,
status,
disabled,
secureTextEntry,
editable,
placeholder,
placeholderTextColor,
returnKeyType,
onSubmitEditing,
submitBehavior,
"aria-label": ariaLabel,
accessibilityHint,
onFocus,
onBlur,
style,
ref,
...props
}: InputProps) {
const { colors, iconSize, minTouchTarget } = useTheme();
const styles = useInputStyles();
const inputRef = useRef<TextInput | null>(null);
// Merge the own ref (stable, for FocusChain) with the user's `ref` prop.
const setRef = useCallback(
(node: TextInput | null) => {
inputRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ref],
);
const f = useTextField({
disabled,
status,
placeholder,
"aria-label": ariaLabel,
accessibilityHint,
onFocus,
onBlur,
});
const [revealed, setRevealed] = useState(false);
const chain = useFocusChainField(inputRef, {
disabled: f.disabled || editable === false,
multiline: props.multiline,
returnKeyType,
onSubmitEditing,
submitBehavior,
});
const toggleSide = iconSize[iconSizes[size]];
const isEditable = !f.disabled && editable !== false;
const frameStyle: StyleProp<ViewStyle> = [
styles.root,
styles[size],
f.focused ? styles.focused : null,
f.status ? styles[f.status] : null,
f.disabled ? styles.disabled : null,
style,
];
const hitSlop = fieldHitSlop(frameStyle, minTouchTarget);
return (
// The frame extends the tap area to 48 (TextInput has no hitSlop): a tap on the frame or
// within its hitSlop focuses the field. It is invisible to screen readers, which reach the
// TextInput (and the password toggle) directly.
<RNPressable
accessible={false}
focusable={false}
importantForAccessibility="no"
disabled={!isEditable}
hitSlop={hitSlop}
onPress={isEditable ? () => inputRef.current?.focus() : undefined}
style={frameStyle}
>
{icon ? <Icon icon={icon} size={iconSizes[size]} color="mutedForeground" /> : null}
<TextInput
ref={setRef}
editable={isEditable}
placeholder={placeholder}
placeholderTextColor={placeholderTextColor ?? colors.mutedForeground}
selectionColor={colors.primary}
secureTextEntry={!!secureTextEntry && !revealed}
style={styles.text}
{...props}
{...chain}
{...f.inputProps}
/>
{secureTextEntry ? (
<Pressable
aria-label={revealed ? "Hide password" : "Show password"}
disabled={f.disabled}
size={{ width: toggleSide, height: toggleSide }}
pressedStyle={styles.pressed}
onPress={() => setRevealed((r) => !r)}
>
<Icon icon={revealed ? EyeOff : Eye} size={iconSizes[size]} color="mutedForeground" />
</Pressable>
) : null}
</RNPressable>
);
}{components}/input.tsx
import { Eye, EyeOff, type LucideIcon } from "lucide-react-native";
import { useCallback, useEffect, useRef, useState, type Ref } from "react";
import {
Pressable as RNPressable,
StyleSheet,
TextInput,
type StyleProp,
type TextInputProps,
type ViewStyle,
} from "react-native";
import { Icon, type IconSize } from "@/components/icon";
import { useFocusChainField } from "@/components/primitives/focus-chain";
import {
useFormField,
type FormFieldState,
type FormFieldStatus,
} from "@/components/primitives/form-field-context";
import { Pressable, touchTargetHitSlop } from "@/components/primitives/pressable";
import { createStyles, useTheme } from "@/theme";
import { announce } from "@/utils/announce";
export type InputSize = "sm" | "md" | "lg";
export type InputStatus = FormFieldStatus;
const iconSizes = { sm: "sm", md: "md", lg: "lg" } as const satisfies Record<InputSize, IconSize>;
export type InputProps = Omit<TextInputProps, "style"> & {
/** Default `md`. */
size?: InputSize;
/** A Lucide icon shown before the text (decorative). */
icon?: LucideIcon;
/** Error or success colours, announced once. Inside a FormField it defaults to the field's. */
status?: InputStatus;
/** Not editable, dimmed and skipped by FocusChain. Inside a FormField it defaults to the field's. */
disabled?: boolean;
/** Layout only, merged last onto the root (the bordered frame). */
style?: StyleProp<ViewStyle>;
ref?: Ref<TextInput>;
};
/** The frame's styles, shared by Input and Textarea (same Style Slots, same Colour Roles). */
export const useInputStyles = createStyles((t) => {
const { fontSize, lineHeight, fontFamily, letterSpacing, ...frame } = {
height: t.controlHeight.sm,
borderRadius: t.radius.sm,
paddingHorizontal: t.scaleValue(10),
borderWidth: t.borderWidth.default,
...t.type.body,
fontSize: t.scaleValue(15),
lineHeight: t.scaleValue(20),
};
return {
root: {
...frame,
flexDirection: "row",
alignItems: "center",
gap: t.spacing[2],
borderCurve: "continuous",
backgroundColor: t.colors.background,
borderColor: t.colors.input,
},
sm: { height: t.scaleValue(32), paddingHorizontal: t.spacing[2] },
md: {},
lg: { height: t.controlHeight.md, paddingHorizontal: t.spacing[3] },
text: {
flex: 1,
alignSelf: "stretch",
margin: 0,
padding: 0,
color: t.colors.foreground,
fontSize,
fontFamily,
letterSpacing,
},
/** Textarea: the frame grows with its content. */
autoHeight: { height: "auto", alignItems: "stretch" },
multilineText: { lineHeight, paddingVertical: t.spacing[2], textAlignVertical: "top" },
// The focus ring: the `ring` Colour Role, drawn outside the border.
focused: {
borderColor: t.colors.ring,
outlineColor: t.colors.ring,
outlineStyle: "solid",
outlineWidth: t.borderWidth.default * 2,
},
error: { borderColor: t.colors.destructive, outlineColor: t.colors.destructive },
success: { borderColor: t.colors.success, outlineColor: t.colors.success },
disabled: { opacity: t.opacity.disabled },
pressed: { filter: [{ brightness: 0.88 }], transform: [{ scale: 0.98 }] },
};
});
/**
* The extra tap area above and below a text field's frame so it reaches `min` (the 48 touch
* target), from the frame's height. The height is known before layout (a Size's Style Slot, or
* a height set through `style`), so the tap area is right from the first frame. Width is left
* alone: fields are wide.
*/
function fieldHitSlop(frameStyle: StyleProp<ViewStyle>, min: number) {
const { height } = StyleSheet.flatten(frameStyle) ?? {};
return touchTargetHitSlop(undefined, typeof height === "number" ? height : undefined, min);
}
export type TextFieldOptions = Pick<
TextInputProps,
"placeholder" | "aria-label" | "accessibilityHint" | "onFocus" | "onBlur"
> & {
disabled?: boolean;
status?: InputStatus;
};
export type TextField = {
/** The enclosing FormField, or `null`. */
field: FormFieldState | null;
disabled: boolean;
status: InputStatus | undefined;
focused: boolean;
/** Spread onto the TextInput: accessible name and hint, disabled state and focus tracking. */
inputProps: Pick<
TextInputProps,
"aria-label" | "aria-disabled" | "accessibilityHint" | "onFocus" | "onBlur"
>;
};
/**
* The shared behaviour of text fields (Input, Textarea): reads the enclosing FormField,
* tracks focus for the ring and announces a new `status`. Precedence: `disabled` > `status`. A FormField's error is announced by the FormField itself.
*/
export function useTextField({
disabled: disabledProp,
status: statusProp,
placeholder,
"aria-label": ariaLabel,
accessibilityHint,
onFocus,
onBlur,
}: TextFieldOptions): TextField {
const field = useFormField();
const disabled = disabledProp ?? field?.disabled ?? false;
const status = disabled ? undefined : (statusProp ?? field?.status);
const [focused, setFocused] = useState(false);
const name = ariaLabel ?? field?.label ?? placeholder;
const fieldError = field?.error;
const announced = useRef<InputStatus | undefined>(undefined);
useEffect(() => {
if (status && status !== announced.current && !fieldError) {
announce(name ? `${name}: ${status}` : status);
}
announced.current = status;
}, [status, name, fieldError]);
const a11y = field?.accessibilityProps;
return {
field,
disabled,
status,
focused,
inputProps: {
"aria-label": ariaLabel ?? a11y?.["aria-label"],
accessibilityHint: accessibilityHint ?? a11y?.accessibilityHint,
"aria-disabled": disabled,
onFocus: (e) => {
setFocused(true);
onFocus?.(e);
},
onBlur: (e) => {
setFocused(false);
onBlur?.(e);
},
},
};
}
/**
* A single-line text field. Colours come from the Colour Roles (`input` border, `ring` focus
* ring, `destructive`/`success` status); Sizes from the `input.*` Style Slots. With
* `secureTextEntry` it adds a Show/Hide password toggle. Inside a FormField it takes the label as
* its name and the error as its hint, and inside a FocusChain its return key reads Next/Done.
* TextInput props pass through; `style` is merged last onto the frame.
*/
export function Input({
size = "md",
icon,
status,
disabled,
secureTextEntry,
editable,
placeholder,
placeholderTextColor,
returnKeyType,
onSubmitEditing,
submitBehavior,
"aria-label": ariaLabel,
accessibilityHint,
onFocus,
onBlur,
style,
ref,
...props
}: InputProps) {
const { colors, iconSize, minTouchTarget } = useTheme();
const styles = useInputStyles();
const inputRef = useRef<TextInput | null>(null);
// Merge the own ref (stable, for FocusChain) with the user's `ref` prop.
const setRef = useCallback(
(node: TextInput | null) => {
inputRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ref],
);
const f = useTextField({
disabled,
status,
placeholder,
"aria-label": ariaLabel,
accessibilityHint,
onFocus,
onBlur,
});
const [revealed, setRevealed] = useState(false);
const chain = useFocusChainField(inputRef, {
disabled: f.disabled || editable === false,
multiline: props.multiline,
returnKeyType,
onSubmitEditing,
submitBehavior,
});
const toggleSide = iconSize[iconSizes[size]];
const isEditable = !f.disabled && editable !== false;
const frameStyle: StyleProp<ViewStyle> = [
styles.root,
styles[size],
f.focused ? styles.focused : null,
f.status ? styles[f.status] : null,
f.disabled ? styles.disabled : null,
style,
];
const hitSlop = fieldHitSlop(frameStyle, minTouchTarget);
return (
// The frame extends the tap area to 48 (TextInput has no hitSlop): a tap on the frame or
// within its hitSlop focuses the field. It is invisible to screen readers, which reach the
// TextInput (and the password toggle) directly.
<RNPressable
accessible={false}
focusable={false}
importantForAccessibility="no"
disabled={!isEditable}
hitSlop={hitSlop}
onPress={isEditable ? () => inputRef.current?.focus() : undefined}
style={frameStyle}
>
{icon ? <Icon icon={icon} size={iconSizes[size]} color="mutedForeground" /> : null}
<TextInput
ref={setRef}
editable={isEditable}
placeholder={placeholder}
placeholderTextColor={placeholderTextColor ?? colors.mutedForeground}
selectionColor={colors.primary}
secureTextEntry={!!secureTextEntry && !revealed}
style={styles.text}
{...props}
{...chain}
{...f.inputProps}
/>
{secureTextEntry ? (
<Pressable
aria-label={revealed ? "Hide password" : "Show password"}
disabled={f.disabled}
size={{ width: toggleSide, height: toggleSide }}
pressedStyle={styles.pressed}
onPress={() => setRevealed((r) => !r)}
>
<Icon icon={revealed ? EyeOff : Eye} size={iconSizes[size]} color="mutedForeground" />
</Pressable>
) : null}
</RNPressable>
);
}
