Collapsed glass disc that stretches into a full search field on tap — the Safari/Maps morph
components/ui/glass-search-morph/glass-search-morph.tsx@lumioui/core@latestreact-native-reanimated@*| value? | string | |
| onChangeText? | (text: string) => void | |
| placeholder? | string | |
| expandedWidth? | number | Width of the expanded field in pt. Default 280. |
| expanded? | boolean | Controlled expansion — uncontrolled when omitted. |
| defaultExpanded? | boolean | |
| onExpandedChange? | (expanded: boolean) => void | |
| className? | string |
Collapsed glass disc that stretches into a full search field on tap — the Safari/Maps morph
import { GlassSearchMorph } from "@/components/ui/glass-search-morph/glass-search-morph";
export function Example() {
return (
<GlassSearchMorph />
);
} import * as React from "react";
import { forwardRef } from "react";
import { View, type ViewProps } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { cn, spring, useHaptic, useReducedMotion } from "@lumioui/core";
import { Glass } from "../glass";
import { Icon } from "../icon";
import { SearchInput } from "../search-input";
import { AnimatedPressable } from "@lumioui/core";
/**
* GlassSearchMorph — the collapsed glass circle that stretches into a full
* search field on tap (Safari/Maps idiom). The disc widens with a glide
* spring while the input crossfades in; tapping close pours it back.
*
* <GlassSearchMorph value={q} onChangeText={setQ} placeholder="Search" />
* Expo Go compatible.
*/
export interface GlassSearchMorphProps extends Omit<ViewProps, "className"> {
value?: string;
onChangeText?: (text: string) => void;
placeholder?: string;
/** Width of the expanded field in pt. Default 280. */
expandedWidth?: number;
/** Controlled expansion — uncontrolled when omitted. */
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
className?: string;
}
const DISC = 48;
export const GlassSearchMorph = forwardRef<
React.ElementRef<typeof View>,
GlassSearchMorphProps
>(function GlassSearchMorph(
{
value,
onChangeText,
placeholder = "Search",
expandedWidth = 280,
expanded,
defaultExpanded = false,
onExpandedChange,
className,
...props
},
ref
) {
const [internal, setInternal] = React.useState(defaultExpanded);
const isExpanded = expanded ?? internal;
const haptics = useHaptic();
const reduce = useReducedMotion();
const width = useSharedValue(defaultExpanded ? expandedWidth : DISC);
const inputOpacity = useSharedValue(defaultExpanded ? 1 : 0);
const iconOpacity = useSharedValue(defaultExpanded ? 0 : 1);
React.useEffect(() => {
const w = isExpanded ? expandedWidth : DISC;
const io = isExpanded ? 1 : 0;
const ico = isExpanded ? 0 : 1;
if (reduce) {
width.value = w;
inputOpacity.value = io;
iconOpacity.value = ico;
} else {
width.value = withSpring(w, spring.glide);
// field fades in late — it pours into the widened disc
inputOpacity.value = withSpring(io, spring.glide);
iconOpacity.value = withSpring(ico, spring.press);
}
}, [isExpanded, expandedWidth, reduce, width, inputOpacity, iconOpacity]);
const setExpanded = (v: boolean) => {
haptics.selection();
if (expanded === undefined) setInternal(v);
onExpandedChange?.(v);
};
const boxStyle = useAnimatedStyle(() => ({ width: width.value }));
const inputStyle = useAnimatedStyle(() => ({ opacity: inputOpacity.value }));
const iconStyle = useAnimatedStyle(() => ({ opacity: iconOpacity.value }));
return (
<Animated.View
ref={ref}
style={boxStyle}
className={cn("overflow-hidden rounded-full", className)}
{...props}
>
<Glass
effect="regular"
interactive={!isExpanded}
className="h-12 w-full flex-row items-center rounded-full"
>
<AnimatedPressable
onPress={() => !isExpanded && setExpanded(true)}
accessibilityRole="button"
accessibilityLabel="Open search"
accessibilityState={{ expanded: isExpanded }}
className={cn("h-12 w-12 items-center justify-center", isExpanded && "hidden")}
style={isExpanded ? { display: "none" } : undefined}
>
<Animated.View style={iconStyle}>
<Icon name="search" size="md" color="foreground" />
</Animated.View>
</AnimatedPressable>
{isExpanded ? (
<Animated.View style={inputStyle} className="flex-1 flex-row items-center pr-1">
<SearchInput
autoFocus
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
className="flex-1 border-0 bg-transparent"
/>
<AnimatedPressable
onPress={() => setExpanded(false)}
accessibilityRole="button"
accessibilityLabel="Close search"
className="h-10 w-10 items-center justify-center"
>
<Icon name="x" size="sm" color="mutedForeground" />
</AnimatedPressable>
</Animated.View>
) : null}
</Glass>
</Animated.View>
);
});
GlassSearchMorph.displayName = "GlassSearchMorph";