Multiline field with auto-grow and character count.
components/ui/textarea/textarea.tsx@lumioui/core@latestreact-native-reanimated@*Live preview — the real component, driven by the props below.
import { Textarea } from "@/components/ui/textarea/textarea";
export function Example() {
return (
<Textarea />
);
} import * as React from "react";
import { forwardRef } from "react";
import { TextInput, View, Text, type TextInputProps, type NativeSyntheticEvent, type TextInputContentSizeChangeEventData, } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
interpolateColor,
} from "react-native-reanimated";
import { cn, spring, timing, useReducedMotion, useTheme, AnimatedTextInput } from "@lumioui/core";
import { Glass } from "../glass";
/** Error shake — kick + spring.shake's underdamped settle, same
* choreography as Input. */
const ERROR_KICK = 10;
/** Focus halo — a soft 2px ring just outside the border, iOS-style. */
const HALO_OPACITY = 0.35;
export interface TextareaProps extends Omit<TextInputProps, "className" | "multiline"> {
/** Visible label above the field. */
label?: string;
/** Helper text below the field. */
helper?: string;
/** Error message — turns the field red. */
error?: string;
/** Minimum height in points. Default 96. */
minHeight?: number;
/** Grow with content. */
autoGrow?: boolean;
/** Show a live character count under the field ("n" or "n/max" with maxLength). */
showCount?: boolean;
/** NativeWind classes for the input. */
className?: string;
/** Liquid-glass field — rounded material instead of the flat fill. */
glass?: boolean;
/** Classes for the outer wrapper. */
containerClassName?: string;
multiline?: boolean;
}
/** Textarea — multiline Input with minHeight and optional auto-grow. */
export const Textarea = forwardRef<React.ElementRef<typeof TextInput>, TextareaProps>(
function Textarea(
{
label,
helper,
error,
minHeight = 96,
autoGrow = false,
showCount = false,
className,
containerClassName,
glass = false,
multiline = true,
onContentSizeChange,
onChangeText,
value,
defaultValue,
maxLength,
editable = true,
onFocus,
onBlur,
style,
...props
},
ref
) {
const { colors } = useTheme();
const reduceMotion = useReducedMotion();
const describedBy = error ?? helper;
// Built-in label gets a stable nativeID so the field can point back at
// it via accessibilityLabelledBy / aria-labelledby (Input parity).
const autoId = React.useId();
const labelId = `${autoId}-label`;
// Character count tracks typing; controlled `value` changes resync it.
const [textLen, setTextLen] = React.useState(() => (value ?? defaultValue ?? "").length);
// Auto-grow animates instead of jumping — the field eases taller.
const contentHeight = useSharedValue(minHeight);
// Error shake — same craft as Input.
const shake = useSharedValue(0);
/** Focus progression — drives the border color glide. */
const focusT = useSharedValue(0);
/** Error/helper text entrance: 0 hidden → 1 visible. */
const msgT = useSharedValue(error || helper ? 1 : 0);
const hadError = React.useRef(!!error);
React.useEffect(() => {
if (error && !hadError.current) {
if (!reduceMotion) {
// Kick sideways, then let the underdamped spring oscillate back
// to rest — a true damped shake, not keyframed steps.
shake.value = -ERROR_KICK;
shake.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 = reduceMotion ? target : withTiming(target, timing.fast);
}, [error, helper, reduceMotion, shake, msgT]);
const shakeStyle = useAnimatedStyle(() => ({
transform: [{ translateX: shake.value }],
}));
const heightStyle = useAnimatedStyle(() =>
autoGrow ? { height: Math.max(minHeight, contentHeight.value) } : {}
);
// Border color glides input → ring on focus; error wins outright.
const borderStyle = useAnimatedStyle(() => ({
borderColor: error
? colors.destructive
: interpolateColor(focusT.value, [0, 1], [colors.input, colors.ring]),
}));
// Focus halo — a faint ring floating just outside the field border
// (same treatment as Input).
const haloStyle = useAnimatedStyle(() => ({
opacity: focusT.value * HALO_OPACITY,
}));
const msgStyle = useAnimatedStyle(() => ({
opacity: msgT.value,
transform: [{ translateY: (1 - msgT.value) * -4 }],
}));
const handleContentSizeChange = (
e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>
) => {
if (autoGrow) {
const next = e.nativeEvent.contentSize.height;
// Reduced motion: snap to the measured height instead of easing.
// timing.fast (150ms) tracks typing without jumping.
if (reduceMotion) contentHeight.value = next;
else contentHeight.value = withTiming(next, timing.fast);
}
onContentSizeChange?.(e);
};
const handleChangeText = (text: string) => {
setTextLen(text.length);
onChangeText?.(text);
};
// Controlled `value` changes (set externally, e.g. cleared) resync the count.
React.useEffect(() => {
if (typeof value === "string") setTextLen(value.length);
}, [value]);
return (
<View className={cn("gap-1.5", containerClassName)}>
{label ? (
<Text
nativeID={labelId}
className="text-sm font-medium text-foreground"
accessibilityRole="text"
>
{label}
</Text>
) : null}
<Animated.View style={shakeStyle}>
{glass ? (
<Glass
effect="regular"
sheen={0.4}
className="absolute inset-0"
style={{ borderRadius: 16 }}
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,
]}
/>
)}
<AnimatedTextInput
ref={ref}
editable={editable}
multiline={multiline}
onFocus={(e) => {
focusT.value = reduceMotion ? 1 : withTiming(1, timing.fast);
onFocus?.(e);
}}
onBlur={(e) => {
focusT.value = reduceMotion ? 0 : withTiming(0, timing.fast);
onBlur?.(e);
}}
onContentSizeChange={handleContentSizeChange}
onChangeText={handleChangeText}
value={value}
defaultValue={defaultValue}
maxLength={maxLength}
className={cn(
// Constant border-[1.5px] — width never flickers; the color is
// animated in borderStyle, not swapped via classes.
glass
? "min-h-[96px] rounded-2xl border-[1.5px] border-transparent bg-transparent px-3.5 py-3 text-base text-foreground"
: "min-h-[96px] rounded-xl border-[1.5px] border-transparent bg-background px-3.5 py-3 text-base text-foreground",
"placeholder:text-muted-foreground",
!editable && "opacity-50",
className
)}
style={[
{
minHeight,
textAlignVertical: "top",
},
heightStyle,
borderStyle,
style,
]}
accessibilityLabel={label ?? props.accessibilityLabel}
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}
/>
</Animated.View>
{error || helper || showCount ? (
<Animated.View
style={error || helper ? msgStyle : undefined}
className="flex-row items-start justify-between gap-2"
>
{error ? (
<Text
className="flex-1 text-sm text-destructive"
accessibilityLiveRegion="polite"
accessibilityRole="alert"
>
{error}
</Text>
) : helper ? (
<Text className="flex-1 text-sm text-muted-foreground">{helper}</Text>
) : (
<View className="flex-1" />
)}
{showCount ? (
<Text
className={cn(
"text-sm",
maxLength != null && textLen >= maxLength
? "text-destructive"
: "text-muted-foreground"
)}
accessibilityLabel={
maxLength != null
? `${textLen} of ${maxLength} characters`
: `${textLen} characters`
}
>
{maxLength != null ? `${textLen}/${maxLength}` : textLen}
</Text>
) : null}
</Animated.View>
) : null}
</View>
);
}
);
Textarea.displayName = "Textarea";
| label? | string | Visible label above the field. | |
| helper? | string | Helper text below the field. | |
| error? | string | Error message — turns the field red. | |
| minHeight? | number | Minimum height in points. Default 96. | |
| autoGrow? | boolean | Grow with content. | |
| showCount? | boolean | Show a live character count under the field ("n" or "n/max" with maxLength). | |
| className? | string | NativeWind classes for the input. | |
| glass? | boolean | Liquid-glass field — rounded material instead of the flat fill. | |
| containerClassName? | string | Classes for the outer wrapper. | |
| multiline? | boolean |
Multiline field with auto-grow and character count.
import { TextareaError } from "@/components/ui/textarea/variants/error";
export function Example() {
return (
<TextareaError />
);
} import * as React from "react";
import { View } from "react-native";
import { Textarea } from "../textarea";
import { Glass } from "../../glass/glass";
/** TextareaError — glass field-level error with message slot. */
export function TextareaError() {
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full rounded-2xl p-4">
<Textarea
glass
label="Message to Priya"
placeholder="Say thanks…"
defaultValue="thx"
error="Too short — add at least a few words."
className="w-full"
/>
</Glass>
</View>
);
}
Multiline field with auto-grow and character count.
import { TextareaGlass } from "@/components/ui/textarea/variants/glass";
export function Example() {
return (
<TextareaGlass />
);
} import * as React from "react";
import { View, Text } from "react-native";
import { Textarea } from "../textarea";
import { Ambient } from "../../glass/ambient";
/** TextareaGlass — a rounded glass field for long-form notes over a cafe photo. */
export function TextareaGlass() {
return (
<View className="relative h-[420px] w-full overflow-hidden rounded-3xl">
<Ambient seed="text" />
<View className="absolute inset-x-5 top-8">
<Textarea
glass
placeholder="Write the story of this place…"
helper="Drafts save automatically"
/>
</View>
<View className="absolute bottom-5 left-5">
<Text className="text-[10px] font-semibold uppercase tracking-widest text-white/90">
GLASS FIELD
</Text>
</View>
</View>
);
}
Multiline field with auto-grow and character count.
import { TextareaNote } from "@/components/ui/textarea/variants/note";
export function Example() {
return (
<TextareaNote />
);
} import * as React from "react";
import { View } from "react-native";
import { Textarea } from "../textarea";
import { Glass } from "../../glass/glass";
import { Icon } from "../../icon/icon";
import { Text } from "../../text/text";
/** TextareaNote — auto-growing glass delivery notes field inside an order card. */
export function TextareaNote() {
const [note, setNote] = React.useState("Extra noodles, soft egg — bell Diego on arrival.");
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<View className="flex-row items-center gap-2">
<Icon name="map-pin" size="sm" color="mutedForeground" />
<View className="flex-1">
<Text variant="small" className="font-semibold">Sakura Ramen · Order #8421</Text>
<Text variant="muted" className="text-xs">Deliver to 14 Rua das Flores</Text>
</View>
</View>
<Textarea
glass
label="Delivery instructions"
placeholder="Gate code, floor, drop-off notes…"
value={note}
onChangeText={setNote}
helper={`${note.length}/200 · driver sees this at checkout`}
autoGrow
className="w-full"
/>
</Glass>
</View>
);
}