Search field with clear button, debounce, focus halo — plus a glass capsule mode that floats over content like iOS 26
Live preview — the real component, driven by the props below.
import { SearchInput } from "@/components/ui/search-input/search-input";
export function Example() {
return (
<SearchInput />
);
} import * as React from "react";
import { forwardRef, useEffect, useRef, useState } from "react";
import { TextInput, View, Text, Pressable, type TextInputProps, } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
interpolateColor,
} from "react-native-reanimated";
import { cn, useHaptic, useTheme, usePressAnimation, useReducedMotion, spring, timing, AnimatedTextInput } from "@lumioui/core";
import { Icon } from "../icon";
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. */
const HALO_OPACITY = 0.35;
/** Clear button — pops in when text appears, dips when pressed. */
function ClearButton({ onPress }: { onPress: () => void }) {
const reduceMotion = useReducedMotion();
// Mount pop — scale/fade in so the button doesn't teleport into the field.
const appear = useSharedValue(0);
const press = usePressAnimation({ pressedScale: 0.85, pressedOpacity: 0.5 });
React.useEffect(() => {
// spring.pop — mount pop for a small element appearing on screen.
appear.value = reduceMotion ? 1 : withSpring(1, spring.pop);
}, [appear, reduceMotion]);
const appearStyle = useAnimatedStyle(() => ({
opacity: appear.value,
transform: [{ scale: appear.value }],
}));
return (
<Pressable
onPress={onPress}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
className="h-8 w-8 items-center justify-center"
accessibilityRole="button"
accessibilityLabel="Clear search"
// 32px visual + 8px/side hitSlop = 48pt ≥ 44pt touch target.
hitSlop={{ top: 8, left: 8, right: 8, bottom: 8 }}
>
<Animated.View style={appearStyle}>
<Animated.View style={press.animatedStyle}>
<Icon name="x" size="sm" color="mutedForeground" />
</Animated.View>
</Animated.View>
</Pressable>
);
}
export interface SearchInputProps extends Omit<TextInputProps, "className"> {
/** Visible label above the field. */
label?: string;
/** Helper text below the field. */
helper?: string;
/** Error message. */
error?: string;
/** Debounce duration in ms for onChangeText. */
debounceMs?: number;
/** Called when the clear button is pressed. */
onClear?: () => void;
/** Leading icon slot. */
icon?: React.ReactNode;
/** Classes for the input element. */
className?: string;
/** Classes for the outer wrapper. */
containerClassName?: string;
/** iOS 26 capsule mode — the field becomes a floating liquid-glass pill
* (search rides over content instead of sitting on the surface). */
glass?: boolean;
}
/** SearchInput — input with search icon, clear, and optional debounced text. */
export const SearchInput = forwardRef<React.ElementRef<typeof TextInput>, SearchInputProps>(
function SearchInput(
{
label,
helper,
error,
debounceMs,
onClear,
icon,
className,
containerClassName,
value,
defaultValue,
onChangeText,
editable = true,
returnKeyType = "search",
onFocus,
onBlur,
glass = false,
...props
},
ref
) {
const haptics = useHaptic();
const { colors } = useTheme();
const reduceMotion = useReducedMotion();
// 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`;
/** Focus progression — drives the border color glide and halo ring. */
const focusT = useSharedValue(0);
/** Error shake offset. */
const shakeX = useSharedValue(0);
/** Error/helper text entrance: 0 hidden → 1 visible. */
const msgT = useSharedValue(error || helper ? 1 : 0);
const hadError = useRef(!!error);
const [display, setDisplay] = useState<string>(() => {
const v = value ?? defaultValue;
return typeof v === "string" ? v : "";
});
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (value !== undefined) setDisplay(value);
}, [value]);
// Error → damped shake; message slides in under the field (Input parity).
useEffect(() => {
if (error && !hadError.current) {
if (!reduceMotion) {
shakeX.value = -ERROR_KICK;
shakeX.value = withSpring(0, spring.shake);
}
hadError.current = true;
} else if (!error) {
hadError.current = false;
}
const target = error || helper ? 1 : 0;
msgT.value = reduceMotion ? target : withTiming(target, timing.fast);
}, [error, helper, msgT, shakeX, reduceMotion]);
const fieldStyle = useAnimatedStyle(() => ({
transform: [{ translateX: shakeX.value }],
borderColor: error
? colors.destructive
: interpolateColor(focusT.value, [0, 1], [colors.input, colors.ring]),
}));
const haloStyle = useAnimatedStyle(() => ({
opacity: focusT.value * HALO_OPACITY,
}));
const msgStyle = useAnimatedStyle(() => ({
opacity: msgT.value,
transform: [{ translateY: (1 - msgT.value) * -4 }],
}));
useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
const emit = (text: string) => {
if (timer.current) clearTimeout(timer.current);
if (debounceMs && debounceMs > 0) {
timer.current = setTimeout(() => {
onChangeText?.(text);
}, debounceMs);
} else {
onChangeText?.(text);
}
};
const handleChangeText = (text: string) => {
setDisplay(text);
emit(text);
};
const handleClear = () => {
haptics.selection();
if (timer.current) clearTimeout(timer.current);
setDisplay("");
onChangeText?.("");
onClear?.();
};
return (
<View className={cn("w-full gap-1.5", containerClassName)}>
{label ? (
<Text
nativeID={labelId}
className="text-sm font-medium text-foreground"
accessibilityRole="text"
>
{label}
</Text>
) : null}
{/* Glass mode: halo wraps the capsule from outside — inside the
field it would clip against the capsule's overflow-hidden. */}
<View className={glass ? "relative" : undefined}>
<FieldSurface glass={glass}>
<Animated.View
style={glass ? { transform: [{ translateX: shakeX.value }] } : fieldStyle}
className={cn(
// Constant border-[1.5px] — width never flickers; the color is
// animated in fieldStyle, not swapped via classes.
"h-12 w-full flex-row items-center border-[1.5px] border-transparent px-3",
glass ? "rounded-full border-0" : "rounded-xl bg-background",
!editable && "opacity-50"
)}
>
{/* Focus halo — floats 3px outside the border, fades with focusT */}
{!glass ? (
<Animated.View
pointerEvents="none"
style={[
{
position: "absolute",
top: -3,
left: -3,
right: -3,
bottom: -3,
borderRadius: 15,
borderWidth: 2,
borderColor: colors.ring,
},
haloStyle,
]}
/>
) : null}
{icon ?? (
<Icon name="search" size="sm" color="mutedForeground" />
)}
{/* AnimatedTextInput is the shared className-registered primitive —
a drop-in TextInput accepting the same props + className. */}
<AnimatedTextInput
ref={ref}
value={display}
editable={editable}
returnKeyType={returnKeyType}
placeholderTextColor={colors.mutedForeground}
onFocus={(e) => {
focusT.value = reduceMotion ? 1 : withTiming(1, timing.fast);
onFocus?.(e);
}}
onBlur={(e) => {
focusT.value = reduceMotion ? 0 : withTiming(0, timing.fast);
onBlur?.(e);
}}
onChangeText={handleChangeText}
className={cn(
"flex-1 px-2 text-base text-foreground",
"placeholder:text-muted-foreground",
className
)}
accessibilityRole="search"
accessibilityLabel={label ?? props.accessibilityLabel}
accessibilityLabelledBy={label ? labelId : undefined}
aria-labelledby={label ? labelId : undefined}
// Hint is helper only — errors are announced by the live
// region below, so repeating them here double-announces.
accessibilityHint={helper}
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}
/>
{display.length > 0 ? <ClearButton onPress={handleClear} /> : null}
</Animated.View>
</FieldSurface>
{glass ? (
<Animated.View
pointerEvents="none"
style={[
{
position: "absolute",
top: -3,
left: -3,
right: -3,
bottom: -3,
borderRadius: 27,
borderWidth: 2,
borderColor: colors.ring,
},
haloStyle,
]}
/>
) : null}
</View>
{error || helper ? (
<Animated.View style={msgStyle}>
{error ? (
<Text
className="text-sm text-destructive"
accessibilityLiveRegion="polite"
accessibilityRole="alert"
>
{error}
</Text>
) : (
<Text className="text-sm text-muted-foreground">{helper}</Text>
)}
</Animated.View>
) : null}
</View>
);
}
);
SearchInput.displayName = "SearchInput";
/** Field surface — glass capsule or plain card, keeping the field row
* identical underneath so focus/halo/shake behavior is shared. */
function FieldSurface({
glass,
children,
}: {
glass?: boolean;
children: React.ReactNode;
}) {
if (!glass) return <>{children}</>;
return (
<Glass effect="regular" className="h-12 w-full rounded-full">
{children}
</Glass>
);
}
| label? | string | Visible label above the field. | |
| helper? | string | Helper text below the field. | |
| error? | string | Error message. | |
| debounceMs? | number | Debounce duration in ms for onChangeText. | |
| onClear? | () => void | Called when the clear button is pressed. | |
| icon? | React.ReactNode | Leading icon slot. | |
| className? | string | Classes for the input element. | |
| containerClassName? | string | Classes for the outer wrapper. | |
| glass? | boolean | iOS 26 capsule mode — the field becomes a floating liquid-glass pill (search rides over content instead of sitting on the surface). |
Search field with clear button, debounce, focus halo — plus a glass capsule mode that floats over content like iOS 26
import { SearchDisabled } from "@/components/ui/search-input/variants/disabled";
export function Example() {
return (
<SearchDisabled />
);
} import * as React from "react";
import { View } from "react-native";
import { SearchInput } from "../search-input";
import { Glass } from "../../glass/glass";
/** SearchDisabled — non-editable search field state. */
export function SearchDisabled() {
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<SearchInput
glass
label="Search orders"
placeholder="Search name, order, item…"
editable={false}
helper="Search is unavailable while offline"
className="w-full"
/>
</Glass>
</View>
);
}
Search field with clear button, debounce, focus halo — plus a glass capsule mode that floats over content like iOS 26
import { SearchInputGlass } from "@/components/ui/search-input/variants/glass";
export function Example() {
return (
<SearchInputGlass />
);
} import * as React from "react";
import { View } from "react-native";
import { SearchInput } from "../search-input";
import { Ambient } from "../../glass/ambient";
/** SearchInputGlass — the capsule floats over the map/photo canvas. */
export function SearchInputGlass() {
const [q, setQ] = React.useState("");
return (
<View className="relative h-[420px] w-full overflow-hidden rounded-3xl">
<Ambient seed="map" />
<View className="absolute left-4 right-4 top-5">
<SearchInput
glass
value={q}
onChangeText={setQ}
placeholder="Search places"
accessibilityLabel="Search places"
/>
</View>
</View>
);
}
Search field with clear button, debounce, focus halo — plus a glass capsule mode that floats over content like iOS 26
import { SearchOrders } from "@/components/ui/search-input/variants/orders";
export function Example() {
return (
<SearchOrders />
);
} import * as React from "react";
import { View } from "react-native";
import { SearchInput } from "../search-input";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** SearchOrders — live-filtered order list under the field. */
export function SearchOrders() {
const [query, setQuery] = React.useState("");
const orders = [
{ id: "#8421", name: "Amara Chen", detail: "Tonkotsu Ramen × 2 · $29.00" },
{ id: "#8420", name: "Diego Ruiz", detail: "Transfer to Porto · $4,280.50" },
{ id: "#8419", name: "Priya Nair", detail: "Miso Ramen × 1 · $13.50" },
];
const q = query.trim().toLowerCase();
const results = q
? orders.filter(
(o) =>
o.name.toLowerCase().includes(q) ||
o.id.toLowerCase().includes(q) ||
o.detail.toLowerCase().includes(q)
)
: orders;
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<SearchInput
glass
label="Search orders"
placeholder="Search name, order, item…"
value={query}
onChangeText={setQuery}
helper={`${results.length} of ${orders.length} orders`}
className="w-full"
/>
<Glass interactive curvature="edge" className="gap-1 rounded-xl p-2">
{results.map((o) => (
<View key={o.id} className="gap-0.5 rounded-lg px-2 py-2">
<Text variant="small">{o.id} · {o.name}</Text>
<Text variant="muted">{o.detail}</Text>
</View>
))}
{results.length === 0 ? (
<Text variant="muted" className="px-2 py-2">No orders match “{query}”.</Text>
) : null}
</Glass>
</Glass>
</View>
);
}