Interaction
Show Qr
An expressive interaction rebuilt for touch
Expo code
Original web component
Watermelon UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
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.