Skip to content
pixelfor Expo
Ctrl K

Interaction

Show Qr

An expressive interaction rebuilt for touch

Try it on your phone

Show Qr

Scan to open Show Qr 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.

show-qr.tsx

import React, {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
} from "react"
import {
  Platform,
  Pressable,
  Text,
  View,
  useColorScheme,
  type StyleProp,
  type ViewStyle,
} from "react-native"
import Animated, {
  Easing,
  defineAnimation,
  useAnimatedProps,
  useAnimatedStyle,
  useDerivedValue,
  useSharedValue,
  withTiming,
  ReduceMotion,
  type SharedValue,
  type AnimationCallback,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import * as Clipboard from "expo-clipboard"
import QRCode from "react-native-qrcode-svg"
import Svg, {
  Defs,
  FeGaussianBlur,
  Filter,
  G,
  Path,
  Rect,
  Text as SvgText,
} from "react-native-svg"
const WEB = Platform.OS === "web",
  FONT = WEB ? "Arial" : Platform.OS === "ios" ? "Helvetica" : "sans-serif"
const FREQUENCY = (2 * Math.PI) / (0.35 * 1.2),
  STIFFNESS = FREQUENCY * FREQUENCY
const clamp = (n: number) => {
  "worklet"
  return Math.max(0, Math.min(1, n))
}
type SpringState = {
  current: number
  toValue: number
  origin: number
  started: number
  factor: number
  speed: number
  delta: number
  callback?: AnimationCallback
  onStart: (a: SpringState, v: number, now: number) => void
  onFrame: (a: SpringState, now: number) => boolean
}
function spring(
  target: number,
  stiffness = STIFFNESS,
  damping = FREQUENCY * 1.5,
  range?: number,
  delay = 0,
  callback?: AnimationCallback
): number {
  "worklet"
  return defineAnimation<SpringState>(target, () => {
    "worklet"
    return {
      current: target,
      toValue: target,
      origin: target,
      started: 0,
      factor: 1,
      speed: 2,
      delta: 0.5,
      callback,
      onStart(a, value, now) {
        a.current = a.origin = value
        a.started = now + delay
        const d = Math.abs(value - target)
        a.factor = range && d ? range / d : 1
        const small = d * a.factor < 5
        a.speed = small ? 0.01 : 2
        a.delta = small ? 0.005 : 0.5
      },
      onFrame(a, now) {
        if (now < a.started) return false
        const t = (now - a.started) / 1000,
          decay = damping / 2,
          angular = Math.sqrt(stiffness - decay * decay),
          x = a.origin - target,
          b = (decay * x) / angular,
          env = Math.exp(-decay * t),
          c = Math.cos(angular * t),
          s = Math.sin(angular * t)
        a.current = target + env * (x * c + b * s)
        const v =
          env * ((b * angular - decay * x) * c - (x * angular + decay * b) * s)
        if (
          Math.abs(v) * a.factor <= a.speed &&
          Math.abs(a.current - target) * a.factor <= a.delta
        ) {
          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 element = (
        this as unknown as {
          elementRef?: {
            current: {
              setAttribute: (key: string, value: string) => void
            } | null
          }
        }
      ).elementRef?.current
      if (amount !== undefined)
        element?.setAttribute("stdDeviation", String(amount))
    } else this.root?.setNativeProps(props)
  }
}
const AG = Animated.createAnimatedComponent(G),
  ABlur = Animated.createAnimatedComponent(BlurTarget)
function QrIcon({ color }: { color: string }) {
  return (
    <Svg width={24} height={24} viewBox="0 0 512 512">
      <G fill={color}>
        {[
          [336, 336, 80],
          [272, 272, 64],
          [416, 416, 64],
          [432, 272, 48],
          [272, 432, 48],
          [336, 96, 80],
          [96, 96, 80],
          [96, 336, 80],
        ].map(([x, y, size], i) => (
          <Rect key={i} x={x} y={y} width={size} height={size} rx={8} ry={8} />
        ))}
        {[
          [288, 48],
          [48, 48],
          [48, 288],
        ].map(([x, y], i) => (
          <Rect
            key={`frame${i}`}
            x={x}
            y={y}
            width={176}
            height={176}
            rx={16}
            ry={16}
            fill="none"
            stroke={color}
            strokeWidth={32}
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        ))}
      </G>
    </Svg>
  )
}
function LinkIcon({ color }: { color: string }) {
  return (
    <Svg width={24} height={24} viewBox="0 0 24 24">
      <Path
        d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"
        fill="none"
        stroke={color}
        strokeWidth={2}
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </Svg>
  )
}
export type ShowQrColors = {
  surface: string
  foreground: string
  qrSurface: string
  qrForeground: string
  qrBorder: string
  actionSurface: string
  actionBorder: string
}
export type ShowQrProps = {
  value: string
  buttonLabel?: string
  onCopy?: () => void
  onCopyError?: (error: unknown) => void
  theme?: "light" | "dark" | "system"
  colors?: Partial<ShowQrColors>
  collapsedWidth?: number
  expandedWidth?: number
  qrSize?: number
  radius?: number
  qrRadius?: number
  fontSize?: number
  actionFontSize?: number
  style?: StyleProp<ViewStyle>
  testID?: string
}
const LIGHT: ShowQrColors = {
  surface: "#F4F4F9",
  foreground: "#171717",
  qrSurface: "#fff",
  qrForeground: "#000",
  qrBorder: "#e5e7eb",
  actionSurface: "#fff",
  actionBorder: "#e5e7eb",
}
const DARK: ShowQrColors = {
  surface: "#1C1C1E",
  foreground: "#fff",
  qrSurface: "#0B0B0E",
  qrForeground: "#fff",
  qrBorder: "rgba(255,255,255,.1)",
  actionSurface: "#0a0a0a",
  actionBorder: "rgba(255,255,255,.1)",
}
function Trigger({
  width,
  label,
  labelWidth,
  fontSize,
  color,
  blur,
  opacity,
  exiting,
}: {
  width: SharedValue<number>
  label: string
  labelWidth: number
  fontSize: number
  color: string
  blur: SharedValue<number>
  opacity: SharedValue<number>
  exiting: boolean
}) {
  const id = useId().replace(/:/g, "")
  const group = useAnimatedProps(() => ({
    transform: `translate(${(width.value - labelWidth - 28) / 2} 24)`,
  }))
  const filter = useAnimatedProps(() => ({
    stdDeviation: 4 * clamp(blur.value / 100),
  }))
  const faded = useAnimatedProps(() => ({
    opacity: clamp(opacity.value / 100),
  }))
  return (
    <Svg width="100%" height={48} pointerEvents="none">
      <Defs>
        <Filter id={id} x="-20%" y="-60%" width="140%" height="220%">
          <ABlur animatedProps={filter} />
        </Filter>
      </Defs>
      <AG animatedProps={faded} filter={`url(#${id})`}>
        <AG animatedProps={group}>
          <G x={0} y={-12}>
            <QrIcon color={color} />
          </G>
          <SvgText
            x={28}
            y={fontSize * 0.35}
            fontFamily={FONT}
            fontSize={fontSize}
            fontWeight="500"
            fill={color}
          >
            {label}
          </SvgText>
        </AG>
      </AG>
    </Svg>
  )
}
type Glyph = {
  key: string
  char: string
  index: number
  local: number
  fresh: boolean
  exiting: boolean
}
function Letter({
  glyph,
  target,
  width,
  center,
  color,
  fontSize,
  remove,
}: {
  glyph: Glyph
  target: number
  width: number
  center: SharedValue<number>
  color: string
  fontSize: number
  remove: (key: string) => void
}) {
  const x = useSharedValue(target),
    y = useSharedValue(glyph.fresh ? 5 : 0),
    scale = useSharedValue(glyph.fresh ? 0.7 : 1),
    opacity = useSharedValue(glyph.fresh ? 0 : 100)
  useLayoutEffect(() => {
    x.value = spring(target, STIFFNESS, FREQUENCY * 1.5, 1000)
    if (glyph.exiting) {
      y.value = spring(-5)
      scale.value = spring(0.7)
      opacity.value = spring(
        0,
        STIFFNESS,
        FREQUENCY * 1.5,
        undefined,
        0,
        (finished) => {
          "worklet"
          if (finished) scheduleOnRN(remove, glyph.key)
        }
      )
    } else if (glyph.fresh) {
      y.value = spring(0, 200, 20, undefined, glyph.index * 30)
      scale.value = spring(1, 200, 20, undefined, glyph.index * 30)
      opacity.value = spring(100, 200, 20, undefined, glyph.index * 30)
    }
  }, [target, glyph.exiting, glyph.fresh, glyph.index])
  const props = useAnimatedProps(() => ({
    opacity: clamp(opacity.value / 100),
    transform: `matrix(${scale.value} 0 0 ${scale.value} ${center.value + x.value + (width / 2) * (1 - scale.value)} ${y.value})`,
  }))
  return (
    <AG animatedProps={props}>
      <SvgText
        x={0}
        y={fontSize * 0.35}
        fontFamily={FONT}
        fontSize={fontSize}
        fontWeight="500"
        letterSpacing={-fontSize * 0.025}
        fill={color}
      >
        {glyph.char}
      </SvgText>
    </AG>
  )
}
function CopyLabel({
  copied,
  widths,
  linkWidth,
  center,
  fontSize,
  color,
}: {
  copied: boolean
  widths: Record<string, number>
  linkWidth: number
  center: SharedValue<number>
  fontSize: number
  color: string
}) {
  const word = copied ? "Copied" : "Copy",
    wordWidth = [...word].reduce((n, c) => n + widths[c], 0),
    groupWidth = 24 + 8 + wordWidth + linkWidth
  const [glyphs, setGlyphs] = useState<Glyph[]>(() => {
    let local = 0
    return [...word].map((char, index) => {
      const entry = {
        key: char + index,
        char,
        index,
        local,
        fresh: false,
        exiting: false,
      }
      local += widths[char]
      return entry
    })
  })
  const groupLeft = -groupWidth / 2
  const iconX = useSharedValue(groupLeft),
    linkX = useSharedValue(groupLeft + 28 + wordWidth + 4)
  useLayoutEffect(() => {
    const keys = new Set([...word].map((c, i) => c + i))
    let local = 0
    setGlyphs((old) => {
      const fresh = [...word].map((char, index) => {
        const key = char + index
        const existing = old.find((g) => g.key === key && !g.exiting)
        const g = { key, char, index, local, fresh: !existing, exiting: false }
        local += widths[char]
        return g
      })
      return [
        ...fresh,
        ...old
          .filter((g) => !keys.has(g.key))
          .map((g) => ({ ...g, exiting: true })),
      ]
    })
    iconX.value = spring(groupLeft, STIFFNESS, FREQUENCY * 1.5, 1000)
    linkX.value = spring(
      groupLeft + 28 + wordWidth + 4,
      STIFFNESS,
      FREQUENCY * 1.5,
      1000
    )
  }, [word, groupWidth, widths])
  const icon = useAnimatedProps(() => ({
    transform: `translate(${center.value + iconX.value} -12)`,
  }))
  const link = useAnimatedProps(() => ({
    transform: `translate(${center.value + linkX.value} 0)`,
  }))
  const remove = (key: string) =>
    setGlyphs((old) => old.filter((g) => g.key !== key || !g.exiting))
  return (
    <>
      <AG animatedProps={icon}>
        <LinkIcon color={color} />
      </AG>
      {glyphs.map((g) => (
        <Letter
          key={g.key}
          glyph={g}
          target={groupLeft + 28 + (g.exiting ? Math.round(g.local) : g.local)}
          width={widths[g.char]}
          center={center}
          fontSize={fontSize}
          color={color}
          remove={remove}
        />
      ))}
      <AG animatedProps={link}>
        <SvgText
          x={0}
          y={fontSize * 0.35}
          fontFamily={FONT}
          fontSize={fontSize}
          fontWeight="500"
          fill={color}
        >
          Link
        </SvgText>
      </AG>
    </>
  )
}
export function ShowQr({
  value,
  buttonLabel = "Show QR Code",
  onCopy,
  onCopyError,
  theme = "system",
  colors: overrides,
  collapsedWidth = 180,
  expandedWidth = 250,
  qrSize = 220,
  radius = 32,
  qrRadius = 24,
  fontSize = 14,
  actionFontSize = 18,
  style,
  testID,
}: ShowQrProps) {
  const system = useColorScheme(),
    colors = {
      ...(theme === "dark" || (theme === "system" && system === "dark")
        ? DARK
        : LIGHT),
      ...overrides,
    }
  const [open, setOpen] = useState(false),
    [copied, setCopied] = useState(false),
    [bodyMounted, setBodyMounted] = useState(false),
    [triggerMounted, setTriggerMounted] = useState(true)
  const [border, setBorder] = useState(1),
    [labelWidth, setLabelWidth] = useState(97.275),
    [linkWidth, setLinkWidth] = useState(33.025)
  const [widths, setWidths] = useState<Record<string, number>>({
    C: 12.55,
    o: 9.5625,
    p: 9.5625,
    y: 8.55,
    i: 3.55,
    e: 9.5625,
    d: 9.5625,
  })
  const borderRef = useRef<View>(null),
    openRef = useRef(false),
    measuredHeight = useRef(0),
    generation = useRef(0),
    alive = useRef(true)
  const rootW = useSharedValue(collapsedWidth),
    rootH = useSharedValue(48),
    triggerWidth = useSharedValue(collapsedWidth)
  const triggerOpacity = useSharedValue(100),
    triggerBlur = useSharedValue(0),
    bodyOpacity = useSharedValue(0),
    qrY = useSharedValue(60),
    qrScale = useSharedValue(1.2),
    qrOpacity = useSharedValue(0)
  const actionHeight = (actionFontSize * 28) / 18 + 16 + 2 * border,
    closeWidth = 24 + 16 + 2 * border,
    bodyHeight = qrSize + 8 + actionHeight + 32
  const closeBody = () => {
    if (!openRef.current) setBodyMounted(false)
  }
  const closeTrigger = () => {
    if (openRef.current) setTriggerMounted(false)
  }
  useEffect(() => {
    alive.current = true
    return () => {
      alive.current = false
    }
  }, [])
  useEffect(() => {
    if (!copied) return
    const timer = setTimeout(() => setCopied(false), 2000)
    return () => clearTimeout(timer)
  }, [copied])
  useLayoutEffect(() => {
    if (WEB && borderRef.current) {
      const actual = parseFloat(
        window.getComputedStyle(borderRef.current as unknown as Element)
          .borderTopWidth
      )
      if (actual > 0 && actual !== border) setBorder(actual)
    }
  }, [bodyMounted, border])
  useLayoutEffect(() => {
    if (!open) triggerWidth.value = rootW.value
  }, [open])
  const toggle = (next: boolean) => {
    ++generation.current
    openRef.current = next
    setOpen(next)
    const damping = FREQUENCY * (next ? 1.5 : 1.7)
    rootW.value = spring(
      next ? expandedWidth : collapsedWidth,
      STIFFNESS,
      damping
    )
    if (next) {
      measuredHeight.current = 0
      if (bodyMounted) {
        measuredHeight.current = bodyHeight
        rootH.value = spring(bodyHeight)
      }
      triggerWidth.value = collapsedWidth
      triggerBlur.value = spring(100)
      if (!bodyMounted) {
        qrY.value = 60
        qrScale.value = 1.2
        qrOpacity.value = 0
      }
      setBodyMounted(true)
      bodyOpacity.value = spring(100)
      qrY.value = spring(0)
      qrScale.value = spring(1)
      qrOpacity.value = spring(100)
      triggerOpacity.value = spring(
        0,
        STIFFNESS,
        damping,
        undefined,
        0,
        (finished) => {
          "worklet"
          if (finished) scheduleOnRN(closeTrigger)
        }
      )
    } else {
      setCopied(false)
      setTriggerMounted(true)
      rootH.value = spring(48, STIFFNESS, damping)
      triggerOpacity.value = spring(100, STIFFNESS, damping)
      triggerBlur.value = spring(0, STIFFNESS, damping)
      bodyOpacity.value = withTiming(
        0,
        {
          duration: 200,
          easing: Easing.bezier(0, 0, 0.58, 1),
          reduceMotion: ReduceMotion.System,
        },
        (finished) => {
          "worklet"
          if (finished) scheduleOnRN(closeBody)
        }
      )
    }
  }
  const copy = async () => {
    const stamp = generation.current
    setCopied(true)
    onCopy?.()
    try {
      const success = await Clipboard.setStringAsync(value)
      if (!success) throw new Error("Clipboard write was not accepted")
    } catch (error) {
      if (alive.current && openRef.current && stamp === generation.current)
        setCopied(false)
      onCopyError?.(error)
    }
  }
  const rootStyle = useAnimatedStyle(() => ({
    width: rootW.value,
    height: rootH.value,
  }))
  const bodyStyle = useAnimatedStyle(() => ({
    width: open ? rootW.value : expandedWidth,
    opacity: clamp(bodyOpacity.value / 100),
  }))
  const triggerStyle = useAnimatedStyle(() => ({
    width: open ? triggerWidth.value : rootW.value,
  }))
  const qrStyle = useAnimatedStyle(() => ({
    opacity: clamp(qrOpacity.value / 100),
    transform: [{ translateY: qrY.value }, { scale: qrScale.value }],
  }))
  const copyCenter = useDerivedValue(
    () => 16 + (rootW.value - 32 - 8 - closeWidth) / 2
  )
  const overlay = useAnimatedProps(() => ({
    transform: `translate(0 ${actionHeight / 2})`,
  }))
  return (
    <Animated.View
      testID={testID}
      style={[
        {
          alignSelf: "center",
          overflow: "hidden",
          borderRadius: radius,
          backgroundColor: colors.surface,
        },
        style,
        rootStyle,
      ]}
    >
      {triggerMounted && (
        <Animated.View
          pointerEvents={open ? "none" : "auto"}
          style={[
            { position: "absolute", left: 0, top: 0, height: 48 },
            triggerStyle,
          ]}
        >
          <Pressable
            accessibilityRole="button"
            accessibilityLabel={buttonLabel}
            accessibilityState={{ expanded: open }}
            onPress={() => toggle(true)}
            style={{ height: 48 }}
          >
            <Trigger
              width={open ? triggerWidth : rootW}
              label={buttonLabel}
              labelWidth={labelWidth}
              fontSize={fontSize}
              color={colors.foreground}
              blur={triggerBlur}
              opacity={triggerOpacity}
              exiting={open}
            />
          </Pressable>
        </Animated.View>
      )}
      {bodyMounted && (
        <Animated.View
          pointerEvents={open ? "auto" : "none"}
          style={[
            {
              position: "absolute",
              left: 0,
              top: 0,
              padding: 16,
              alignItems: "center",
              gap: 8,
              height: bodyHeight,
            },
            bodyStyle,
          ]}
          onLayout={(e) => {
            const height = e.nativeEvent.layout.height
            if (openRef.current && height !== measuredHeight.current) {
              measuredHeight.current = height
              rootH.value = spring(height)
            }
          }}
        >
          <Animated.View
            style={[
              {
                width: qrSize,
                height: qrSize,
                borderWidth: 1,
                borderColor: colors.qrBorder,
                borderRadius: qrRadius,
                padding: 16,
                backgroundColor: colors.qrSurface,
                alignItems: "center",
                justifyContent: "center",
              },
              qrStyle,
            ]}
          >
            <QRCode
              value={value}
              size={qrSize - 32 - border * 2}
              ecl="H"
              color={colors.qrForeground}
              backgroundColor="transparent"
              quietZone={0}
            />
          </Animated.View>
          <View
            style={{
              width: "100%",
              height: actionHeight,
              flexDirection: "row",
              alignItems: "center",
              gap: 8,
            }}
          >
            <Pressable
              accessibilityRole="button"
              accessibilityLabel={copied ? "Copied Link" : "Copy Link"}
              onPress={copy}
              style={{ flex: 1, height: actionHeight }}
            >
              <View
                ref={borderRef}
                style={{
                  height: "100%",
                  borderWidth: 1,
                  borderColor: colors.actionBorder,
                  borderRadius: 9999,
                  backgroundColor: colors.actionSurface,
                }}
              />
            </Pressable>
            <Pressable
              accessibilityRole="button"
              accessibilityLabel="Close QR code"
              onPress={() => toggle(false)}
              style={{
                width: closeWidth,
                height: closeWidth,
                borderWidth: 1,
                borderColor: colors.actionBorder,
                borderRadius: 9999,
                backgroundColor: colors.actionSurface,
                alignItems: "center",
                justifyContent: "center",
              }}
            >
              <Svg width={24} height={24} viewBox="0 0 24 24">
                <Path
                  d="M18 6 6 18M6 6l12 12"
                  fill="none"
                  stroke={colors.foreground}
                  strokeWidth={2}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </Svg>
            </Pressable>
            <Svg
              width="100%"
              height={actionHeight}
              pointerEvents="none"
              style={{
                position: "absolute",
                left: -16,
                top: 0,
                overflow: "visible",
              }}
            >
              <AG animatedProps={overlay}>
                <CopyLabel
                  copied={copied}
                  widths={widths}
                  linkWidth={linkWidth}
                  center={copyCenter}
                  fontSize={actionFontSize}
                  color={colors.foreground}
                />
              </AG>
            </Svg>
          </View>
        </Animated.View>
      )}
      <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" }}>
          {[buttonLabel, "Link", ..."Copyied"].map((text, i) => (
            <Text
              key={i}
              style={{
                fontFamily: FONT,
                fontSize: i === 0 ? fontSize : actionFontSize,
                fontWeight: "500",
                letterSpacing: i > 1 ? -actionFontSize * 0.025 : 0,
              }}
              ref={(ref) => {
                if (ref && WEB) {
                  const node = ref as unknown as Element
                  const measured = node.getBoundingClientRect().width
                  if (i === 0 && measured > 0)
                    setLabelWidth((old) => (old === measured ? old : measured))
                  else if (i === 1 && measured > 0)
                    setLinkWidth((old) => (old === measured ? old : measured))
                  else if (measured > 0)
                    setWidths((old) =>
                      old[text] === measured
                        ? old
                        : { ...old, [text]: measured }
                    )
                }
              }}
              onLayout={(e) => {
                if (WEB) return
                const measured = e.nativeEvent.layout.width
                if (i === 0) setLabelWidth(measured)
                else if (i === 1) setLinkWidth(measured)
                else
                  setWidths((old) =>
                    old[text] === measured ? old : { ...old, [text]: measured }
                  )
              }}
            >
              {text}
            </Text>
          ))}
        </View>
      </View>
    </Animated.View>
  )
}

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

Back to Watermelon UI