Skip to content
pixelfor Expo
Ctrl K

Interaction

Morphing Discovery Bar

An expressive interaction rebuilt for touch

Try it on your phone

Morphing Discovery Bar

Scan to open Morphing Discovery Bar in Expo Go

Install Expo Go first, then scan this QR to run the native component.

GET EXPO GO ONGoogle Play

This demo uses Expo SDK 54. If your installed Expo Go supports a different SDK, get the compatible build. iOS needs a build supporting this SDK.

Expo code

Original web component

Watermelon UI

Our Expo component

React Native Web

Component 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.

Back to Watermelon UI