Labeled field with focus ring, error shake and success check.
components/ui/input/input.tsx@lumioui/core@latestreact-native-reanimated@*react-native-svg@*Live preview — the real component, driven by the props below.
import { Input } from "@/components/ui/input/input";
export function Example() {
return (
<Input />
);
} import * as React from "react";
import { forwardRef, useEffect, useId, useRef } from "react";
import { TextInput, View, Text, type TextInputProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
useAnimatedProps,
withSpring,
withTiming,
interpolateColor,
} from "react-native-reanimated";
import Svg from "react-native-svg";
import { cn, spring, timing, useReducedMotion, useTheme, AnimatedPath, AnimatedTextInput } from "@lumioui/core";
import { Glass } from "../glass";
const SUCCESS_PATH = "M5 13l4 4L19 7";
/** Upper bound of the check path length — used for the dasharray trick. */
const CHECK_LEN = 24;
/** Error shake — a physical damped oscillation: the field is kicked sideways
* and spring.shake's underdamped settle brings it back (~3 decaying bounces,
* ~500ms). Reads far more organic than keyframed left-right steps. */
const ERROR_KICK = 10;
/** Focus halo — a soft 2px ring just outside the border, iOS-style. */
const HALO_OPACITY = 0.35;
export interface InputProps extends Omit<TextInputProps, "className"> {
/** Visible label above the field. */
label?: string;
/** Helper text below the field. */
helper?: string;
/** Error message — turns the field red, shakes it, and announces to screen readers. */
error?: string;
/** Shows an animated success check on the right edge. */
success?: boolean;
/** Icon node rendered inside the left edge of the field. */
leftIcon?: React.ReactNode;
/** Icon node rendered inside the right edge (hidden while the success check shows). */
rightIcon?: React.ReactNode;
/** Reserve one helper line even when empty — prevents layout shift on error. */
reserveHelperLine?: boolean;
/** Field density — "sm" for toolbars and dense forms, "md" (default) for
* full-size fields. */
size?: "sm" | "md";
/** NativeWind classes for the input element. */
className?: string;
/** Classes for the outer wrapper. */
containerClassName?: string;
/** Liquid-glass field — capsule material instead of the flat fill. */
glass?: boolean;
}
/**
* Input — label + helper + error states, error shake, animated success check,
* icon slots, focus ring. Minimum 44pt/48dp height per platform guidelines.
*/
export const Input = forwardRef<React.ElementRef<typeof TextInput>, InputProps>(
function Input(
{
label,
helper,
error,
success = false,
leftIcon,
rightIcon,
size = "md",
reserveHelperLine = false,
className,
containerClassName,
glass = false,
editable = true,
onFocus,
onBlur,
style,
...props
},
ref
) {
const { colors } = useTheme();
const reducedMotion = useReducedMotion();
const describedBy = error ?? helper;
// Built-in label gets a stable nativeID so the field can point back at
// it via accessibilityLabelledBy (Android) / aria-labelledby (web) —
// the same path Form.Label uses when wired through nativeID.
const autoId = useId();
const labelId = `${autoId}-label`;
const translateX = useSharedValue(0);
/** Focus progression — drives the border color glide and halo ring. */
const focusT = useSharedValue(0);
/** Error/helper text entrance: 0 hidden → 1 visible. */
const msgT = useSharedValue(error || helper ? 1 : 0);
/** Success check draw progress. */
const checkDraw = useSharedValue(success ? 1 : 0);
const hadError = useRef(!!error);
const showSuccess = success && !error;
useEffect(() => {
if (error && !hadError.current) {
if (!reducedMotion) {
// Kick sideways, then let the underdamped spring oscillate back
// to rest — a true damped shake, not keyframed steps.
translateX.value = -ERROR_KICK;
translateX.value = withSpring(0, spring.shake);
}
hadError.current = true;
} else if (!error) {
hadError.current = false;
}
const target = error || helper ? 1 : 0;
// timing.fast (150ms): message arrives quick enough to not flash.
msgT.value = reducedMotion ? target : withTiming(target, timing.fast);
}, [error, helper, msgT, translateX, reducedMotion]);
useEffect(() => {
const target = success && !error ? 1 : 0;
// timing.normal (250ms): the draw-on registers as a deliberate mark.
checkDraw.value = reducedMotion ? target : withTiming(target, timing.normal);
}, [success, error, checkDraw, reducedMotion]);
const fieldAnim = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
// Border color glides input → ring on focus; error/success win outright.
// Lives on the input itself (the shake wrapper has no border).
const borderStyle = useAnimatedStyle(() => ({
borderColor: error
? colors.destructive
: showSuccess
? colors.success
: interpolateColor(focusT.value, [0, 1], [colors.input, colors.ring]),
}));
// Focus halo — a faint ring floating just outside the field border.
const haloStyle = useAnimatedStyle(() => ({
opacity: focusT.value * HALO_OPACITY,
}));
const msgStyle = useAnimatedStyle(() => ({
opacity: msgT.value,
transform: [{ translateY: (1 - msgT.value) * -4 }],
}));
const checkProps = useAnimatedProps(() => ({
strokeDashoffset: (1 - checkDraw.value) * CHECK_LEN,
}));
const showRight = !!rightIcon || showSuccess;
const message = error ?? helper;
const showLine = !!message || reserveHelperLine;
return (
<View className={cn("gap-1.5", containerClassName)}>
{label ? (
<Text
nativeID={labelId}
className="text-sm font-medium text-foreground"
accessibilityRole="text"
>
{label}
</Text>
) : null}
{/* Disabled dim goes on the field wrapper — on web the input's own
opacity-50 loses to a placeholder-shown preflight rule. */}
<Animated.View
style={fieldAnim}
className={cn("justify-center", glass ? "rounded-full" : "rounded-xl", !editable && "opacity-50")}
>
{glass ? (
<Glass
effect="regular"
className="absolute inset-0 rounded-full"
pointerEvents="none"
/>
) : null}
{/* Focus halo — floats 3px outside the border, fades with focusT.
Skipped on glass fields: the material's own rim plus this ring
reads as a double outline. */}
{glass ? null : (
<Animated.View
pointerEvents="none"
style={[
{
position: "absolute",
top: -3,
left: -3,
right: -3,
bottom: -3,
borderRadius: 15,
borderWidth: 2,
borderColor: colors.ring,
},
haloStyle,
]}
/>
)}
{leftIcon ? (
<View className="absolute left-3 top-0 h-full items-center justify-center z-10" pointerEvents="none">
{leftIcon}
</View>
) : null}
{/* AnimatedTextInput is the shared className-registered primitive —
a drop-in TextInput accepting the same props + className + style. */}
<AnimatedTextInput
ref={ref}
editable={editable}
placeholderTextColor={colors.mutedForeground}
style={[borderStyle, style]}
onFocus={(e) => {
focusT.value = reducedMotion ? 1 : withTiming(1, timing.fast);
onFocus?.(e);
}}
onBlur={(e) => {
focusT.value = reducedMotion ? 0 : withTiming(0, timing.fast);
onBlur?.(e);
}}
className={cn(
// 48px = 44pt+ touch target; px-3.5 aligns text to 14px grid.
// Constant border-[1.5px] in every state — width never flickers;
// the color itself is animated (borderColor lives in fieldAnim).
size === "sm"
? glass
? "h-10 rounded-full border-[1.5px] border-transparent bg-transparent px-4 text-sm text-foreground"
: "h-10 rounded-xl border-[1.5px] border-transparent bg-background px-3 text-sm text-foreground"
: glass
? "h-12 rounded-full border-[1.5px] border-transparent bg-transparent px-4 text-base text-foreground"
: "h-12 rounded-xl border-[1.5px] border-transparent bg-background px-3.5 text-base text-foreground",
leftIcon && "pl-10",
showRight && "pr-10",
className
)}
accessibilityLabel={label ?? props.accessibilityLabel}
// Label association — the built-in label's nativeID; callers can
// still override by passing their own accessibilityLabelledBy /
// aria-labelledby through props (spread below wins).
accessibilityLabelledBy={label ? labelId : undefined}
aria-labelledby={label ? labelId : undefined}
accessibilityHint={describedBy}
accessibilityState={{ disabled: !editable }}
// accessibilityState isn't forwarded to the DOM on web — mirror
// the error with an explicit aria-invalid like Checkbox does.
aria-invalid={!!error}
{...props}
/>
{showRight ? (
<View className="absolute right-3 top-0 h-full items-center justify-center" pointerEvents="none">
{showSuccess ? (
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
<AnimatedPath
d={SUCCESS_PATH}
stroke={colors.success}
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={CHECK_LEN}
animatedProps={checkProps}
/>
</Svg>
) : (
rightIcon
)}
</View>
) : null}
</Animated.View>
{showLine ? (
<Animated.View style={[{ minHeight: 20 }, msgStyle]}>
{error ? (
<Text
className="text-sm text-destructive"
accessibilityLiveRegion="polite"
accessibilityRole="alert"
>
{error}
</Text>
) : helper ? (
<Text className="text-sm text-muted-foreground">{helper}</Text>
) : null}
</Animated.View>
) : null}
</View>
);
}
);
Input.displayName = "Input";
| label? | string | Visible label above the field. | |
| helper? | string | Helper text below the field. | |
| error? | string | Error message — turns the field red, shakes it, and announces to screen readers. | |
| success? | boolean | Shows an animated success check on the right edge. | |
| leftIcon? | React.ReactNode | Icon node rendered inside the left edge of the field. | |
| rightIcon? | React.ReactNode | Icon node rendered inside the right edge (hidden while the success check shows). | |
| reserveHelperLine? | boolean | Reserve one helper line even when empty — prevents layout shift on error. | |
| size? | "sm" | "md" | Field density — "sm" for toolbars and dense forms, "md" (default) for full-size fields. | |
| className? | string | NativeWind classes for the input element. | |
| containerClassName? | string | Classes for the outer wrapper. | |
| glass? | boolean | Liquid-glass field — capsule material instead of the flat fill. |
Labeled field with focus ring, error shake and success check.
import { InputCheckout } from "@/components/ui/input/variants/checkout";
export function Example() {
return (
<InputCheckout />
);
} import * as React from "react";
import { View } from "react-native";
import { Input } from "../input";
import { Icon } from "../../icon/icon";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** InputCheckout — labeled fields with icons, helper copy and live validation. */
export function InputCheckout() {
const [name, setName] = React.useState("Amara Chen");
const [email, setEmail] = React.useState("");
const [phone, setPhone] = React.useState("");
const invalid = email.length > 0 && !email.includes("@");
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<Text variant="small" className="font-semibold">Checkout details</Text>
<Input
glass
label="Full name"
placeholder="Amara Chen"
value={name}
onChangeText={setName}
success={name.trim().length > 1}
reserveHelperLine
/>
<Input
glass
label="Email"
placeholder="amara@example.com"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
helper="Receipts and order updates go here."
leftIcon={<Icon name="mail" size="sm" color="mutedForeground" />}
error={invalid ? "Enter a valid email address" : undefined}
success={email.includes("@")}
reserveHelperLine
/>
<Input
glass
label="Phone"
placeholder="(415) 555-0132"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
helper="Courier calls this on arrival."
leftIcon={<Icon name="phone" size="sm" color="mutedForeground" />}
reserveHelperLine
/>
<Input
glass
label="Delivery notes"
placeholder="Gate code, floor, extra chili oil…"
helper="Optional — 140 characters."
/>
<Input glass label="Promo code" placeholder="Unavailable during checkout" editable={false} />
</Glass>
</View>
);
}
Labeled field with focus ring, error shake and success check.
import { InputGlass } from "@/components/ui/input/variants/glass";
export function Example() {
return (
<InputGlass />
);
} import * as React from "react";
import { View, Text } from "react-native";
import { Input } from "../input";
import { Icon } from "../../icon/icon";
import { Ambient } from "../../glass/ambient";
/** InputGlass — a glass capsule field floating over a dusk gradient photo. */
export function InputGlass() {
return (
<View className="relative h-[420px] w-full overflow-hidden rounded-3xl">
<Ambient seed="input" />
<View className="absolute inset-x-5 top-6 gap-3">
<Input
glass
placeholder="Where to next?"
leftIcon={<Icon name="search" size="sm" color="#fff" />}
/>
<Input
glass
placeholder="Add a note"
leftIcon={<Icon name="file-text" size="sm" color="#fff" />}
helper="Visible to your trip group"
/>
</View>
<View className="absolute bottom-5 left-5">
<Text className="text-[10px] font-semibold uppercase tracking-widest text-white/90">
GLASS FIELDS
</Text>
</View>
</View>
);
}