Input
React NativeError shake, focus ring and a drawn success check.
Preview


Install
Source on GitHubIt works in Expo and in bare React Native. New to beUI Native? Set up your app first.
Copies the component and the shared files it needs into src/, adds the theme tokens if they're missing, and installs its libraries. Files you've changed are kept.
Keep the folder layout, since the component imports the shared files by relative path.
1Install the libraries
npx expo install react-native-reanimated react-native-worklets
npm install uniwind tailwindcss2Add the theme tokens
Once per app. Import it from the CSS file Uniwind reads, after Tailwind and Uniwind, and change the values to match your brand.
/* Matches the default beUI palette in ui-components/lib/themes.ts.
Native surface maps to card; muted maps to muted-foreground. */
@layer theme {
:root {
@variant light {
--color-background: oklch(99% 0 0);
--color-surface: oklch(97% 0 0);
--color-foreground: oklch(15% 0 0);
--color-muted: oklch(50% 0 0);
--color-border: oklch(15% 0 0 / 0.06);
--color-border-strong: oklch(15% 0 0 / 0.16);
--color-primary: #0285f7;
--color-on-primary: #ffffff;
--color-destructive: #dc2626;
--color-success: #059669;
--color-warning: #d97706;
}
}
:root {
@variant dark {
--color-background: #151515;
--color-surface: #1c1c1c;
--color-foreground: oklch(96% 0 0);
--color-muted: oklch(62% 0 0);
--color-border: rgb(255 255 255 / 0.05);
--color-border-strong: rgb(255 255 255 / 0.14);
--color-primary: #0285f7;
--color-on-primary: #ffffff;
--color-destructive: #f87171;
--color-success: #34d399;
--color-warning: #fbbf24;
}
}
}
3Copy the shared files
import { useEffect, useRef, useState } from "react";
export type PresenceItem<T> = {
key: string;
value: T;
/** False for the item present on first render, so it does not animate in. */
appear: boolean;
exiting: boolean;
};
/**
* Keeps replaced items mounted while they animate out, like AnimatePresence.
* A key that returns while exiting is revived in place.
*/
export function usePresence<T>(
key: string | null,
value: T,
exitDuration: (value: T) => number,
) {
const [items, setItems] = useState<PresenceItem<T>[]>(() =>
key === null ? [] : [{ key, value, appear: false, exiting: false }],
);
const [currentKey, setCurrentKey] = useState(key);
const exitDurationRef = useRef(exitDuration);
exitDurationRef.current = exitDuration;
// Adjust during render so the new item and the exits start in the same frame.
if (key !== currentKey) {
setCurrentKey(key);
setItems((previous) => {
const next = previous.map((item) =>
item.key === key
? { ...item, value, exiting: false }
: { ...item, exiting: true },
);
if (key !== null && !previous.some((item) => item.key === key)) {
next.push({ key, value, appear: true, exiting: false });
}
return next;
});
}
const exitingKeys = items
.filter((item) => item.exiting)
.map((item) => item.key)
.join("\n");
useEffect(() => {
if (!exitingKeys) return;
const timers = items
.filter((item) => item.exiting)
.map((item) =>
setTimeout(() => {
setItems((previous) =>
previous.filter(
(candidate) => !(candidate.key === item.key && candidate.exiting),
),
);
}, exitDurationRef.current(item.value)),
);
return () => {
for (const timer of timers) clearTimeout(timer);
};
// Restart removal timers only when the set of exiting items changes.
}, [exitingKeys]);
return items;
}
import { useEffect, useState } from "react";
import { AccessibilityInfo } from "react-native";
import { useReducedMotion } from "react-native-reanimated";
/** Reanimated supplies a synchronous initial value; native events keep it current. */
export function useReducedMotionPreference() {
const initialValue = useReducedMotion();
const [reducedMotion, setReducedMotion] = useState(initialValue);
useEffect(() => {
let active = true;
let receivedEvent = false;
const subscription = AccessibilityInfo.addEventListener(
"reduceMotionChanged",
(value) => {
receivedEvent = true;
setReducedMotion(value);
},
);
void AccessibilityInfo.isReduceMotionEnabled().then((value) => {
if (active && !receivedEvent) setReducedMotion(value);
});
return () => {
active = false;
subscription.remove();
};
}, []);
return reducedMotion;
}
import { useCallback } from "react";
import {
type EasingFunction,
type EasingFunctionFactory,
useAnimatedStyle,
useSharedValue,
withSequence,
withTiming,
} from "react-native-reanimated";
/**
* Horizontal shake through `keyframes` (in points) over `duration` ms, split
* evenly like a Motion keyframe array. Callers skip it under reduced motion.
*/
export function useShake(
keyframes: number[],
duration: number,
easing?: EasingFunction | EasingFunctionFactory,
) {
const offset = useSharedValue(0);
const step = duration / Math.max(1, keyframes.length - 1);
const shake = useCallback(() => {
const [first = 0, ...rest] = keyframes;
offset.value = first;
offset.value = withSequence(
// An explicit `easing: undefined` would replace Reanimated's default.
...rest.map((point) =>
withTiming(
point,
easing ? { duration: step, easing } : { duration: step },
),
),
);
}, [easing, keyframes, offset, step]);
const style = useAnimatedStyle(() => ({
transform: [{ translateX: offset.value }],
}));
return { shake, style };
}
import { type ColorValue, StyleSheet, View } from "react-native";
import Animated, {
interpolate,
type SharedValue,
useAnimatedStyle,
} from "react-native-reanimated";
/** A straight line in a square viewBox: [x1, y1, x2, y2]. */
export type PathSegment = [x1: number, y1: number, x2: number, y2: number];
export type DrawnPathProps = {
/** Straight segments drawn in order, like an SVG polyline. */
segments: PathSegment[];
/** Rendered width and height in points. */
size: number;
/** Stroke width in viewBox units. */
strokeWidth: number;
/** Side of the square viewBox the segments are written in. Defaults to 24. */
viewBox?: number;
color: ColorValue | undefined;
/** 0 draws nothing and 1 draws every segment, like SVG `pathLength`. */
progress: SharedValue<number>;
};
function length([x1, y1, x2, y2]: PathSegment) {
return Math.hypot(x2 - x1, y2 - y1);
}
/**
* Draws a polyline on with rotated Views, so shared components need no SVG
* library. Round caps extend half a stroke past each end, like SVG's linecap.
*/
export function DrawnPath({
segments,
size,
strokeWidth,
viewBox = 24,
color,
progress,
}: DrawnPathProps) {
const unit = size / viewBox;
const stroke = strokeWidth * unit;
const total = segments.reduce((sum, segment) => sum + length(segment), 0);
let start = 0;
return (
<View style={{ width: size, height: size }}>
{segments.map((segment) => {
const from = start / total;
start += length(segment);
return (
<Stroke
key={segment.join()}
segment={segment}
unit={unit}
stroke={stroke}
from={from}
to={start / total}
progress={progress}
color={color}
/>
);
})}
</View>
);
}
function Stroke({
segment,
unit,
stroke,
from,
to,
progress,
color,
}: {
segment: PathSegment;
unit: number;
stroke: number;
from: number;
to: number;
progress: SharedValue<number>;
color: ColorValue | undefined;
}) {
const [x1, y1, x2, y2] = segment;
const full = length(segment) * unit;
const angle = Math.atan2(y2 - y1, x2 - x1);
const animatedStyle = useAnimatedStyle(() => {
const drawn = interpolate(progress.value, [from, to], [0, 1], "clamp");
return { opacity: drawn > 0 ? 1 : 0, width: stroke + full * drawn };
});
return (
<Animated.View
style={[
styles.stroke,
{
left: x1 * unit - stroke / 2,
top: y1 * unit - stroke / 2,
height: stroke,
borderRadius: stroke / 2,
backgroundColor: color,
// Rotate around the start point so the stroke grows from it.
transformOrigin: [stroke / 2, stroke / 2, 0],
transform: [{ rotate: `${angle}rad` }],
},
animatedStyle,
]}
/>
);
}
const styles = StyleSheet.create({
stroke: { position: "absolute" },
});
import {
type ComponentType,
createElement,
isValidElement,
type ReactNode,
} from "react";
import type { ColorValue } from "react-native";
export type IconProps = { size: number; color: ColorValue | undefined };
/** A component receiving `{ size, color }` (such as a lucide icon), or a prebuilt node. */
export type IconSource = ComponentType<IconProps> | ReactNode;
export function renderIcon(icon: IconSource, props: IconProps) {
if (typeof icon === "function") return createElement(icon, props);
// forwardRef and memo components (lucide-react-native, for example) are objects.
if (
icon != null &&
typeof icon === "object" &&
!isValidElement(icon) &&
"$$typeof" in icon
) {
return createElement(icon as unknown as ComponentType<IconProps>, props);
}
return icon;
}
import { Easing, ReduceMotion } from "react-native-reanimated";
// Mirrors beUI's lib/ease.ts so native motion matches the web library.
export const EASE_OUT = Easing.bezier(0.16, 1, 0.3, 1);
export const EASE_IN_OUT = Easing.bezier(0.77, 0, 0.175, 1);
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
stiffness: 500,
damping: 30,
mass: 0.6,
reduceMotion: ReduceMotion.System,
} as const;
/** Content swaps: label and icon slots trading places inside a control. */
export const SPRING_SWAP = {
stiffness: 460,
damping: 30,
mass: 0.55,
reduceMotion: ReduceMotion.System,
} as const;
/** Shared-layout glides: pills, indicators and panels moving between positions. */
export const SPRING_LAYOUT = {
stiffness: 360,
damping: 32,
mass: 0.6,
reduceMotion: ReduceMotion.System,
} as const;
/** Dragged handles and fills; critically damped so values never rebound off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
reduceMotion: ReduceMotion.System,
} as const;
export const motion = {
press: SPRING_PRESS,
swap: SPRING_SWAP,
layout: SPRING_LAYOUT,
glide: SPRING_GLIDE,
exit: { duration: 160, easing: EASE_OUT, reduceMotion: ReduceMotion.System },
fade: { duration: 150, reduceMotion: ReduceMotion.System },
} as const;
4Copy the component
export type { InputClassNames, InputProps } from "./input";
export { Input } from "./input";
import { type Ref, useEffect, useRef, useState } from "react";
import {
AccessibilityInfo,
Platform,
StyleSheet,
Text,
TextInput,
type TextInputProps,
View,
} from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useResolveClassNames, useUniwind } from "uniwind";
import { usePresence } from "../../hooks/use-presence";
import { useReducedMotionPreference } from "../../hooks/use-reduced-motion";
import { useShake } from "../../hooks/use-shake";
import { DrawnPath, type PathSegment } from "../../primitives/drawn-path";
import { type IconSource, renderIcon } from "../../primitives/render-icon";
import { motion } from "../../styles/motion";
const FIELD_HEIGHT = 44;
const ICON = 16;
const CHECK: PathSegment[] = [
[5, 12.5, 9.5, 17],
[9.5, 17, 19, 7.5],
];
const SHAKE = [0, -6, 6, -4, 4, -2, 0];
const COLOR_FADE = { duration: 200, reduceMotion: motion.fade.reduceMotion };
const MESSAGE_FADE = { duration: 200, reduceMotion: motion.fade.reduceMotion };
export type InputClassNames = {
root?: string;
label?: string;
field?: string;
input?: string;
errorMessage?: string;
};
export type InputProps = Omit<TextInputProps, "editable"> & {
label?: string;
/** A truthy error shakes the field and turns it red. A string also shows a message. */
error?: string | boolean;
/** Reserves one message line so validation does not shift content below. */
reserveErrorLine?: boolean;
/** Draws a check at the trailing edge in place of `rightIcon`. */
success?: boolean;
/** Decorative icon at the leading edge, given `{ size, color }`. */
leftIcon?: IconSource;
/** Trailing icon or control, such as a show-password button. */
rightIcon?: IconSource;
disabled?: boolean;
/** Disables shake and transitions independently of the system preference. */
disableAnimation?: boolean;
className?: string;
classNames?: InputClassNames;
ref?: Ref<TextInput>;
};
function ErrorMessage({
message,
appear,
exiting,
reduce,
className,
}: {
message: string;
appear: boolean;
exiting: boolean;
reduce: boolean;
className: string;
}) {
const progress = useSharedValue(appear ? 0 : 1);
useEffect(() => {
progress.value = withTiming(exiting ? 0 : 1, MESSAGE_FADE);
}, [exiting, progress]);
const animatedStyle = useAnimatedStyle(
() => ({
opacity: progress.value,
transform: [{ translateY: reduce ? 0 : -4 * (1 - progress.value) }],
}),
[reduce],
);
return (
<Animated.View
style={[exiting && styles.exiting, animatedStyle]}
accessibilityLiveRegion="polite"
>
<Text className={`px-1 text-xs text-destructive ${className}`}>
{message}
</Text>
</Animated.View>
);
}
function SuccessCheck({ reduce }: { reduce: boolean }) {
const { color } = useResolveClassNames("text-success");
const draw = useSharedValue(reduce ? 1 : 0);
useEffect(() => {
if (!reduce) {
draw.value = withTiming(1, {
duration: 350,
easing: Easing.out(Easing.quad),
});
}
}, [draw, reduce]);
return (
<View
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
aria-hidden
style={styles.trailing}
>
<DrawnPath
segments={CHECK}
size={20}
strokeWidth={2.5}
color={color}
progress={draw}
/>
</View>
);
}
export function Input({
label,
error,
reserveErrorLine = false,
success = false,
leftIcon,
rightIcon,
disabled = false,
disableAnimation = false,
className = "",
classNames,
value: valueProp,
defaultValue,
onChangeText,
onFocus,
onBlur,
accessibilityLabel,
accessibilityHint,
keyboardAppearance,
style,
ref,
...props
}: InputProps) {
// Match the keyboard to the app's theme unless the caller chooses one.
const { theme } = useUniwind();
const [uncontrolled, setUncontrolled] = useState(defaultValue ?? "");
const value = valueProp ?? uncontrolled;
const [focused, setFocused] = useState(false);
const inputRef = useRef<TextInput | null>(null);
const reducedMotion = useReducedMotionPreference();
const animate = !reducedMotion && !disableAnimation;
const hasError = Boolean(error);
const errorMessage = typeof error === "string" && error ? error : null;
const state = hasError ? "error" : focused ? "focused" : "idle";
const { color: foreground } = useResolveClassNames("text-foreground");
const { color: muted } = useResolveClassNames("text-muted");
const { color: placeholder } = useResolveClassNames("text-muted/60");
const { backgroundColor: idleBorder } = useResolveClassNames("bg-border");
const { backgroundColor: focusBorder } =
useResolveClassNames("bg-foreground/40");
const { backgroundColor: focusRing } =
useResolveClassNames("bg-foreground/10");
const { backgroundColor: errorBorder } =
useResolveClassNames("bg-destructive");
const { backgroundColor: errorRing } =
useResolveClassNames("bg-destructive/25");
const borderColor =
state === "error"
? errorBorder
: state === "focused"
? focusBorder
: idleBorder;
const ringColor = state === "error" ? errorRing : focusRing;
const border = useSharedValue(String(borderColor ?? "transparent"));
const ring = useSharedValue(state === "idle" ? 0 : 1);
const { shake, style: shakeStyle } = useShake(SHAKE, 450);
useEffect(() => {
const next = String(borderColor ?? "transparent");
border.value = animate ? withTiming(next, COLOR_FADE) : next;
const target = state === "idle" ? 0 : 1;
ring.value = animate ? withTiming(target, COLOR_FADE) : target;
}, [animate, border, borderColor, ring, state]);
// Shake and announce when an error appears, not while it stays.
useEffect(() => {
if (!hasError) return;
if (animate) shake();
}, [animate, hasError, shake]);
useEffect(() => {
if (errorMessage) AccessibilityInfo.announceForAccessibility(errorMessage);
}, [errorMessage]);
const messages = usePresence(
errorMessage,
errorMessage ?? "",
() => MESSAGE_FADE.duration + 40,
);
const fieldStyle = useAnimatedStyle(() => ({ borderColor: border.value }));
const ringStyle = useAnimatedStyle(() => ({ opacity: ring.value }));
const trailing = success ? null : rightIcon;
const iconProps = { size: ICON, color: muted };
return (
<View className={`gap-1.5 ${className} ${classNames?.root ?? ""}`}>
{label ? (
<Text
// The field already carries the label for screen readers.
accessibilityElementsHidden
importantForAccessibility="no"
className={`px-1 text-sm font-medium text-foreground ${classNames?.label ?? ""}`}
onPress={() => inputRef.current?.focus()}
>
{label}
</Text>
) : null}
<Animated.View
style={[styles.fieldWrap, disabled && styles.disabled, shakeStyle]}
>
<Animated.View
style={[styles.ring, { borderColor: ringColor }, ringStyle]}
/>
<Animated.View
className={`flex-row items-center overflow-hidden rounded-full border ${classNames?.field ?? ""}`}
style={[styles.field, fieldStyle]}
>
{leftIcon != null ? (
<View
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
aria-hidden
style={styles.leading}
>
{renderIcon(leftIcon, iconProps)}
</View>
) : null}
<TextInput
{...props}
ref={(node) => {
inputRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
value={value}
editable={!disabled}
accessibilityLabel={accessibilityLabel ?? label}
accessibilityHint={accessibilityHint ?? errorMessage ?? undefined}
aria-invalid={hasError}
aria-disabled={disabled}
placeholderTextColor={placeholder}
cursorColor={foreground}
selectionColor={foreground}
className={`h-full flex-1 text-foreground ${leftIcon != null ? "ps-10" : "ps-3.5"} ${trailing != null || success ? "pe-10" : "pe-3.5"} ${classNames?.input ?? ""}`}
style={[styles.input, style]}
keyboardAppearance={keyboardAppearance ?? theme}
onChangeText={(next) => {
if (valueProp === undefined) setUncontrolled(next);
onChangeText?.(next);
}}
onFocus={(event) => {
setFocused(true);
onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
onBlur?.(event);
}}
/>
{success ? (
<SuccessCheck reduce={!animate} />
) : trailing != null ? (
<View style={styles.trailingControl}>
{renderIcon(trailing, iconProps)}
</View>
) : null}
</Animated.View>
</Animated.View>
<View style={reserveErrorLine && styles.reserved}>
{messages.map((item) =>
item.value ? (
<ErrorMessage
key={item.key}
message={item.value}
appear={item.appear}
exiting={item.exiting}
reduce={!animate}
className={classNames?.errorMessage ?? ""}
/>
) : null,
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
fieldWrap: { height: FIELD_HEIGHT },
disabled: { opacity: 0.6 },
// A 2-point ring around the border box, like the web's ring-2.
ring: {
position: "absolute",
top: -2,
bottom: -2,
left: -2,
right: -2,
borderRadius: FIELD_HEIGHT,
borderWidth: 2,
pointerEvents: "none",
},
field: { height: FIELD_HEIGHT },
input: {
// A font size without a line height: a TextInput line height pushes the
// text and placeholder off center on iOS.
fontSize: 16,
paddingVertical: 0,
// Android centers text vertically only when asked, and adds font padding.
textAlignVertical: "center",
includeFontPadding: false,
// Web inputs draw a focus outline; the field's border and ring replace it.
// Chrome draws an "auto" outline whatever its width, so the style has to
// go. React Native's types don't list "none", but React Native Web passes
// it through.
...Platform.select({
web: { outlineStyle: "none" as "solid" },
default: {},
}),
},
leading: {
position: "absolute",
start: 12,
pointerEvents: "none",
},
trailing: {
position: "absolute",
end: 14,
pointerEvents: "none",
},
// A 44-point square keeps trailing controls such as a password toggle tappable.
trailingControl: {
position: "absolute",
end: 0,
top: 0,
bottom: 0,
width: 44,
alignItems: "center",
justifyContent: "center",
},
reserved: { minHeight: 16 },
exiting: { position: "absolute", left: 0, right: 0, top: 0 },
});
Documentation
Native port of beUI Input. A text field with a label, leading and trailing icons, an error shake, and a check that draws itself on success.
import { Input } from "./src";
<Input
label="Email"
leftIcon={Mail}
value={email}
onChangeText={setEmail}
error={valid ? undefined : "Enter a valid email address."}
reserveErrorLine
/>
<Input label="Search" leftIcon={Search} success={found} />
<Input label="Password" secureTextEntry rightIcon={<ToggleButton />} />- Field: a 44-point, fully rounded field. Its border fades between idle, focused and error colours over 200 ms. Focus and error also show a 2-point ring, like the web's
ring-2. - Error: a truthy
errorshakes the field (6, 4 and 2 points over 450 ms) and turns it red. A stringerroralso shows a message that fades and drops in, and is announced to screen readers.reserveErrorLinekeeps one line of space so content below doesn't jump. - Success:
successdraws a check at the trailing edge in place ofrightIcon. - Icons:
leftIconandrightIcontake a component given{ size, color }(such as a lucide icon) or a prebuilt element. A trailing control, such as a show-password button, sits in a 44-point square so it stays easy to tap. - State: pass
valuewithonChangeTextto control it, ordefaultValueto let it manage itself. Every otherTextInputprop passes through, andrefreaches theTextInput. - Accessibility: the label is the field's spoken name, and pressing the label focuses the field. The error message is read as the hint and reported as invalid.
- Reduced motion: when the system asks for it, or
disableAnimationis set, colours change instantly, nothing shakes, and the check appears fully drawn.
classNames styles the root, label, field, input and error message individually.
Keyboard
- Theme: the keyboard follows the app's light or dark theme on iOS. Pass
keyboardAppearanceto override it. - Text: text and placeholder sit centred in the 44-point field on every platform. The field sets a font size without a line height, because a line height on a
TextInputpushes the text down on iOS. - Moving between fields:
refreaches theTextInput, so setreturnKeyType="next",submitBehavior="submit"andonSubmitEditing={() => nextRef.current?.focus()}. - Autofill: pass
autoCompleteandtextContentTypeso iOS and Android can fill passwords, emails and one-time codes. - Staying above the keyboard: that's the screen's job, not the field's. Since Android became edge-to-edge, the window no longer resizes when the keyboard opens, so use react-native-keyboard-controller: wrap the app in
KeyboardProvider, put forms in aKeyboardAwareScrollViewwith abottomOffset, and addKeyboardToolbarfor previous, next and done controls. The example app does exactly this.
Copy this directory, hooks/use-presence.ts, hooks/use-reduced-motion.ts, hooks/use-shake.ts, primitives/drawn-path.tsx, primitives/render-icon.tsx, styles/motion.ts, and the semantic tokens in styles/theme.css.