Interaction
Morphing Discovery Bar
An expressive interaction rebuilt for touch
Expo code
Original web component
Watermelon UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
morphing-discovery-bar.in-progress.tsx
import React, { useEffect, useId, useRef, useState } from "react"
import {
Platform,
Pressable,
Text,
TextInput,
View,
useColorScheme,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from "react-native"
import Animated, {
Easing,
useAnimatedProps,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated"
import Svg, {
Defs,
Filter,
FeGaussianBlur,
G,
Path,
Rect,
Text as SvgText,
} from "react-native-svg"
import { BlurView } from "expo-blur"
export interface Category {
id: string
label: string
icon: React.ReactNode | ((color: string) => React.ReactNode)
activeColor: string
activeTextColor: string
}
export interface MorphingDiscoveryBarProps {
categories: Category[]
onCategoryChange?: (id: string) => void
onSearchChange?: (value: string) => void
onSearchSubmit?: (value: string) => void
defaultCategory?: string
searchPlaceholder?: string
compact?: boolean
searchWidth?: number
iconSize?: number
fontFamily?: string
theme?: "light" | "dark" | "system"
radius?: number
backdropIntensity?: number
colors?: Partial<{
surface: string
border: string
text: string
inactive: string
placeholder: string
darkPill: string
}>
style?: StyleProp<ViewStyle>
testID?: string
}
function discoverySpring(p: number) {
"worklet"
const a = Math.sqrt(1 - 0.7 * 0.7),
omega = Math.log(0.7 / a / 0.001) / (0.7 * 0.7),
t = 0.7 * p
return p >= 1
? 1
: 1 -
Math.exp(-omega * 0.7 * t) *
(Math.cos(omega * a * t) + (0.7 / a) * Math.sin(omega * a * t))
}
const MORPH = { duration: 700, easing: discoverySpring }
const INPUT = { duration: 150, easing: Easing.bezier(0.25, 0.1, 0.35, 1) }
class BlurTarget extends FeGaussianBlur {
setNativeProps = (
props: React.ComponentProps<typeof FeGaussianBlur> & {
style?: React.ComponentProps<typeof FeGaussianBlur>
}
) => {
if (Platform.OS === "web") {
const node = (
this as unknown as {
elementRef?: {
current: {
setAttribute: (key: string, value: string) => void
} | null
}
}
).elementRef?.current
const amount = props.style?.stdDeviation ?? props.stdDeviation
if (amount !== undefined)
node?.setAttribute("stdDeviation", String(amount))
} else this.root?.setNativeProps(props)
}
}
const AB = Animated.createAnimatedComponent(BlurTarget),
AG = Animated.createAnimatedComponent(G),
AR = Animated.createAnimatedComponent(Rect),
AT = Animated.createAnimatedComponent(SvgText)
const AnimatedInput = Animated.createAnimatedComponent(TextInput)
export function MorphingDiscoveryBar({
categories,
onCategoryChange,
onSearchChange,
onSearchSubmit,
defaultCategory,
searchPlaceholder = "Search",
compact,
searchWidth,
iconSize = 18,
fontFamily = Platform.OS === "web" ? "Arial" : undefined,
theme = "system",
radius = 32,
backdropIntensity = 12,
colors: overrides,
style,
testID,
}: MorphingDiscoveryBarProps) {
const [searching, setSearching] = useState(false),
[selected, setSelected] = useState(defaultCategory ?? categories[0]?.id),
[value, setValue] = useState(""),
[closingValue, setClosingValue] = useState("")
const [labelWidths, setLabelWidths] = useState<Record<string, number>>({})
const input = useRef<TextInput>(null)
const window = useWindowDimensions(),
scheme = useColorScheme(),
small = compact ?? window.width < 640,
dark = theme === "dark" || (theme === "system" && scheme === "dark")
const colors = {
surface: dark ? "#171717" : "#fff",
border: dark ? "#262626" : "#f5f5f5",
text: dark ? "#fff" : "#171717",
inactive: dark ? "#a3a3a3" : "#525252",
placeholder: dark ? "#525252" : "#a3a3a3",
darkPill: "#262626",
...overrides,
}
const size = small ? 48 : 56,
gap = small ? 6 : 12,
padding = small ? 6 : 8,
labelSize = small ? 12 : 18,
tabPadding = small ? 12 : 24,
tabGap = small ? 6 : 8,
iconPadding = small ? 12 : 16
const tabHeight = Math.max(iconSize, small ? 16 : 28) + (small ? 16 : 24),
categoryHeight = tabHeight + 10
let cursor = 5
const tabs = categories.map((category) => {
const width =
tabPadding * 2 +
iconSize +
tabGap +
(labelWidths[`${labelSize}-${category.id}`] ??
category.label.length * labelSize * 0.55)
const tab = { ...category, x: cursor, width }
cursor += width + 4
return tab
})
const categoryWidth = Math.max(10, cursor + 1)
const maximum = window.width - (small ? 16 : 32)
const visibleCategoryWidth = Math.max(
0,
Math.min(categoryWidth, maximum - padding * 2 - size - gap)
)
const targetSearchWidth =
searchWidth ??
(small ? (window.width >= 480 ? 256 : window.width - 80) : 320)
const rootWidth = useSharedValue(
Math.min(maximum, padding * 2 + size + gap + visibleCategoryWidth)
),
searchSize = useSharedValue(size),
iconX = useSharedValue((size - 18) / 2),
categoryP = useSharedValue(0),
closeP = useSharedValue(0),
inputP = useSharedValue(0),
scrollX = useSharedValue(0),
closingScrollX = useSharedValue(0),
pressP = useSharedValue(1)
const pillX = useSharedValue(tabs.find((tab) => tab.id === selected)?.x ?? 5),
pillWidth = useSharedValue(
tabs.find((tab) => tab.id === selected)?.width ?? 0
),
pillColor = useSharedValue(
dark
? colors.darkPill
: (tabs.find((tab) => tab.id === selected)?.activeColor ??
"transparent")
)
const categoryFilter = useId().replace(/\W/g, ""),
closeFilter = useId().replace(/\W/g, ""),
inputFilter = useId().replace(/\W/g, "")
useEffect(() => {
rootWidth.set(
withTiming(
Math.min(
maximum,
padding * 2 +
(searching
? targetSearchWidth + gap + 56
: size + gap + visibleCategoryWidth)
),
MORPH
)
)
searchSize.set(withTiming(searching ? targetSearchWidth : size, MORPH))
iconX.set(withTiming(searching ? iconPadding : (size - 18) / 2, MORPH))
categoryP.set(withTiming(searching ? 0 : 1, MORPH))
closeP.set(withTiming(searching ? 1 : 0, MORPH))
inputP.set(withTiming(searching ? 1 : 0, INPUT))
if (searching) {
const timer = setTimeout(() => input.current?.focus(), 100)
return () => clearTimeout(timer)
}
}, [searching, targetSearchWidth, size, gap, visibleCategoryWidth, maximum])
useEffect(() => {
const tab = tabs.find((item) => item.id === selected)
if (tab) {
pillX.set(withTiming(tab.x, MORPH))
pillWidth.set(withTiming(tab.width, MORPH))
pillColor.set(
withTiming(dark ? colors.darkPill : tab.activeColor, { duration: 150 })
)
}
}, [selected, labelWidths, dark, labelSize])
const root = useAnimatedStyle(() => ({ width: rootWidth.get() }))
const search = useAnimatedStyle(() => ({ width: searchSize.get() }))
const searchIcon = useAnimatedStyle(() => ({ left: iconX.get() }))
const inputBox = useAnimatedStyle(() => ({
width: Math.max(0, searchSize.get() - 2 * iconPadding - 26),
}))
const inputMotion = useAnimatedProps(() => ({
opacity: searching && inputP.get() === 1 ? 0 : inputP.get(),
transform: `translate(0 20) scale(${0.6 + 0.4 * inputP.get()} ${0.8 + 0.2 * inputP.get()}) translate(0 -20)`,
}))
const nativeInputMotion = useAnimatedStyle(() => ({
opacity: searching && inputP.get() === 1 ? 1 : 0,
}))
const mirrorPosition = useAnimatedProps(() => ({
x: -(searching ? scrollX.get() : closingScrollX.get()),
}))
const inputBlur = useAnimatedProps(() => ({
stdDeviation: 4 * (1 - inputP.get()),
}))
const categoryBox = useAnimatedStyle(() => ({
left: padding + searchSize.get() + gap,
opacity: Math.max(0, Math.min(1, categoryP.get())),
}))
const categoryMotion = useAnimatedProps(() => ({
transform: `translate(${visibleCategoryWidth / 2} ${categoryHeight / 2}) scale(${0.9 + 0.1 * categoryP.get()}) translate(${-visibleCategoryWidth / 2} ${-categoryHeight / 2})`,
}))
const categoryBlur = useAnimatedProps(() => ({
stdDeviation: 10 * Math.max(0, 1 - categoryP.get()),
}))
const pill = useAnimatedProps(() => ({
x: pillX.get(),
width: Math.max(0, pillWidth.get()),
fill: pillColor.get(),
}))
const closeBox = useAnimatedStyle(() => ({
left: padding + searchSize.get() + gap,
width: 120 - 64 * closeP.get(),
opacity: Math.max(0, Math.min(1, closeP.get())),
transformOrigin: [0, "50%", 0],
transform: [
{ translateX: -80 * (1 - closeP.get()) },
{ scaleX: (1.5 - 0.5 * closeP.get()) * pressP.get() },
{ scaleY: (0.8 + 0.2 * closeP.get()) * pressP.get() },
],
}))
const closeBlur = useAnimatedProps(() => ({
stdDeviation: 8 * Math.max(0, 1 - closeP.get()),
}))
const closeRect = useAnimatedProps(() => ({
width: Math.max(0, 120 - 64 * closeP.get() - 1),
}))
const closeIcon = useAnimatedProps(() => ({
transform: `translate(${(120 - 64 * closeP.get() - 18) / 2} ${(size - 18) / 2})`,
}))
function close() {
setClosingValue(value)
closingScrollX.set(scrollX.get())
input.current?.blur()
setSearching(false)
setValue("")
onSearchChange?.("")
}
function change(text: string) {
setValue(text)
onSearchChange?.(text)
}
return (
<Animated.View
testID={testID}
style={[
{
height: 80,
alignSelf: "center",
borderRadius: radius,
justifyContent: "center",
},
root,
style,
]}
>
<View
pointerEvents="none"
style={{
position: "absolute",
top: (80 - Math.max(size, categoryHeight) - padding * 2) / 2,
bottom: (80 - Math.max(size, categoryHeight) - padding * 2) / 2,
left: 0,
right: 0,
borderRadius: radius,
overflow: "hidden",
}}
>
<BlurView
intensity={backdropIntensity}
tint={dark ? "dark" : "light"}
experimentalBlurMethod="dimezisBlurView"
style={{ flex: 1 }}
/>
</View>
<View
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={{ position: "absolute", opacity: 0, flexDirection: "row" }}
>
{categories.map((category) => (
<Text
key={`${labelSize}-${category.id}`}
onLayout={(e) => {
const w = e.nativeEvent.layout.width
setLabelWidths((previous) =>
previous[`${labelSize}-${category.id}`] === w
? previous
: { ...previous, [`${labelSize}-${category.id}`]: w }
)
}}
style={{
fontFamily,
fontSize: labelSize,
lineHeight: small ? 16 : 28,
fontWeight: "700",
letterSpacing: -labelSize * 0.025,
}}
>
{category.label}
</Text>
))}
</View>
<Animated.View
style={[
{
position: "absolute",
left: padding,
height: size,
borderRadius: 28,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
overflow: "hidden",
boxShadow: "0px 1px 2px rgba(0,0,0,.05)",
transitionProperty: ["backgroundColor", "borderColor"],
transitionDuration: 150,
},
search,
]}
>
<Animated.View
pointerEvents="none"
style={[{ position: "absolute", top: (size - 18) / 2 }, searchIcon]}
>
<Svg
width={18}
height={18}
viewBox="0 0 24 24"
fill="none"
stroke={dark ? colors.inactive : colors.text}
strokeWidth={3}
strokeLinecap="round"
>
<Path d="m21 21-4.34-4.34" />
<Path d="M19 11a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
</Svg>
</Animated.View>
<Animated.View
pointerEvents={searching ? "auto" : "none"}
style={[
{
position: "absolute",
left: iconPadding + 26,
top: (size - 40) / 2,
height: 40,
overflow: "hidden",
},
inputBox,
]}
>
<Svg
width={Math.max(1, targetSearchWidth - 2 * iconPadding - 26)}
height={40}
pointerEvents="none"
style={{ position: "absolute", top: 0, left: 0 }}
>
<Defs>
<Filter
id={inputFilter}
x="-10%"
y="-30%"
width="120%"
height="160%"
>
<AB animatedProps={inputBlur} />
</Filter>
</Defs>
<AG animatedProps={inputMotion}>
<G filter={`url(#${inputFilter})`}>
<AT
animatedProps={mirrorPosition}
y={20 + (small ? 14 : 16) * 0.35}
fontFamily={fontFamily}
fontSize={small ? 14 : 16}
fontWeight="500"
fill={
(searching ? value : closingValue)
? colors.text
: colors.placeholder
}
>
{(searching ? value : closingValue) || searchPlaceholder}
</AT>
</G>
</AG>
</Svg>
<AnimatedInput
ref={input}
value={value}
onChangeText={change}
onSubmitEditing={() => onSearchSubmit?.(value)}
returnKeyType="search"
editable={searching}
accessibilityLabel={searchPlaceholder}
cursorColor={colors.text}
selectionColor={colors.text}
placeholder={searchPlaceholder}
placeholderTextColor={colors.placeholder}
onScroll={({ nativeEvent }) => {
if (searching)
scrollX.set(Math.max(0, nativeEvent.contentOffset.x))
}}
style={[
{
color: colors.text,
fontFamily,
fontSize: small ? 14 : 16,
fontWeight: "500",
padding: 0,
height: 40,
textAlignVertical: "center",
includeFontPadding: false,
},
nativeInputMotion,
]}
/>
</Animated.View>
{!searching && (
<Pressable
accessibilityRole="button"
accessibilityLabel="Open search"
onPress={() => setSearching(true)}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
}}
/>
)}
</Animated.View>
<Animated.View
pointerEvents={searching ? "none" : "auto"}
accessibilityElementsHidden={searching}
importantForAccessibility={searching ? "no-hide-descendants" : "auto"}
style={[
{
position: "absolute",
top: (80 - categoryHeight) / 2,
height: categoryHeight,
width: visibleCategoryWidth,
},
categoryBox,
]}
>
<Svg
width={visibleCategoryWidth}
height={categoryHeight}
pointerEvents="none"
style={{ position: "absolute", overflow: "visible" }}
>
<Defs>
<Filter
id={categoryFilter}
x="-10%"
y="-40%"
width="120%"
height="180%"
>
<AB animatedProps={categoryBlur} />
</Filter>
</Defs>
<AG animatedProps={categoryMotion}>
<G filter={`url(#${categoryFilter})`}>
<Rect
x={0.5}
y={0.5}
width={Math.max(0, visibleCategoryWidth - 1)}
height={categoryHeight - 1}
rx={999}
fill={colors.surface}
stroke={dark ? colors.border : "#F0F0F0"}
/>
<AR
y={5}
height={tabHeight}
rx={999}
animatedProps={pill}
stroke={dark ? "#404040" : "transparent"}
/>
{tabs.map((tab) => (
<CategoryContent
key={tab.id}
tab={tab}
active={selected === tab.id}
size={labelSize}
small={small}
iconSize={iconSize}
tabHeight={tabHeight}
padding={tabPadding}
gap={tabGap}
inactive={colors.inactive}
fontFamily={fontFamily}
/>
))}
</G>
</AG>
</Svg>
{tabs.map((tab) => (
<Pressable
key={tab.id}
accessibilityRole="tab"
accessibilityState={{ selected: selected === tab.id }}
accessibilityLabel={tab.label}
onPress={() => {
setSelected(tab.id)
onCategoryChange?.(tab.id)
}}
style={{
position: "absolute",
left: tab.x,
top: 5,
width: Math.max(
0,
Math.min(tab.width, visibleCategoryWidth - tab.x)
),
height: tabHeight,
}}
/>
))}
</Animated.View>
<Animated.View
pointerEvents={searching ? "auto" : "none"}
style={[
{ position: "absolute", top: (80 - size) / 2, height: size },
closeBox,
]}
>
<Svg
width={120}
height={size}
pointerEvents="none"
style={{ position: "absolute", overflow: "visible" }}
>
<Defs>
<Filter
id={closeFilter}
x="-20%"
y="-40%"
width="140%"
height="180%"
>
<AB animatedProps={closeBlur} />
</Filter>
</Defs>
<G filter={`url(#${closeFilter})`}>
<AR
x={0.5}
y={0.5}
height={size - 1}
rx={999}
fill={colors.surface}
stroke={colors.border}
animatedProps={closeRect}
/>
<AG animatedProps={closeIcon}>
<Svg
width={18}
height={18}
viewBox="0 0 24 24"
fill="none"
stroke={colors.text}
strokeWidth={2.5}
strokeLinecap="round"
>
<Path d="m18 6-12 12M6 6l12 12" />
</Svg>
</AG>
</G>
</Svg>
<Pressable
accessibilityRole="button"
accessibilityLabel="Close search"
onPress={close}
onPressIn={() =>
pressP.set(withSpring(0.9, { stiffness: 550, damping: 30 }))
}
onPressOut={() =>
pressP.set(withSpring(1, { stiffness: 550, damping: 30 }))
}
style={{ position: "absolute", left: 0, right: 0, top: 0, bottom: 0 }}
/>
</Animated.View>
</Animated.View>
)
}
function CategoryContent({
tab,
active,
size,
small,
iconSize,
tabHeight,
padding,
gap,
inactive,
fontFamily,
}: {
tab: Category & { x: number; width: number }
active: boolean
size: number
small: boolean
iconSize: number
tabHeight: number
padding: number
gap: number
inactive: string
fontFamily?: string
}) {
const color = useSharedValue(active ? tab.activeTextColor : inactive)
useEffect(() => {
color.set(
withTiming(active ? tab.activeTextColor : inactive, {
duration: 150,
easing: Easing.bezier(0.4, 0, 0.2, 1),
})
)
}, [active, inactive, tab.activeTextColor])
const fill = useAnimatedProps(() => ({ fill: color.get() }))
return (
<G>
<AG
animatedProps={fill}
transform={`translate(${tab.x + padding} ${5 + (tabHeight - iconSize) / 2})`}
>
<Svg
width={iconSize}
height={iconSize}
viewBox={`0 0 ${iconSize} ${iconSize}`}
>
<G
transform={
small
? `translate(${iconSize * 0.05} ${iconSize * 0.05}) scale(.9)`
: undefined
}
>
{typeof tab.icon === "function"
? tab.icon(active ? tab.activeTextColor : inactive)
: tab.icon}
</G>
</Svg>
</AG>
<AT
x={tab.x + padding + iconSize + gap}
y={5 + tabHeight / 2 + size * 0.35}
fontFamily={fontFamily}
fontSize={size}
fontWeight="700"
letterSpacing={-size * 0.025}
animatedProps={fill}
>
{tab.label}
</AT>
</G>
)
}
export default MorphingDiscoveryBar
Give the gestures and motion a run on a real Android phone.