Search Morph
A search button that stretches into a search field, with Cancel sliding in and results rising beneath.
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
Pressable,
StyleSheet,
Text,
TextInput,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type SearchMorphProps = {
value: string;
onChangeText: (value: string) => void;
/** Called from the keyboard's search key. */
onSubmit?: (value: string) => void;
/** Defaults to 'Search'. Also names the button and the field. */
placeholder?: string;
/** Controlled open state. Leave unset to let the component keep its own. */
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Defaults to 'Cancel'. */
cancelLabel?: string;
/** Shown under the field while it is open, e.g. results or recent searches. */
children?: ReactNode;
testID?: string;
style?: StyleProp<ViewStyle>;
};
/** A little over the 44 pt minimum, so pixel rounding never takes it below. */
const SIZE = 48;
const GAP = 12;
/**
* A search button that grows into a search field. The circle stretches
* across the bar with the glass riding its left edge, Cancel slides in beside
* it, and whatever you pass as children rises in underneath.
*/
export function SearchMorph({
value,
onChangeText,
onSubmit,
placeholder = 'Search',
open,
defaultOpen = false,
onOpenChange,
cancelLabel = 'Cancel',
children,
testID,
style,
}: SearchMorphProps) {
const { colors, font, radius, space } = useKinetikTheme();
const reduced = useReduceMotion();
const [inner, setInner] = useState(defaultOpen);
const isOpen = open ?? inner;
const [width, setWidth] = useState(0);
const [cancelW, setCancelW] = useState(0);
const input = useRef<TextInput>(null);
const t = useSharedValue(isOpen ? 1 : 0);
useEffect(() => {
t.set(withSpring(isOpen ? 1 : 0, spring('smooth', reduced)));
if (isOpen) {
// Focus once the field has started to open, so the keyboard does not race the morph.
const id = setTimeout(() => input.current?.focus(), reduced ? 0 : 120);
return () => clearTimeout(id);
}
input.current?.blur();
}, [isOpen, reduced, t]);
const setOpen = (next: boolean) => {
if (open === undefined) setInner(next);
onOpenChange?.(next);
};
const cancel = () => {
onChangeText('');
setOpen(false);
};
const shell = useAnimatedStyle(() => {
const p = t.get();
return {
// Hidden until the bar is measured, so it never flashes at full width.
opacity: width === 0 ? 0 : 1,
left: Math.max(0, (width - SIZE) * (1 - p)),
right: (cancelW + GAP) * Math.max(0, p),
backgroundColor: interpolateColor(p, [0, 1], [colors.surfaceRaised, colors.surface]),
borderColor: interpolateColor(p, [0, 1], [colors.border, colors.borderStrong]),
};
});
const field = useAnimatedStyle(() => ({ opacity: Math.max(0, t.get() * 1.6 - 0.6) }));
const cancelStyle = useAnimatedStyle(() => ({
opacity: Math.max(0, t.get() * 2 - 1),
transform: [{ translateX: (1 - t.get()) * 24 }],
}));
const below = useAnimatedStyle(() => ({
opacity: Math.max(0, t.get() * 2 - 1),
transform: [{ translateY: (1 - t.get()) * -12 }],
}));
return (
<View testID={testID} style={style}>
<View style={styles.bar} onLayout={(e) => setWidth(e.nativeEvent.layout.width)}>
<Animated.View style={[styles.shell, { borderRadius: radius.pill }, shell]}>
<View style={styles.glass} pointerEvents="none">
<View style={[styles.lens, { borderColor: colors.text }]} />
<View style={[styles.handle, { backgroundColor: colors.text }]} />
</View>
<Animated.View
style={[styles.field, field]}
pointerEvents={isOpen ? 'auto' : 'none'}
accessibilityElementsHidden={!isOpen}
importantForAccessibility={isOpen ? 'auto' : 'no-hide-descendants'}
>
<TextInput
ref={input}
value={value}
onChangeText={onChangeText}
onSubmitEditing={() => onSubmit?.(value)}
placeholder={placeholder}
placeholderTextColor={colors.textSubtle}
returnKeyType="search"
autoCorrect={false}
accessibilityLabel={placeholder}
selectionColor={colors.azure}
style={[styles.input, { color: colors.text, fontSize: font.size.md }]}
/>
{value ? (
<Pressable
onPress={() => {
onChangeText('');
input.current?.focus();
}}
accessibilityRole="button"
accessibilityLabel="Clear search"
style={styles.clear}
>
<View style={[styles.clearDot, { backgroundColor: colors.textSubtle }]}>
<View style={[styles.cross, { backgroundColor: colors.surface }]} />
<View
style={[styles.cross, styles.crossTurn, { backgroundColor: colors.surface }]}
/>
</View>
</Pressable>
) : null}
</Animated.View>
{!isOpen ? (
<Pressable
onPress={() => setOpen(true)}
accessibilityRole="button"
accessibilityLabel={placeholder}
style={StyleSheet.absoluteFill}
/>
) : null}
</Animated.View>
<Animated.View
style={[styles.cancel, cancelStyle]}
pointerEvents={isOpen ? 'auto' : 'none'}
accessibilityElementsHidden={!isOpen}
importantForAccessibility={isOpen ? 'auto' : 'no-hide-descendants'}
>
<Pressable
onPress={cancel}
accessibilityRole="button"
onLayout={(e) => setCancelW(e.nativeEvent.layout.width)}
style={({ pressed }) => [styles.cancelButton, { opacity: pressed ? 0.6 : 1 }]}
>
<Text
style={{
color: colors.azure,
fontSize: font.size.md,
fontWeight: font.weight.medium,
}}
>
{cancelLabel}
</Text>
</Pressable>
</Animated.View>
</View>
{children && isOpen ? (
<Animated.View style={[{ marginTop: space[3] }, below]}>{children}</Animated.View>
) : null}
</View>
);
}
const styles = StyleSheet.create({
bar: { height: SIZE, justifyContent: 'center' },
shell: {
position: 'absolute',
top: 0,
bottom: 0,
borderWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
},
// The glass sits in the first 44 pt, so it reads the same closed and open.
glass: { position: 'absolute', left: 0, top: 0, width: SIZE, height: SIZE },
lens: {
position: 'absolute',
left: 16,
top: 15,
width: 13,
height: 13,
borderRadius: 7,
borderWidth: 2,
},
handle: {
position: 'absolute',
left: 26,
top: 27.5,
width: 7,
height: 2,
borderRadius: 1,
transform: [{ rotate: '45deg' }],
},
field: {
position: 'absolute',
left: SIZE - 4,
right: 0,
top: 0,
bottom: 0,
flexDirection: 'row',
alignItems: 'center',
},
input: { flex: 1, height: SIZE, paddingVertical: 0 },
clear: { width: SIZE, height: SIZE, alignItems: 'center', justifyContent: 'center' },
clearDot: {
width: 18,
height: 18,
borderRadius: 9,
alignItems: 'center',
justifyContent: 'center',
},
cross: { position: 'absolute', width: 9, height: 1.5, transform: [{ rotate: '45deg' }] },
crossTurn: { transform: [{ rotate: '-45deg' }] },
cancel: { position: 'absolute', right: 0, top: 0, bottom: 0, justifyContent: 'center' },
cancelButton: { minHeight: SIZE, justifyContent: 'center' },
});Installation
npx kinetik-ui add search-morphpnpm dlx kinetik-ui add search-morphbunx kinetik-ui add search-morphyarn dlx kinetik-ui add search-morphThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/search-morph.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/search-morph.tsx
Usage
import { SearchMorph } from '@/components/kinetik/search-morph';
<SearchMorph value={query} onChangeText={setQuery} onSubmit={search} placeholder="Search places">
<Results query={query} />
</SearchMorph>;Give it the width it may grow into, for example style={{ flex: 1 }} in a header row. Closed, it sits at the end of that space as a 44 pt circle.
When to use
- Headers where search is useful but should not take a full row until it is wanted.
- Lists and grids that filter as you type, with the results passed as children.
When not to use
- Screens whose main job is search. Show the field open from the start.
- Desktop-style toolbars with room to spare. A plain field is easier to find.
Notes
- The field takes focus a moment after it starts to open, so the keyboard does not race the morph.
- Cancel clears the text and closes. Control
openwithonOpenChangeto hide a title while it is open, as the demo does. - Children are rendered only while open, so results do not run their queries in the background.
- With reduced motion the field opens and closes in place.
Props
SearchMorphProps
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | — | |
onChangeTextrequired | (value: string) => void | — | |
onSubmit | (value: string) => void | — | Called from the keyboard's search key. |
placeholder | string | 'Search' | Defaults to 'Search'. Also names the button and the field. |
open | boolean | — | Controlled open state. Leave unset to let the component keep its own. |
defaultOpen | boolean | false | |
onOpenChange | (open: boolean) => void | — | |
cancelLabel | string | 'Cancel' | Defaults to 'Cancel'. |
children | ReactNode | — | Shown under the field while it is open, e.g. results or recent searches. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- One shared value drives the stretch, the field and Cancel. The shell's edges animate, so it lays out for the length of the spring; nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 0.90× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 155 MB app memory. Measured with its own script (open, type, close) since the default gestures miss it; slower frames come from the keyboard opening and closing.
Real-device measurement pending.
Accessibility
- Reduced motion
- The field opens and closes in place.
- Screen readers
- A Search button that becomes a text field named by the placeholder, with Clear search and Cancel buttons. Hidden parts are hidden from screen readers.
- Touch target
- The button, the clear button and the bar are all 48 pt tall.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/search-morph.tsx
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
Pressable,
StyleSheet,
Text,
TextInput,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type SearchMorphProps = {
value: string;
onChangeText: (value: string) => void;
/** Called from the keyboard's search key. */
onSubmit?: (value: string) => void;
/** Defaults to 'Search'. Also names the button and the field. */
placeholder?: string;
/** Controlled open state. Leave unset to let the component keep its own. */
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Defaults to 'Cancel'. */
cancelLabel?: string;
/** Shown under the field while it is open, e.g. results or recent searches. */
children?: ReactNode;
testID?: string;
style?: StyleProp<ViewStyle>;
};
/** A little over the 44 pt minimum, so pixel rounding never takes it below. */
const SIZE = 48;
const GAP = 12;
/**
* A search button that grows into a search field. The circle stretches
* across the bar with the glass riding its left edge, Cancel slides in beside
* it, and whatever you pass as children rises in underneath.
*/
export function SearchMorph({
value,
onChangeText,
onSubmit,
placeholder = 'Search',
open,
defaultOpen = false,
onOpenChange,
cancelLabel = 'Cancel',
children,
testID,
style,
}: SearchMorphProps) {
const { colors, font, radius, space } = useKinetikTheme();
const reduced = useReduceMotion();
const [inner, setInner] = useState(defaultOpen);
const isOpen = open ?? inner;
const [width, setWidth] = useState(0);
const [cancelW, setCancelW] = useState(0);
const input = useRef<TextInput>(null);
const t = useSharedValue(isOpen ? 1 : 0);
useEffect(() => {
t.set(withSpring(isOpen ? 1 : 0, spring('smooth', reduced)));
if (isOpen) {
// Focus once the field has started to open, so the keyboard does not race the morph.
const id = setTimeout(() => input.current?.focus(), reduced ? 0 : 120);
return () => clearTimeout(id);
}
input.current?.blur();
}, [isOpen, reduced, t]);
const setOpen = (next: boolean) => {
if (open === undefined) setInner(next);
onOpenChange?.(next);
};
const cancel = () => {
onChangeText('');
setOpen(false);
};
const shell = useAnimatedStyle(() => {
const p = t.get();
return {
// Hidden until the bar is measured, so it never flashes at full width.
opacity: width === 0 ? 0 : 1,
left: Math.max(0, (width - SIZE) * (1 - p)),
right: (cancelW + GAP) * Math.max(0, p),
backgroundColor: interpolateColor(p, [0, 1], [colors.surfaceRaised, colors.surface]),
borderColor: interpolateColor(p, [0, 1], [colors.border, colors.borderStrong]),
};
});
const field = useAnimatedStyle(() => ({ opacity: Math.max(0, t.get() * 1.6 - 0.6) }));
const cancelStyle = useAnimatedStyle(() => ({
opacity: Math.max(0, t.get() * 2 - 1),
transform: [{ translateX: (1 - t.get()) * 24 }],
}));
const below = useAnimatedStyle(() => ({
opacity: Math.max(0, t.get() * 2 - 1),
transform: [{ translateY: (1 - t.get()) * -12 }],
}));
return (
<View testID={testID} style={style}>
<View style={styles.bar} onLayout={(e) => setWidth(e.nativeEvent.layout.width)}>
<Animated.View style={[styles.shell, { borderRadius: radius.pill }, shell]}>
<View style={styles.glass} pointerEvents="none">
<View style={[styles.lens, { borderColor: colors.text }]} />
<View style={[styles.handle, { backgroundColor: colors.text }]} />
</View>
<Animated.View
style={[styles.field, field]}
pointerEvents={isOpen ? 'auto' : 'none'}
accessibilityElementsHidden={!isOpen}
importantForAccessibility={isOpen ? 'auto' : 'no-hide-descendants'}
>
<TextInput
ref={input}
value={value}
onChangeText={onChangeText}
onSubmitEditing={() => onSubmit?.(value)}
placeholder={placeholder}
placeholderTextColor={colors.textSubtle}
returnKeyType="search"
autoCorrect={false}
accessibilityLabel={placeholder}
selectionColor={colors.azure}
style={[styles.input, { color: colors.text, fontSize: font.size.md }]}
/>
{value ? (
<Pressable
onPress={() => {
onChangeText('');
input.current?.focus();
}}
accessibilityRole="button"
accessibilityLabel="Clear search"
style={styles.clear}
>
<View style={[styles.clearDot, { backgroundColor: colors.textSubtle }]}>
<View style={[styles.cross, { backgroundColor: colors.surface }]} />
<View
style={[styles.cross, styles.crossTurn, { backgroundColor: colors.surface }]}
/>
</View>
</Pressable>
) : null}
</Animated.View>
{!isOpen ? (
<Pressable
onPress={() => setOpen(true)}
accessibilityRole="button"
accessibilityLabel={placeholder}
style={StyleSheet.absoluteFill}
/>
) : null}
</Animated.View>
<Animated.View
style={[styles.cancel, cancelStyle]}
pointerEvents={isOpen ? 'auto' : 'none'}
accessibilityElementsHidden={!isOpen}
importantForAccessibility={isOpen ? 'auto' : 'no-hide-descendants'}
>
<Pressable
onPress={cancel}
accessibilityRole="button"
onLayout={(e) => setCancelW(e.nativeEvent.layout.width)}
style={({ pressed }) => [styles.cancelButton, { opacity: pressed ? 0.6 : 1 }]}
>
<Text
style={{
color: colors.azure,
fontSize: font.size.md,
fontWeight: font.weight.medium,
}}
>
{cancelLabel}
</Text>
</Pressable>
</Animated.View>
</View>
{children && isOpen ? (
<Animated.View style={[{ marginTop: space[3] }, below]}>{children}</Animated.View>
) : null}
</View>
);
}
const styles = StyleSheet.create({
bar: { height: SIZE, justifyContent: 'center' },
shell: {
position: 'absolute',
top: 0,
bottom: 0,
borderWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
},
// The glass sits in the first 44 pt, so it reads the same closed and open.
glass: { position: 'absolute', left: 0, top: 0, width: SIZE, height: SIZE },
lens: {
position: 'absolute',
left: 16,
top: 15,
width: 13,
height: 13,
borderRadius: 7,
borderWidth: 2,
},
handle: {
position: 'absolute',
left: 26,
top: 27.5,
width: 7,
height: 2,
borderRadius: 1,
transform: [{ rotate: '45deg' }],
},
field: {
position: 'absolute',
left: SIZE - 4,
right: 0,
top: 0,
bottom: 0,
flexDirection: 'row',
alignItems: 'center',
},
input: { flex: 1, height: SIZE, paddingVertical: 0 },
clear: { width: SIZE, height: SIZE, alignItems: 'center', justifyContent: 'center' },
clearDot: {
width: 18,
height: 18,
borderRadius: 9,
alignItems: 'center',
justifyContent: 'center',
},
cross: { position: 'absolute', width: 9, height: 1.5, transform: [{ rotate: '45deg' }] },
crossTurn: { transform: [{ rotate: '-45deg' }] },
cancel: { position: 'absolute', right: 0, top: 0, bottom: 0, justifyContent: 'center' },
cancelButton: { minHeight: SIZE, justifyContent: 'center' },
});