Skip to content
pixelfor Expo
Ctrl K

Interaction

Quick Switcher

An expressive interaction rebuilt for touch

Try it on your phone

Quick Switcher

Scan to open Quick Switcher 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.

quick-switcher.in-progress.tsx

import React, { useCallback, useEffect, useId, useRef, useState } from "react"
import {
  Platform,
  Pressable,
  Text,
  TextInput,
  View,
  useColorScheme,
  useWindowDimensions,
  type StyleProp,
  type ViewStyle,
} from "react-native"
import Animated, {
  defineAnimation,
  type AnimationObject,
  cubicBezier,
  useAnimatedProps,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  withTiming,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import Svg, {
  Defs,
  Filter,
  FeGaussianBlur,
  G,
  Path,
  Text as SvgText,
} from "react-native-svg"
type DelayedMotion = AnimationObject<number> & {
  current: number
  startedAt: number
  started: boolean
  held: boolean
  next: AnimationObject<number>
  previous?: AnimationObject<number>
}
function motionDelay(delay: number, animation: number): number {
  "worklet"
  const definition = animation as unknown as
    AnimationObject<number> | (() => AnimationObject<number>)
  return defineAnimation<DelayedMotion>(animation, () => {
    "worklet"
    const next = typeof definition === "function" ? definition() : definition
    return {
      isHigherOrder: true,
      current: next.current ?? 0,
      startedAt: 0,
      started: false,
      held: false,
      next,
      onStart(a, value, now, previous) {
        a.current = value
        a.startedAt = now
        a.started = false
        a.held = false
        const prior = previous?.next
          ? previous.started
            ? previous.next
            : previous.previous
          : previous
        const stationary =
          previous?.next &&
          !previous.started &&
          (previous.held || now - previous.startedAt > 30)
        a.previous = prior
          ? {
              ...prior,
              current: value,
              velocity: stationary ? 0 : prior.velocity,
            }
          : undefined
        if (next.reduceMotion === undefined) next.reduceMotion = a.reduceMotion
      },
      onFrame(a, now) {
        if (!a.started) {
          if (!a.reduceMotion && now - a.startedAt < delay) {
            a.held = true
            return false
          }
          const start = a.reduceMotion ? now : a.startedAt + delay
          const previous = a.previous
            ? {
                ...a.previous,
                lastTimestamp: start,
                startTimestamp: 0,
                startTime: 0,
              }
            : undefined
          next.onStart(next, a.current, start, previous)
          a.started = true
          a.previous = undefined
        }
        const finished = next.onFrame(next, now)
        a.current = next.current ?? a.current
        return finished
      },
      callback: next.callback,
    }
  }) as unknown as number
}
export type QuickSwitcherMode = "ask" | "generate"
export interface QuickSwitcherProps {
  defaultMode?: QuickSwitcherMode
  askIcon?: React.ReactNode
  generateIcon?: React.ReactNode
  askLabel?: string
  generateLabel?: string
  onActionClick?: (mode: QuickSwitcherMode, value: string) => void
  onModeChange?: (mode: QuickSwitcherMode) => void
  onChangeText?: (value: string) => void
  compact?: boolean
  width?: number
  radius?: number
  fontFamily?: string
  theme?: "light" | "dark" | "system"
  colors?: Partial<{
    surface: string
    button: string
    hover: string
    pressed: string
    actionBorder: string
    border: string
    text: string
    muted: string
    chevron: string
  }>
  style?: StyleProp<ViewStyle>
  testID?: string
}
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)
const ICON_SPRING = { stiffness: 300, damping: 30, mass: 0.8 }
const LETTER_SPRING = { stiffness: 240, damping: 16, mass: 1.2 }
type WordEntry = { id: number; text: string; exit: boolean }
export function QuickSwitcher({
  defaultMode = "ask",
  askIcon,
  generateIcon,
  askLabel = "Ask Anything",
  generateLabel = "Generate Image",
  onActionClick,
  onModeChange,
  onChangeText,
  compact,
  width,
  radius = 999,
  fontFamily = Platform.OS === "web" ? "Arial" : undefined,
  theme = "system",
  colors: overrides,
  style,
  testID,
}: QuickSwitcherProps) {
  const [mode, setMode] = useState(defaultMode),
    [value, setValue] = useState("")
  const window = useWindowDimensions(),
    scheme = useColorScheme(),
    small = compact ?? window.width < 640
  const dark = theme === "dark" || (theme === "system" && scheme === "dark")
  const colors = {
    surface: dark ? "#171717" : "#F2F2F2",
    button: dark ? "#262626" : "#fff",
    hover: dark ? "#404040" : "#f5f5f5",
    pressed: dark ? "#262626" : "#fafafa",
    actionBorder: dark ? "#404040" : "#f3f4f6",
    border: dark ? "#26262680" : "#e5e7eb80",
    text: dark ? "#f5f5f5" : "#171717",
    muted: dark ? "#737373" : "#a3a3a3",
    chevron: dark ? "#737373" : "#d4d4d4",
    ...overrides,
  }
  const [modeHover, setModeHover] = useState(false),
    [actionHover, setActionHover] = useState(false),
    [actionPressed, setActionPressed] = useState(false)
  const modeScale = useSharedValue(1),
    actionScale = useSharedValue(1),
    actionX = useSharedValue(0)
  const modeMotion = useAnimatedStyle(() => ({
    transform: [{ scale: modeScale.get() }],
  }))
  const actionMotion = useAnimatedStyle(() => ({
    transform: [{ translateX: actionX.get() }, { scale: actionScale.get() }],
  }))
  const label = mode === "ask" ? askLabel : generateLabel
  const nextId = useRef(1)
  const [words, setWords] = useState<WordEntry[]>([
    { id: 0, text: label, exit: false },
  ])
  const [glyphWidths, setGlyphWidths] = useState<Record<string, number>>({})
  const lastTarget = useRef(label)
  const size = small ? 18 : 20
  useEffect(() => {
    const target = value.length ? "" : label
    if (lastTarget.current === target) return
    lastTarget.current = target
    setWords((current) => {
      const returning = current.some((entry) => entry.text === target)
      const words = current.map((entry) => ({
        ...entry,
        exit: entry.text !== target,
      }))
      return target && !returning
        ? [...words, { id: nextId.current++, text: target, exit: false }]
        : words
    })
  }, [label, value])
  const removeWord = useCallback(
    (id: number) =>
      setWords((current) =>
        current.filter((entry) => entry.id !== id || !entry.exit)
      ),
    []
  )
  function toggle() {
    const next = mode === "ask" ? "generate" : "ask"
    setMode(next)
    onModeChange?.(next)
  }
  function change(text: string) {
    setValue(text)
    onChangeText?.(text)
  }
  function submit() {
    onActionClick?.(mode, value)
  }
  return (
    <View
      testID={testID}
      style={[
        {
          alignSelf: "center",
          width: width ?? (small ? 320 : 380),
          height: 68,
          borderRadius: radius,
          borderWidth: 1,
          borderColor: colors.border,
          backgroundColor: colors.surface,
          padding: 6,
          flexDirection: "row",
          alignItems: "center",
          boxShadow: "0px 1px 2px rgba(0,0,0,.05)",
        },
        style,
      ]}
    >
      <Pressable
        accessibilityRole="button"
        accessibilityLabel={`Switch from ${mode} mode`}
        onPress={toggle}
        onHoverIn={() => setModeHover(true)}
        onHoverOut={() => setModeHover(false)}
        onPressIn={() => modeScale.set(0.95)}
        onPressOut={() => modeScale.set(1)}
      >
        <Animated.View
          style={[
            {
              height: 54,
              flexDirection: "row",
              alignItems: "center",
              borderRadius: radius,
              paddingLeft: 8,
              paddingRight: 16,
              backgroundColor: modeHover ? colors.hover : colors.button,
              transitionProperty: "backgroundColor",
              transitionDuration: 150,
              transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
              boxShadow: "0px 1px 2px rgba(0,0,0,.05)",
            },
            modeMotion,
          ]}
        >
          <View style={{ width: 48, height: 48, overflow: "hidden" }}>
            <ModeIcon active={mode === "ask"} color={colors.text}>
              {askIcon ?? (
                <Path
                  d="M5 5h14v10H9l-4 4V5Z"
                  fill="none"
                  stroke={colors.text}
                  strokeWidth={2}
                />
              )}
            </ModeIcon>
            <ModeIcon active={mode === "generate"} color={colors.text}>
              {generateIcon ?? (
                <>
                  <Path
                    d="M4 4h16v16H4Z"
                    fill="none"
                    stroke={colors.text}
                    strokeWidth={2}
                  />
                  <Path
                    d="m4 16 5-5 4 4 3-3 4 4"
                    fill="none"
                    stroke={colors.text}
                    strokeWidth={2}
                  />
                </>
              )}
            </ModeIcon>
          </View>
          <View style={{ marginLeft: 4 }}>
            {[true, false].map((up) => (
              <Svg
                key={String(up)}
                width={14}
                height={14}
                viewBox="0 0 24 24"
                fill="none"
                stroke={colors.chevron}
                strokeWidth={4}
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <Path d={up ? "m18 15-6-6-6 6" : "m6 9 6 6 6-6"} />
              </Svg>
            ))}
          </View>
        </Animated.View>
      </Pressable>
      <View
        style={{
          flex: 1,
          height: 54,
          paddingHorizontal: 16,
          justifyContent: "center",
          overflow: "hidden",
        }}
      >
        <View
          pointerEvents="none"
          accessibilityElementsHidden
          importantForAccessibility="no-hide-descendants"
          style={{
            position: "absolute",
            left: 16,
            top: 3,
            height: 48,
            flexDirection: "row",
            opacity: 0,
          }}
        >
          {Array.from(new Set((askLabel + generateLabel).split(""))).map(
            (char) => (
              <Text
                key={`${size}-${char}`}
                onLayout={(e) => {
                  const measured = e.nativeEvent.layout.width
                  setGlyphWidths((previous) =>
                    previous[`${size}-${char}`] === measured
                      ? previous
                      : { ...previous, [`${size}-${char}`]: measured }
                  )
                }}
                style={{ fontFamily, fontSize: size, fontWeight: "600" }}
              >
                {char}
              </Text>
            )
          )}
        </View>
        {words.map((entry) => (
          <View
            key={entry.id}
            pointerEvents="none"
            accessibilityElementsHidden
            importantForAccessibility="no-hide-descendants"
            style={{
              position: "absolute",
              top: 3,
              left: 16,
              flexDirection: "row",
              height: 48,
            }}
          >
            {entry.text.split("").map((char, i) => (
              <PlaceholderGlyph
                key={i}
                char={char}
                index={i}
                width={
                  glyphWidths[`${size}-${char}`] ??
                  (char === " " ? size * 0.28 : size * 0.56)
                }
                size={size}
                fontFamily={fontFamily}
                color={colors.muted}
                exit={entry.exit}
                onExit={
                  i === entry.text.length - 1
                    ? () => removeWord(entry.id)
                    : undefined
                }
              />
            ))}
          </View>
        ))}
        <TextInput
          accessibilityLabel={label}
          value={value}
          onChangeText={change}
          onSubmitEditing={submit}
          returnKeyType="send"
          style={{
            padding: 0,
            fontFamily,
            fontSize: size,
            fontWeight: "600",
            color: colors.text,
            height: 48,
          }}
        />
      </View>
      <Pressable
        accessibilityRole="button"
        accessibilityLabel={mode === "ask" ? "Ask" : "Generate image"}
        onPress={submit}
        onHoverIn={() => {
          setActionHover(true)
          actionX.set(withSpring(2, { stiffness: 500, damping: 25 }))
        }}
        onHoverOut={() => {
          setActionHover(false)
          actionX.set(withSpring(0, { stiffness: 500, damping: 25 }))
        }}
        onPressIn={() => {
          setActionPressed(true)
          actionScale.set(withSpring(0.9, { stiffness: 500, damping: 25 }))
        }}
        onPressOut={() => {
          setActionPressed(false)
          actionScale.set(withSpring(1, { stiffness: 500, damping: 25 }))
        }}
      >
        <Animated.View
          style={[
            {
              width: 48,
              height: 48,
              borderRadius: radius,
              borderWidth: 1,
              borderColor: colors.actionBorder,
              backgroundColor: actionPressed
                ? colors.pressed
                : actionHover
                  ? colors.hover
                  : colors.button,
              transitionProperty: ["backgroundColor", "borderColor"],
              transitionDuration: 150,
              transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
              justifyContent: "center",
              alignItems: "center",
              boxShadow: "0px 1px 2px rgba(0,0,0,.05)",
            },
            actionMotion,
          ]}
        >
          <Svg
            width={22}
            height={22}
            viewBox="0 0 24 24"
            fill="none"
            stroke={colors.text}
            strokeWidth={2.5}
            strokeLinecap="round"
            strokeLinejoin="round"
          >
            <Path d="M5 12h14m-7-7 7 7-7 7" />
          </Svg>
        </Animated.View>
      </Pressable>
    </View>
  )
}
function ModeIcon({
  active,
  color,
  children,
}: {
  active: boolean
  color: string
  children: React.ReactNode
}) {
  const id = useId().replace(/\W/g, ""),
    progress = useSharedValue(active ? 1 : 0)
  useEffect(() => {
    progress.set(withSpring(active ? 1 : 0, ICON_SPRING))
  }, [active])
  const motion = useAnimatedProps(() => ({
    opacity: progress.get(),
    transform: `translate(12 12) scale(${0.5 + 0.5 * progress.get()}) translate(-12 -12)`,
  }))
  const blur = useAnimatedProps(() => ({
    stdDeviation: 4 * Math.max(0, 1 - progress.get()),
  }))
  return (
    <Svg
      width={48}
      height={48}
      viewBox="-12 -12 48 48"
      pointerEvents="none"
      style={{ position: "absolute" }}
    >
      <Defs>
        <Filter id={id} x="-70%" y="-70%" width="240%" height="240%">
          <AB animatedProps={blur} />
        </Filter>
      </Defs>
      <AG animatedProps={motion}>
        <G fill={color} filter={`url(#${id})`}>
          {children}
        </G>
      </AG>
    </Svg>
  )
}
function PlaceholderGlyph({
  char,
  index,
  width,
  size,
  fontFamily,
  color,
  exit,
  onExit,
}: {
  char: string
  index: number
  width: number
  size: number
  fontFamily?: string
  color: string
  exit: boolean
  onExit?: () => void
}) {
  const id = useId().replace(/\W/g, ""),
    progress = useSharedValue(0),
    y = useSharedValue(10)
  useEffect(() => {
    const delay = index * 14
    progress.set(
      motionDelay(
        delay,
        withSpring(exit ? 0 : 1, LETTER_SPRING, (finished) => {
          if (finished && exit && onExit) scheduleOnRN(onExit)
        })
      )
    )
    y.set(motionDelay(delay, withSpring(exit ? -10 : 0, LETTER_SPRING)))
  }, [exit, index])
  const motion = useAnimatedProps(() => ({
    opacity: Math.max(0, Math.min(1, progress.get())),
    transform: `translate(${width / 2} ${24 + y.get()}) scale(${0.5 + 0.5 * progress.get()}) translate(${-width / 2} -24)`,
  }))
  const blur = useAnimatedProps(() => ({
    stdDeviation: 2 * Math.max(0, 1 - progress.get()),
  }))
  return (
    <Svg width={Math.max(1, width)} height={48} style={{ overflow: "visible" }}>
      <Defs>
        <Filter id={id} x="-100%" y="-30%" width="300%" height="160%">
          <AB animatedProps={blur} />
        </Filter>
      </Defs>
      <AG animatedProps={motion}>
        <G filter={`url(#${id})`}>
          <SvgText
            x={0}
            y={24 + size * 0.35}
            fontFamily={fontFamily}
            fontSize={size}
            fontWeight="600"
            fill={color}
          >
            {char === " " ? "\u00a0" : char}
          </SvgText>
        </G>
      </AG>
    </Svg>
  )
}
export default QuickSwitcher

Give the gestures and motion a run on a real Android phone.

Back to Watermelon UI