Skip to content
pixelfor Expo
Ctrl K

Interaction

Swap Currency Card

An expressive interaction rebuilt for touch

Try it on your phone

Swap Currency Card

Scan to open Swap Currency Card 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.

swap-currency-card.in-progress.tsx

import React, {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
} from "react"
import {
  Platform,
  Modal,
  Keyboard,
  Pressable,
  Text,
  TextInput,
  View,
  useColorScheme,
  useWindowDimensions,
  type StyleProp,
  type ViewStyle,
} from "react-native"
import Animated, {
  type AnimationObject,
  Easing,
  defineAnimation,
  interpolateColor,
  useAnimatedProps,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
  type SharedValue,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import Svg, {
  Defs,
  Filter,
  FeGaussianBlur,
  G,
  Path,
  Rect,
  SvgXml,
  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
}
const WEB = Platform.OS === "web",
  FONT = WEB ? "Arial" : Platform.OS === "ios" ? "Helvetica" : "sans-serif"
const AG = Animated.createAnimatedComponent(G),
  AR = Animated.createAnimatedComponent(Rect)
const CSS = Easing.bezier(0.4, 0, 0.2, 1),
  MENU = Easing.bezier(0, 0, 0.58, 1)
const clamp = (n: number) => {
  "worklet"
  return Math.max(0, Math.min(1, n))
}
const DIGIT_FREQUENCY = Math.log(0.8 / 0.6 / 0.001) / (0.8 * 0.4)
function digitEase(progress: number) {
  "worklet"
  const t = progress * 0.4,
    a = DIGIT_FREQUENCY * 0.6,
    d = DIGIT_FREQUENCY * 0.8
  return progress >= 1
    ? 1
    : 1 - Math.exp(-d * t) * (Math.cos(a * t) + (d / a) * Math.sin(a * t))
}
type EntranceSpring = {
  current: number
  origin: number
  started: number
  factor: number
  onStart: (a: EntranceSpring, value: number, time: number) => void
  onFrame: (a: EntranceSpring, time: number) => boolean
}
function entranceSpring(target: number, normalize = false): number {
  "worklet"
  return defineAnimation<EntranceSpring>(target, () => ({
    current: target,
    origin: target,
    started: 0,
    factor: 1,
    onStart(a, value, time) {
      a.current = a.origin = value
      a.started = time
      a.factor =
        normalize && value !== target ? 100 / Math.abs(value - target) : 1
    },
    onFrame(a, time) {
      const t = (time - a.started) / 1000,
        x = a.origin - target,
        env = Math.exp(-10 * t),
        c = Math.cos(10 * t),
        s = Math.sin(10 * t)
      a.current = target + x * env * (c + s)
      const velocity = -20 * x * env * s,
        small = Math.abs(x) * a.factor < 5
      if (
        Math.abs(velocity) * a.factor <= (small ? 0.01 : 2) &&
        Math.abs(a.current - target) * a.factor <= (small ? 0.005 : 0.5)
      ) {
        a.current = target
        return true
      }
      return false
    },
  })) as unknown as number
}
class BlurTarget extends FeGaussianBlur {
  setNativeProps = (
    props: React.ComponentProps<typeof FeGaussianBlur> & {
      style?: React.ComponentProps<typeof FeGaussianBlur>
    }
  ) => {
    if (WEB) {
      const amount = props.style?.stdDeviation ?? props.stdDeviation
      const node = (
        this as unknown as {
          elementRef?: {
            current: { setAttribute: (k: string, v: string) => void } | null
          }
        }
      ).elementRef?.current
      if (amount !== undefined)
        node?.setAttribute("stdDeviation", String(amount))
    } else this.root?.setNativeProps(props)
  }
}
const AB = Animated.createAnimatedComponent(BlurTarget)
function Blur({ id, value }: { id: string; value: SharedValue<number> }) {
  const props = useAnimatedProps(() => ({
    stdDeviation: Math.max(0, value.value),
  }))
  return (
    <Filter id={id} x="-50%" y="-50%" width="200%" height="200%">
      <AB animatedProps={props} />
    </Filter>
  )
}
export interface Currency {
  code: string
  countryCode: string
  flag: string
  rate: number
  name: string
}
export type SwapCurrencyColors = {
  surface: string
  border: string
  title: string
  field: string
  number: string
  selector: string
  selectorBorder: string
  selectorText: string
  muted: string
  menuHover: string
  proceed: string
  proceedHover: string
  proceedText: string
  shadow: string
}
export type SwapCurrencyCardProps = {
  currencies: Currency[]
  defaultFromCode?: string
  defaultToCode?: string
  defaultAmount?: string
  theme?: "light" | "dark" | "system"
  colors?: Partial<SwapCurrencyColors>
  size?: "auto" | "compact" | "large"
  width?: number
  radius?: number
  fontFamily?: string
  style?: StyleProp<ViewStyle>
  testID?: string
  onProceed?: (result: {
    from: Currency
    to: Currency
    fromAmount: string
    toAmount: string
  }) => void
}
const LIGHT: SwapCurrencyColors = {
  surface: "#FEFEFE",
  border: "#E5E5E9",
  title: "#898990",
  field: "#F6F5FA",
  number: "#010103",
  selector: "#fefefe",
  selectorBorder: "#E5E5E9",
  selectorText: "#374151",
  muted: "#9ca3af",
  menuHover: "#f9fafb",
  proceed: "#262629",
  proceedHover: "#000",
  proceedText: "#fff",
  shadow: "rgba(0,0,0,.1)",
}
const DARK: SwapCurrencyColors = {
  surface: "#18181b",
  border: "#27272a",
  title: "#71717a",
  field: "rgba(39,39,42,.5)",
  number: "#fff",
  selector: "#27272a",
  selectorBorder: "#3f3f46",
  selectorText: "#e4e4e7",
  muted: "#71717a",
  menuHover: "rgba(63,63,70,.5)",
  proceed: "#f4f4f5",
  proceedHover: "#fff",
  proceedText: "#09090b",
  shadow: "rgba(0,0,0,.5)",
}
const flagRequests = new Map<string, Promise<string>>()
function flagXml(source: string, prefix: string) {
  let xml = source
  const root = xml.match(/<svg\b[^>]*>/)?.[0] ?? ""
  if (!/\bviewBox=/.test(root)) {
    const width = parseFloat(root.match(/\bwidth=["']([^"']+)/)?.[1] ?? ""),
      height = parseFloat(root.match(/\bheight=["']([^"']+)/)?.[1] ?? "")
    if (width > 0 && height > 0)
      xml = xml.replace(/<svg\b/, `<svg viewBox="0 0 ${width} ${height}"`)
  }
  const ids = new Set(
    [...xml.matchAll(/\bid=["']([^"']+)["']/g)].map((m) => m[1])
  )
  xml = xml.replace(
    /\bid=(["'])([^"']+)\1/g,
    (_, quote, id) => `id=${quote}${prefix}-${id}${quote}`
  )
  xml = xml.replace(/#([^"'\s)<>]+)/g, (match, id) =>
    ids.has(id) ? `#${prefix}-${id}` : match
  )
  return xml
}
function Flag({
  currency,
  x,
  y,
  large,
}: {
  currency: Currency
  x: number
  y: number
  large: boolean
}) {
  const [failed, setFailed] = useState(false),
    [xml, setXml] = useState<string | null>(null),
    id = useId().replace(/[^\w-]/g, "")
  const w = large ? 24 : 20,
    h = large ? 20 : 16,
    uri =
      currency.countryCode === "eu"
        ? "https://upload.wikimedia.org/wikipedia/commons/b/b7/Flag_of_Europe.svg"
        : `https://flagcdn.com/${currency.countryCode.toLowerCase()}.svg`
  useEffect(() => {
    let alive = true
    setFailed(false)
    setXml(null)
    if (!currency.countryCode) return
    let request = flagRequests.get(uri)
    if (!request) {
      request = fetch(uri).then((response) => {
        if (!response.ok) throw new Error("Flag download failed")
        return response.text()
      })
      flagRequests.set(uri, request)
    }
    request
      .then((source) => {
        if (alive) setXml(flagXml(source, `flag-${id}`))
      })
      .catch(() => {
        flagRequests.delete(uri)
        if (alive) setFailed(true)
      })
    return () => {
      alive = false
    }
  }, [currency.countryCode, uri, id])
  return (
    <G transform={`translate(${x} ${y})`}>
      {!currency.countryCode || failed ? (
        <SvgText
          x={w / 2}
          y={h * 0.85}
          fontSize={large ? 14 : 12}
          textAnchor="middle"
        >
          {currency.flag}
        </SvgText>
      ) : (
        <Svg
          x={0}
          y={0}
          width={w}
          height={h}
          viewBox={`0 0 ${w} ${h}`}
          style={{ overflow: "hidden" }}
        >
          {xml && (
            <SvgXml
              xml={xml}
              width={w}
              height={h}
              preserveAspectRatio="xMidYMid slice"
              onError={() => setFailed(true)}
            />
          )}
        </Svg>
      )}
      {currency.countryCode && (
        <Rect
          width={w}
          height={h}
          rx={2}
          fill="none"
          stroke="#3f3f46"
          strokeWidth={0.8}
        />
      )}
    </G>
  )
}
type DigitLayer = {
  id: number
  char: string
  initial: boolean
  exiting: boolean
  delay: number
}
function Digit({
  layer,
  delay,
  width,
  font,
  color,
  family,
  remove,
}: {
  layer: DigitLayer
  delay: number
  width: number
  font: number
  color: string
  family: string
  remove: (id: number) => void
}) {
  const id = useId().replace(/:/g, ""),
    opacity = useSharedValue(layer.initial ? 1 : 0),
    y = useSharedValue(layer.initial ? 0 : -10),
    scale = useSharedValue(layer.initial ? 1 : 0.65),
    blur = useSharedValue(layer.initial ? 0 : 2)
  useLayoutEffect(() => {
    const timing = { duration: 400, easing: digitEase }
    y.value = motionDelay(delay, withTiming(0, timing))
    scale.value = motionDelay(delay, withTiming(1, timing))
  }, [])
  useLayoutEffect(() => {
    const timing = { duration: 400, easing: digitEase }
    blur.value = motionDelay(delay, withTiming(layer.exiting ? 2 : 0, timing))
    opacity.value = motionDelay(
      delay,
      withTiming(layer.exiting ? 0 : 1, timing, (finished) => {
        "worklet"
        if (finished && layer.exiting) scheduleOnRN(remove, layer.id)
      })
    )
  }, [layer.exiting])
  const props = useAnimatedProps(() => ({
    opacity: clamp(opacity.value),
    transform: `translate(${width / 2} ${y.value}) scale(${scale.value})`,
  }))
  return (
    <>
      <Defs>
        <Blur id={id} value={blur} />
      </Defs>
      <AG animatedProps={props} filter={`url(#${id})`}>
        <SvgText
          y={font * 0.35}
          fontFamily={family}
          fontSize={font}
          fontWeight="700"
          fill={color}
          textAnchor="middle"
        >
          {layer.char}
        </SvgText>
      </AG>
    </>
  )
}
function Column({
  char,
  delay,
  font,
  color,
  family,
}: {
  char: string
  delay: number
  font: number
  color: string
  family: string
}) {
  const serial = useRef(0),
    last = useRef(char),
    [layers, setLayers] = useState<DigitLayer[]>([
      { id: 0, char, initial: true, exiting: false, delay },
    ])
  useLayoutEffect(() => {
    if (char === last.current) {
      setLayers((old) =>
        old.every((layer) => layer.exiting || layer.delay === delay)
          ? old
          : old.map((layer) => (layer.exiting ? layer : { ...layer, delay }))
      )
      return
    }
    last.current = char
    setLayers((old) => {
      const returning = old.some((layer) => layer.char === char)
      const next = old.map((layer) => ({
        ...layer,
        exiting: layer.char !== char,
        delay: layer.char === char ? delay : layer.delay,
      }))
      return returning
        ? next
        : [
            ...next,
            {
              id: ++serial.current,
              char,
              initial: false,
              exiting: false,
              delay,
            },
          ]
    })
  }, [char, delay])
  const remove = (id: number) =>
    setLayers((old) => old.filter((l) => l.id !== id || !l.exiting))
  return (
    <>
      {layers.map((layer) => (
        <Digit
          key={layer.id}
          layer={layer}
          delay={layer.delay}
          width={font * 0.6}
          font={font}
          color={color}
          family={family}
          remove={remove}
        />
      ))}
    </>
  )
}
function Amount({
  value,
  x,
  y,
  font,
  color,
  family,
}: {
  value: string
  x: number
  y: number
  font: number
  color: string
  family: string
}) {
  const chars = [...(value || "0")]
  let offset = 0
  return (
    <G transform={`translate(${x} ${y})`}>
      {chars.map((char, index) => {
        const numeric = !Number.isNaN(Number(char)),
          at = offset
        offset += font * (numeric ? 0.6 : 0.54)
        return (
          <G key={index} transform={`translate(${at} 0)`}>
            {numeric ? (
              <Column
                char={char}
                delay={(chars.length - 1 - index) * 30}
                font={font}
                color={color}
                family={family}
              />
            ) : (
              <SvgText
                x={font * 0.27}
                y={font * 0.35}
                fontFamily={family}
                fontSize={font}
                fontWeight="700"
                textAnchor="middle"
                fill={color}
              >
                {char}
              </SvgText>
            )}
          </G>
        )
      })}
    </G>
  )
}
function SelectorVisual({
  currency,
  x,
  y,
  w,
  h,
  large,
  open,
  pressed,
  colors,
  family,
}: {
  currency: Currency
  x: number
  y: number
  w: number
  h: number
  large: boolean
  open: boolean
  pressed: SharedValue<number>
  colors: SwapCurrencyColors
  family: string
}) {
  const angle = useSharedValue(0),
    font = large ? 14 : 12,
    flagW = large ? 24 : 20,
    gap = large ? 8 : 6,
    pad = large ? 12 : 10,
    icon = large ? 20 : 16
  useEffect(() => {
    angle.value = withTiming(open ? 180 : 0, { duration: 150, easing: CSS })
  }, [open])
  const props = useAnimatedProps(() => ({
    transform: `translate(${x + w / 2} ${y + h / 2}) scale(${pressed.value}) translate(${-w / 2} ${-h / 2})`,
  }))
  const chevron = useAnimatedProps(() => ({
    transform: `translate(${w - pad - icon / 2} ${h / 2}) rotate(${angle.value}) scale(${icon / 24})`,
  }))
  return (
    <AG animatedProps={props}>
      <Rect
        width={w}
        height={h}
        rx={h / 2}
        fill={colors.selector}
        stroke={colors.selectorBorder}
        strokeWidth={0.8}
      />
      <Flag
        currency={currency}
        x={pad}
        y={(h - (large ? 20 : 16)) / 2}
        large={large}
      />
      <SvgText
        x={pad + flagW + gap}
        y={h / 2 + font * 0.35}
        fontFamily={family}
        fontSize={font}
        fontWeight="600"
        fill={colors.selectorText}
      >
        {currency.code}
      </SvgText>
      <AG animatedProps={chevron}>
        <Path
          d="m-6 -3 6 6 6-6"
          stroke="#9ca3af"
          fill="none"
          strokeWidth={2}
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </AG>
    </AG>
  )
}
function MenuRow({
  currency,
  selected,
  x,
  y,
  width,
  height,
  large,
  colors,
  family,
  hover,
}: {
  currency: Currency
  selected: boolean
  x: number
  y: number
  width: number
  height: number
  large: boolean
  colors: SwapCurrencyColors
  family: string
  hover: boolean
}) {
  const progress = useSharedValue(0),
    pad = large ? 16 : 12,
    flagW = large ? 24 : 20,
    gap = large ? 12 : 8,
    font = large ? 14 : 12
  useEffect(() => {
    progress.value = withTiming(hover ? 1 : 0, { duration: 150, easing: CSS })
  }, [hover])
  const bg = useAnimatedProps(() => ({
    fill: interpolateColor(
      progress.value,
      [0, 1],
      ["transparent", colors.menuHover]
    ),
  }))
  return (
    <G transform={`translate(${x} ${y})`}>
      <AR width={width} height={height} animatedProps={bg} />
      <Flag
        currency={currency}
        x={pad}
        y={(height - (large ? 20 : 16)) / 2}
        large={large}
      />
      <SvgText
        x={pad + flagW + gap}
        y={height / 2 + font * 0.35}
        fontFamily={family}
        fontSize={font}
        fontWeight="500"
        fill={colors.selectorText}
      >
        {currency.code}
      </SvgText>
      {selected && (
        <Path
          d="m-5 0 3 3 6-6"
          transform={`translate(${width - pad - 8} ${height / 2}) scale(${large ? 1 : 0.875})`}
          fill="none"
          stroke={colors.muted}
          strokeWidth={2}
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      )}
    </G>
  )
}
function MenuVisual({
  open,
  close,
  x,
  y,
  width,
  height,
  rows,
  selected,
  large,
  colors,
  family,
  hover,
}: {
  open: boolean
  close: () => void
  x: number
  y: number
  width: number
  height: number
  rows: Currency[]
  selected: Currency
  large: boolean
  colors: SwapCurrencyColors
  family: string
  hover: number | null
}) {
  const id = useId().replace(/:/g, ""),
    progress = useSharedValue(0),
    blur = useSharedValue(4),
    rowHeight = large ? 40 : 32
  useLayoutEffect(() => {
    blur.value = withTiming(open ? 0 : 4, { duration: 200, easing: MENU })
    progress.value = withTiming(
      open ? 1 : 0,
      { duration: 200, easing: MENU },
      (finished) => {
        "worklet"
        if (finished && !open) scheduleOnRN(close)
      }
    )
  }, [open])
  const props = useAnimatedProps(() => ({
    opacity: clamp(progress.value),
    transform: `translate(${x + width / 2} ${y + height / 2 - 8 * (1 - progress.value)}) scale(${0.96 + 0.04 * progress.value}) translate(${-width / 2} ${-height / 2})`,
  }))
  return (
    <>
      <Defs>
        <Blur id={id} value={blur} />
        <Filter
          id={`${id}shadowA`}
          x="-50%"
          y="-50%"
          width="200%"
          height="200%"
        >
          <FeGaussianBlur stdDeviation={7.5} />
        </Filter>
        <Filter
          id={`${id}shadowB`}
          x="-50%"
          y="-50%"
          width="200%"
          height="200%"
        >
          <FeGaussianBlur stdDeviation={3} />
        </Filter>
      </Defs>
      <AG animatedProps={props} filter={`url(#${id})`}>
        <Rect
          x={3}
          y={13}
          width={width - 6}
          height={height - 6}
          rx={Math.max(0, (large ? 16 : 12) - 3)}
          fill="rgba(0,0,0,.1)"
          filter={`url(#${id}shadowA)`}
        />
        <Rect
          x={4}
          y={8}
          width={width - 8}
          height={height - 8}
          rx={Math.max(0, (large ? 16 : 12) - 4)}
          fill="rgba(0,0,0,.1)"
          filter={`url(#${id}shadowB)`}
        />
        <Rect
          width={width}
          height={height}
          rx={large ? 16 : 12}
          fill={colors.selector}
          stroke={colors.selectorBorder}
          strokeWidth={1.6}
        />
        {rows.map((currency, i) => (
          <MenuRow
            key={currency.code}
            currency={currency}
            selected={currency.code === selected.code}
            x={1.6}
            y={5.6 + i * rowHeight}
            width={width - 3.2}
            height={rowHeight}
            large={large}
            colors={colors}
            family={family}
            hover={hover === i}
          />
        ))}
      </AG>
    </>
  )
}
function convert(value: string, from: Currency, to: Currency) {
  const n = parseFloat(value)
  return Number.isNaN(n) ? "" : ((n / from.rate) * to.rate).toFixed(2)
}
function fieldPath(
  x: number,
  y: number,
  w: number,
  h: number,
  top: number,
  bottom: number
) {
  return `M${x + top} ${y}H${x + w - top}A${top} ${top} 0 0 1 ${x + w} ${y + top}V${y + h - bottom}A${bottom} ${bottom} 0 0 1 ${x + w - bottom} ${y + h}H${x + bottom}A${bottom} ${bottom} 0 0 1 ${x} ${y + h - bottom}V${y + top}A${top} ${top} 0 0 1 ${x + top} ${y}Z`
}
export function SwapCurrencyCard({
  currencies,
  defaultFromCode,
  defaultToCode,
  defaultAmount = "10",
  theme = "system",
  colors: overrides,
  size = "auto",
  width: customWidth,
  radius: customRadius,
  fontFamily = FONT,
  style,
  testID,
  onProceed,
}: SwapCurrencyCardProps) {
  const window = useWindowDimensions(),
    system = useColorScheme(),
    large = size === "large" || (size === "auto" && window.width >= 640),
    colors = {
      ...(theme === "dark" || (theme === "system" && system === "dark")
        ? DARK
        : LIGHT),
      ...overrides,
    }
  const [from, setFrom] = useState(
      () => currencies.find((c) => c.code === defaultFromCode) || currencies[0]
    ),
    [to, setTo] = useState(
      () => currencies.find((c) => c.code === defaultToCode) || currencies[1]
    ),
    [fromAmount, setFromAmount] = useState(defaultAmount),
    [toAmount, setToAmount] = useState("")
  const [menu, setMenu] = useState<0 | 1 | null>(null),
    [mounted, setMounted] = useState<[boolean, boolean]>([false, false]),
    [hover, setHover] = useState<number | null>(null)
  const [labelWidths, setLabelWidths] = useState<Record<string, number>>({})
  const labelKey = (code: string) => `${fontFamily}|${large}|${code}`
  const measureLabel = (code: string, value: number) => {
    if (value <= 0) return
    const key = labelKey(code)
    setLabelWidths((old) =>
      old[key] === value ? old : { ...old, [key]: value }
    )
  }
  const cardRef = useRef<View>(null)
  const amountRefs = useRef<Array<TextInput | null>>([null, null])
  const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null)
  const menuRef = useRef(menu)
  menuRef.current = menu
  useEffect(() => {
    if (!WEB) return
    const close = (event: MouseEvent) => {
      const card = cardRef.current as unknown as HTMLElement | null
      if (card && !card.contains(event.target as Node)) setMenu(null)
    }
    document.addEventListener("mousedown", close)
    return () => document.removeEventListener("mousedown", close)
  }, [])
  const entrance = useSharedValue(0),
    entranceScale = useSharedValue(0.95),
    blur = useSharedValue(8),
    fromPress = useSharedValue(1),
    toPress = useSharedValue(1),
    proceedPress = useSharedValue(1),
    proceedHover = useSharedValue(0),
    id = useId().replace(/:/g, "")
  useEffect(() => {
    entrance.value = entranceSpring(1, true)
    entranceScale.value = entranceSpring(1)
    blur.value = entranceSpring(0, true)
  }, [])
  useEffect(() => {
    const frame = requestAnimationFrame(() =>
      setToAmount(convert(fromAmount, from, to))
    )
    return () => cancelAnimationFrame(frame)
  }, [fromAmount, from, to])
  const width = customWidth ?? (large ? 384 : 320),
    border = 1.6,
    pad = large ? 32 : 24,
    gap = large ? 24 : 20,
    inside = width - 2 * (pad + border),
    titleH = large ? 31.1111 : 28,
    selectorH = large ? 37.6 : 29.6,
    rowPad = large ? 16 : 14,
    rowH = selectorH + 2 * rowPad,
    rowGap = large ? 8 : 6,
    buttonH = large ? 59 : 52,
    footerH = large ? 24 : 20
  const contentX = pad + border,
    titleY = contentX,
    row1Y = titleY + titleH + gap,
    row2Y = row1Y + rowH + rowGap,
    buttonY = row2Y + rowH + gap,
    footerY = buttonY + buttonH + gap,
    height = footerY + footerH + pad + border,
    radius = customRadius ?? (large ? 40 : 32),
    numberFont = large ? 24 : 20
  const selectorWidths = [from, to].map(
      (currency) =>
        (large ? 85.6 : 69.6) +
        (labelWidths[labelKey(currency.code)] ??
          currency.code.length * (large ? 9.85417 : 8.44643))
    ),
    selectorXs = selectorWidths.map((w) => contentX + inside - rowPad - w),
    selectorYs = [row1Y + rowPad, row2Y + rowPad],
    menuWidth = large ? 192 : 160,
    menuRowH = large ? 40 : 32,
    menuHeight = currencies.length * menuRowH + 11.2,
    menuX = contentX + inside - rowPad - menuWidth,
    menuYs = selectorYs.map((y) => y + selectorH + 8)
  const measureAnchor = () =>
    cardRef.current?.measureInWindow((x, y) => {
      const scale = entranceScale.value
      setAnchor({
        x: x - (width * (1 - scale)) / 2,
        y: y - (height * (1 - scale)) / 2,
      })
    })
  useEffect(() => {
    if (WEB) return
    const listener = Keyboard.addListener("keyboardDidHide", () => {
      if (menuRef.current !== null) measureAnchor()
    })
    return () => listener.remove()
  }, [width, height])
  const rootStyle = useAnimatedStyle(() => ({
    opacity: clamp(entrance.value),
    transform: [{ scale: entranceScale.value }],
  }))
  const proceedGroup = useAnimatedProps(() => ({
    transform: `translate(${contentX + inside / 2} ${buttonY + buttonH / 2}) scale(${proceedPress.value}) translate(${-inside / 2} ${-buttonH / 2})`,
  }))
  const proceedFill = useAnimatedProps(() => ({
    fill: interpolateColor(
      proceedHover.value,
      [0, 1],
      [colors.proceed, colors.proceedHover]
    ),
  }))
  const openMenu = (index: 0 | 1) => {
    const next = menu === index ? null : index
    setMenu(next)
    setHover(null)
    if (next !== null)
      setMounted((old) => {
        const n = [...old] as [boolean, boolean]
        n[next] = true
        return n
      })
    if (!WEB && next !== null) {
      Keyboard.dismiss()
      measureAnchor()
    }
  }
  const unmount = (index: 0 | 1) => {
    if (menuRef.current === index) return
    setMounted((old) => {
      const n = [...old] as [boolean, boolean]
      n[index] = false
      return n
    })
  }
  const change = (value: string, index: 0 | 1) => {
    if (value !== "" && !/^\d*\.?\d*$/.test(value)) return
    if (index === 0) {
      setFromAmount(value)
      setToAmount(convert(value, from, to))
    } else {
      setToAmount(value)
      setFromAmount(convert(value, to, from))
    }
  }
  const choose = (currency: Currency, index: 0 | 1) => {
    if (index === 0) {
      setFrom(currency)
      setToAmount(convert(fromAmount, currency, to))
    } else {
      setTo(currency)
      setToAmount(convert(fromAmount, from, currency))
    }
    setMenu(null)
    setHover(null)
  }
  const press = (shared: SharedValue<number>, value: number) => {
    shared.value = withTiming(value, { duration: 150, easing: CSS })
  }
  if (currencies.length < 2)
    throw new Error("SwapCurrencyCard requires at least two currencies")
  return (
    <Animated.View
      testID={testID}
      ref={cardRef}
      collapsable={false}
      style={[
        { width, height, alignSelf: "center", overflow: "visible" },
        style,
        rootStyle,
      ]}
    >
      <Svg
        width={width}
        height={height}
        style={{ position: "absolute", left: 0, top: 0, overflow: "visible" }}
        pointerEvents="none"
      >
        <Defs>
          <Blur id={id} value={blur} />
          <Filter
            id={`${id}shadow`}
            x="-70%"
            y="-70%"
            width="240%"
            height="240%"
          >
            <FeGaussianBlur stdDeviation={large ? 30 : 23.5} />
          </Filter>
          <Filter
            id={`${id}actionShadowA`}
            x="-50%"
            y="-100%"
            width="200%"
            height="300%"
          >
            <FeGaussianBlur stdDeviation={7.5} />
          </Filter>
          <Filter
            id={`${id}actionShadowB`}
            x="-50%"
            y="-100%"
            width="200%"
            height="300%"
          >
            <FeGaussianBlur stdDeviation={3} />
          </Filter>
        </Defs>
        <G filter={`url(#${id})`}>
          <Rect
            x={16}
            y={48}
            width={width - 32}
            height={height - 32}
            rx={Math.max(0, radius - 16)}
            fill={colors.shadow}
            filter={`url(#${id}shadow)`}
          />
          <Rect
            x={border / 2}
            y={border / 2}
            width={width - border}
            height={height - border}
            rx={radius}
            fill={colors.surface}
            stroke={colors.border}
            strokeWidth={border}
          />
          <SvgText
            x={contentX}
            y={titleY + titleH / 2 + (large ? 20 : 18) * 0.35}
            fontFamily={fontFamily}
            fontSize={large ? 20 : 18}
            fontWeight="600"
            fill={colors.title}
          >
            Swap Currency
          </SvgText>
          {[row1Y, row2Y].map((y, index) => (
            <G key={index}>
              <Path
                d={fieldPath(
                  contentX,
                  y,
                  inside,
                  rowH,
                  index === 0 ? (large ? 24 : 32) : large ? 24 : 16,
                  index === 0 ? (large ? 24 : 16) : large ? 24 : 32
                )}
                fill={colors.field}
              />
              <Amount
                value={index === 0 ? fromAmount : toAmount}
                x={contentX + rowPad}
                y={y + rowH / 2}
                font={numberFont}
                color={colors.number}
                family={fontFamily}
              />
              <SelectorVisual
                currency={index === 0 ? from : to}
                x={selectorXs[index]}
                y={selectorYs[index]}
                w={selectorWidths[index]}
                h={selectorH}
                large={large}
                open={menu === index}
                pressed={index === 0 ? fromPress : toPress}
                colors={colors}
                family={fontFamily}
              />
            </G>
          ))}
          <AG animatedProps={proceedGroup}>
            <Rect
              x={3}
              y={13}
              width={inside - 6}
              height={buttonH - 6}
              rx={(large ? 22 : 16) - 3}
              fill="rgba(0,0,0,.1)"
              filter={`url(#${id}actionShadowA)`}
            />
            <Rect
              x={4}
              y={8}
              width={inside - 8}
              height={buttonH - 8}
              rx={(large ? 22 : 16) - 4}
              fill="rgba(0,0,0,.1)"
              filter={`url(#${id}actionShadowB)`}
            />
            <AR
              width={inside}
              height={buttonH}
              rx={large ? 22 : 16}
              animatedProps={proceedFill}
            />
            <SvgText
              x={inside / 2}
              y={buttonH / 2 + (large ? 18 : 16) * 0.35}
              textAnchor="middle"
              fontFamily={fontFamily}
              fontSize={large ? 18 : 16}
              fontWeight="600"
              fill={colors.proceedText}
            >
              Proceed
            </SvgText>
          </AG>
          <SvgText
            x={width / 2}
            y={footerY + footerH / 2 + (large ? 16 : 14) * 0.35}
            textAnchor="middle"
            fontFamily={fontFamily}
            fontSize={large ? 16 : 14}
            fontWeight="500"
            fill={theme === "light" ? "#9F9EA1" : colors.muted}
          >
            1 {from.code} ≈ {(to.rate / from.rate).toFixed(2)} {to.code}
          </SvgText>
          {mounted.map(
            (visible, index) =>
              visible && (
                <MenuVisual
                  key={index}
                  open={menu === index}
                  close={() => unmount(index as 0 | 1)}
                  x={menuX}
                  y={menuYs[index]}
                  width={menuWidth}
                  height={menuHeight}
                  rows={currencies}
                  selected={index === 0 ? from : to}
                  large={large}
                  colors={colors}
                  family={fontFamily}
                  hover={menu === index ? hover : null}
                />
              )
          )}
        </G>
      </Svg>
      {WEB && menu !== null && (
        <Pressable
          accessibilityRole="button"
          accessibilityLabel="Dismiss currency menu"
          onPress={() => setMenu(null)}
          style={{ position: "absolute", left: 0, top: 0, width, height }}
        />
      )}
      {[row1Y, row2Y].map((y, index) => (
        <TextInput
          key={index}
          ref={(ref) => {
            amountRefs.current[index] = ref
          }}
          accessibilityLabel={index === 0 ? "From amount" : "To amount"}
          value={index === 0 ? fromAmount : toAmount}
          onChangeText={(value) => change(value, index as 0 | 1)}
          onFocus={() => setMenu(null)}
          onPressIn={() => setMenu(null)}
          keyboardType="decimal-pad"
          selectionColor={colors.number}
          cursorColor={colors.number}
          style={{
            position: "absolute",
            left: contentX + rowPad,
            top: y + rowH / 2 - 16,
            width: selectorXs[index] - (contentX + rowPad) - 8,
            height: 32,
            padding: 0,
            fontFamily,
            fontSize: numberFont,
            fontWeight: "600",
            ...(WEB ? { caretColor: colors.number } : {}),
            letterSpacing: numberFont * 0.08,
            color: "transparent",
            backgroundColor: "transparent",
            borderWidth: 0,
          }}
        />
      ))}
      <Pressable
        accessibilityRole="button"
        accessibilityLabel="Proceed"
        onPress={() => {
          setMenu(null)
          onProceed?.({ from, to, fromAmount, toAmount })
        }}
        onPressIn={() => press(proceedPress, 0.98)}
        onPressOut={() => press(proceedPress, 1)}
        onHoverIn={() => {
          proceedHover.value = withTiming(1, { duration: 150, easing: CSS })
        }}
        onHoverOut={() => {
          proceedHover.value = withTiming(0, { duration: 150, easing: CSS })
        }}
        style={{
          position: "absolute",
          left: contentX,
          top: buttonY,
          width: inside,
          height: buttonH,
        }}
      />
      {([0, 1] as const).map((index) => (
        <Pressable
          key={index}
          accessibilityRole="button"
          accessibilityLabel={`${index === 0 ? "From" : "To"} currency ${index === 0 ? from.code : to.code}`}
          accessibilityState={{ expanded: menu === index }}
          onPress={() => openMenu(index)}
          onPressIn={() => press(index === 0 ? fromPress : toPress, 0.95)}
          onPressOut={() => press(index === 0 ? fromPress : toPress, 1)}
          style={{
            position: "absolute",
            left: selectorXs[index],
            top: selectorYs[index],
            width: selectorWidths[index],
            height: selectorH,
          }}
        />
      ))}
      {menu !== null && (
        <View
          style={{
            position: "absolute",
            left: menuX,
            top: menuYs[menu],
            width: menuWidth,
            height: menuHeight,
            padding: 5.6,
          }}
        >
          {currencies.map((currency, index) => (
            <Pressable
              key={currency.code}
              accessibilityRole="button"
              accessibilityLabel={currency.name}
              accessibilityState={{
                selected: currency.code === (menu === 0 ? from : to).code,
              }}
              onPress={() => choose(currency, menu)}
              onHoverIn={() => setHover(index)}
              onHoverOut={() => setHover(null)}
              style={{ height: menuRowH }}
            />
          ))}
        </View>
      )}
      {!WEB && (
        <Modal
          transparent
          animationType="none"
          visible={menu !== null && anchor !== null}
          onRequestClose={() => setMenu(null)}
          statusBarTranslucent
          hardwareAccelerated
        >
          <View style={{ flex: 1 }}>
            <Pressable
              accessibilityRole="button"
              accessibilityLabel="Dismiss currency menu"
              onPress={() => setMenu(null)}
              style={{
                position: "absolute",
                left: 0,
                top: 0,
                right: 0,
                bottom: 0,
              }}
            />
            {anchor && (
              <Animated.View
                pointerEvents="box-none"
                style={[
                  {
                    position: "absolute",
                    left: anchor.x,
                    top: anchor.y,
                    width,
                    height,
                  },
                  rootStyle,
                ]}
              >
                {[row1Y, row2Y].map((y, index) => (
                  <Pressable
                    key={`amount${index}`}
                    accessibilityRole="button"
                    accessibilityLabel={`Edit ${index === 0 ? "from" : "to"} amount`}
                    onPress={() => {
                      setMenu(null)
                      requestAnimationFrame(() =>
                        amountRefs.current[index]?.focus()
                      )
                    }}
                    style={{
                      position: "absolute",
                      left: contentX + rowPad,
                      top: y,
                      width: selectorXs[index] - contentX - rowPad - 8,
                      height: rowH,
                    }}
                  />
                ))}
                <Pressable
                  accessibilityRole="button"
                  accessibilityLabel="Proceed"
                  onPress={() => {
                    setMenu(null)
                    onProceed?.({ from, to, fromAmount, toAmount })
                  }}
                  onPressIn={() => press(proceedPress, 0.98)}
                  onPressOut={() => press(proceedPress, 1)}
                  style={{
                    position: "absolute",
                    left: contentX,
                    top: buttonY,
                    width: inside,
                    height: buttonH,
                  }}
                />
                {([0, 1] as const).map((index) => (
                  <Pressable
                    key={`selector${index}`}
                    accessibilityRole="button"
                    accessibilityLabel={`${index === 0 ? "From" : "To"} currency ${index === 0 ? from.code : to.code}`}
                    accessibilityState={{ expanded: menu === index }}
                    onPress={() => openMenu(index)}
                    onPressIn={() =>
                      press(index === 0 ? fromPress : toPress, 0.95)
                    }
                    onPressOut={() =>
                      press(index === 0 ? fromPress : toPress, 1)
                    }
                    style={{
                      position: "absolute",
                      left: selectorXs[index],
                      top: selectorYs[index],
                      width: selectorWidths[index],
                      height: selectorH,
                    }}
                  />
                ))}
                {menu !== null && (
                  <View
                    style={{
                      position: "absolute",
                      left: menuX,
                      top: menuYs[menu],
                      width: menuWidth,
                      height: menuHeight,
                      padding: 5.6,
                    }}
                  >
                    {currencies.map((currency, index) => (
                      <Pressable
                        key={currency.code}
                        accessibilityRole="button"
                        accessibilityLabel={currency.name}
                        accessibilityState={{
                          selected:
                            currency.code === (menu === 0 ? from : to).code,
                        }}
                        onPress={() => choose(currency, menu)}
                        style={{ height: menuRowH }}
                      />
                    ))}
                  </View>
                )}
              </Animated.View>
            )}
          </View>
        </Modal>
      )}
      <View
        pointerEvents="none"
        aria-hidden
        accessible={false}
        accessibilityElementsHidden
        importantForAccessibility="no-hide-descendants"
        style={{
          position: "absolute",
          width: 0,
          height: 0,
          overflow: "hidden",
        }}
      >
        <View style={{ width: 10000, alignItems: "flex-start" }}>
          {currencies.map((currency) => (
            <Text
              key={currency.code}
              allowFontScaling={false}
              style={{
                fontFamily,
                fontSize: large ? 14 : 12,
                fontWeight: "600",
              }}
              ref={(ref) => {
                if (WEB && ref)
                  measureLabel(
                    currency.code,
                    (ref as unknown as Element).getBoundingClientRect().width
                  )
              }}
              onLayout={(event) => {
                if (!WEB)
                  measureLabel(currency.code, event.nativeEvent.layout.width)
              }}
            >
              {currency.code}
            </Text>
          ))}
        </View>
      </View>
    </Animated.View>
  )
}

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

Back to Watermelon UI