Interaction
Transaction List
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.
transaction-list.in-progress.tsx
import React, { useEffect, useRef, useState } from "react"
import {
Platform,
Pressable,
Text,
View,
useColorScheme,
type StyleProp,
type ViewStyle,
} from "react-native"
import Animated, {
defineAnimation,
type AnimationObject,
Easing,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
type SharedValue,
} from "react-native-reanimated"
import Svg, { Path } 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 interface Transaction {
id: string
icon: React.ReactNode
name: string
category: string
amount: string
date: string
time: string
transactionId: string
paymentMethod: string
cardNumber: string
cardType: string
}
export interface TransactionListProps {
transactions: Transaction[]
onAllTransactions?: () => void
theme?: "light" | "dark" | "system"
width?: number
radius?: number
fontFamily?: string
colors?: Partial<{
surface: string
border: string
text: string
muted: string
detailAmount: string
icon: string
close: string
}>
style?: StyleProp<ViewStyle>
testID?: string
}
type Box = { x: number; y: number; width: number; height: number }
type Boxes = Record<string, Box>
type Palette = Required<NonNullable<TransactionListProps["colors"]>>
type MeasureProps = {
id: string
root: React.RefObject<View | null>
record: (id: string, box: Box) => void
children: React.ReactNode
style?: StyleProp<ViewStyle>
}
function Measured({ id, root, record, children, style }: MeasureProps) {
const ref = useRef<View>(null)
return (
<View
ref={ref}
collapsable={false}
onLayout={() => {
if (root.current)
ref.current?.measureLayout(root.current, (x, y, width, height) =>
record(id, { x, y, width, height })
)
}}
style={style}
>
{children}
</View>
)
}
function critical(p: number) {
"worklet"
const t = 9.233413476 * p
return p >= 1 ? 1 : 1 - Math.exp(-t) * (1 + t)
}
const MORPH = { duration: 600, easing: critical }
const FADE = { duration: 400, easing: Easing.bezier(0.19, 1, 0.22, 1) }
export function TransactionList({
transactions,
onAllTransactions,
theme = "system",
width = 400,
radius = 16,
fontFamily = Platform.OS === "web" ? "Arial" : undefined,
colors: overrides,
style,
testID,
}: TransactionListProps) {
const [open, setOpen] = useState<string | null>(null)
const [lastSelected, setLastSelected] = useState<string | null>(null)
const [boxes, setBoxes] = useState<Boxes>({})
const [listHeight, setListHeight] = useState(24)
const [detailBounds, setDetailBounds] = useState<{
id: string
height: number
} | null>(null)
const root = useRef<View>(null)
const scheme = useColorScheme(),
dark = theme === "dark" || (theme === "system" && scheme === "dark")
const colors = {
surface: dark ? "#18181b" : "#f4f4f5",
border: dark ? "#ffffff1a" : "#e4e4e7",
text: dark ? "#f4f4f5" : "#3f3f46",
muted: dark ? "#a1a1aa" : "#71717a",
detailAmount: dark ? "#fff" : "#000",
icon: "#27272a",
close: dark ? "#3f3f46" : "#d4d4d8",
...overrides,
}
const selected = transactions.find(
(item) => item.id === (open ?? lastSelected)
)
const h = useSharedValue(24),
listOpacity = useSharedValue(0),
expandedOpacity = useSharedValue(1),
detailsOpacity = useSharedValue(0)
useEffect(() => {
if (!open) h.set(withTiming(listHeight, MORPH))
else if (detailBounds?.id === open)
h.set(withTiming(detailBounds.height, MORPH))
}, [open, listHeight, detailBounds])
useEffect(() => {
listOpacity.set(withTiming(open ? 0 : 1, FADE))
expandedOpacity.set(open ? 1 : withTiming(0, MORPH))
detailsOpacity.set(motionDelay(100, withTiming(open ? 1 : 0, FADE)))
}, [open])
const shell = useAnimatedStyle(() => ({ height: h.get() }))
const listFade = useAnimatedStyle(() => ({ opacity: listOpacity.get() }))
const detailFade = useAnimatedStyle(() => ({ opacity: detailsOpacity.get() }))
const expandedFade = useAnimatedStyle(() => ({
opacity: expandedOpacity.get(),
}))
function record(id: string, box: Box) {
setBoxes((previous) => {
const old = previous[id]
return old &&
old.x === box.x &&
old.y === box.y &&
old.width === box.width &&
old.height === box.height
? previous
: { ...previous, [id]: box }
})
}
function select(id: string) {
setLastSelected(id)
setOpen(id)
}
const measure = (
id: string,
children: React.ReactNode,
elementStyle?: StyleProp<ViewStyle>
) => (
<Measured id={id} root={root} record={record} style={elementStyle}>
{children}
</Measured>
)
const bodyWidth = Math.min(256, width - 24)
const contentLeft = (width - bodyWidth) / 2
return (
<Animated.View
testID={testID}
style={[
{
alignSelf: "center",
width,
overflow: "hidden",
borderRadius: radius,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
boxShadow: "0px 1px 2px rgba(0,0,0,.05)",
},
shell,
style,
]}
>
<View
ref={root}
collapsable={false}
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "100%",
}}
>
<View
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={{
position: "absolute",
left: contentLeft,
top: 12,
width: bodyWidth,
opacity: 0,
}}
onLayout={(e) => setListHeight(e.nativeEvent.layout.height + 24)}
>
<Text
style={{
fontFamily,
fontSize: 16,
lineHeight: 24,
fontWeight: "500",
}}
>
Transaction
</Text>
{transactions.map((item) => (
<Measured
key={item.id}
id={`list:${item.id}:row`}
root={root}
record={record}
style={{
flexDirection: "row",
gap: 8,
marginTop: 8,
minHeight: 40,
}}
>
{measure(`list:${item.id}:icon`, item.icon, {
width: 40,
height: 40,
justifyContent: "center",
alignItems: "center",
})}
<View style={{ flex: 1, justifyContent: "center" }}>
{measure(
`list:${item.id}:name`,
<Text
style={{
fontFamily,
fontSize: 12,
lineHeight: 16,
fontWeight: "600",
}}
>
{item.name}
</Text>
)}
{measure(
`list:${item.id}:category`,
<Text style={{ fontFamily, fontSize: 12, lineHeight: 16 }}>
{item.category}
</Text>
)}
</View>
{measure(
`list:${item.id}:amount`,
<Text style={{ fontFamily, fontSize: 12, lineHeight: 16 }}>
{item.amount}
</Text>,
{ justifyContent: "center" }
)}
</Measured>
))}
<View style={{ height: 28, marginTop: 8 }} />
</View>
{selected && (
<View
key={selected.id}
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={{
position: "absolute",
left: contentLeft,
top: 12,
width: bodyWidth,
opacity: 0,
}}
onLayout={(e) => {
const height = e.nativeEvent.layout.height + 24
setDetailBounds((previous) =>
previous?.id === selected.id && previous.height === height
? previous
: { id: selected.id, height }
)
}}
>
{measure(`detail:${selected.id}:icon`, selected.icon, {
width: 40,
height: 40,
alignItems: "center",
justifyContent: "center",
})}
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
marginTop: 8,
}}
>
<View>
{measure(
`detail:${selected.id}:name`,
<Text
style={{
fontFamily,
fontSize: 16,
lineHeight: 24,
fontWeight: "600",
}}
>
{selected.name}
</Text>
)}
{measure(
`detail:${selected.id}:category`,
<Text style={{ fontFamily, fontSize: 14, lineHeight: 20 }}>
{selected.category}
</Text>
)}
</View>
{measure(
`detail:${selected.id}:amount`,
<Text style={{ fontFamily, fontSize: 16, lineHeight: 24 }}>
{selected.amount}
</Text>
)}
</View>
<Measured
id={`detail:${selected.id}:body`}
root={root}
record={record}
style={{ marginTop: 8 }}
>
<Details
data={selected}
colors={colors}
fontFamily={fontFamily}
/>
</Measured>
</View>
)}
<Animated.View
pointerEvents={open ? "none" : "auto"}
accessibilityElementsHidden={!!open}
importantForAccessibility={open ? "no-hide-descendants" : "auto"}
style={[
{ position: "absolute", left: contentLeft, top: 12, width: bodyWidth },
listFade,
]}
>
<Text
style={{
fontFamily,
fontSize: 16,
lineHeight: 24,
fontWeight: "500",
color: dark ? colors.text : colors.muted,
}}
>
Transaction
</Text>
{transactions.map((item) => (
<Pressable
key={item.id}
onPress={() => select(item.id)}
accessibilityRole="button"
accessibilityLabel={`${item.name}, ${item.amount}, ${item.category}`}
style={{
height: boxes[`list:${item.id}:row`]?.height ?? 40,
marginTop: 8,
}}
/>
))}
<Pressable
accessibilityRole="button"
onPress={onAllTransactions}
style={{
height: 28,
marginTop: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 4,
}}
>
<Text style={{ fontFamily, fontSize: 14, color: colors.text }}>
All transactions
</Text>
<Svg
width={14}
height={14}
viewBox="0 0 24 24"
fill="none"
stroke={colors.text}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<Path d="M5 12h14m-7-7 7 7-7 7" />
</Svg>
</Pressable>
</Animated.View>
{selected && (
<Animated.View
pointerEvents={open ? "auto" : "none"}
accessibilityElementsHidden={!open}
importantForAccessibility={open ? "auto" : "no-hide-descendants"}
style={[
{ position: "absolute", left: contentLeft, top: 12, width: bodyWidth },
expandedFade,
]}
>
<Pressable
accessibilityRole="button"
accessibilityLabel="Close transaction details"
onPress={() => setOpen(null)}
hitSlop={8}
style={{
position: "absolute",
right: 0,
top: 0,
width: 32,
height: 32,
borderRadius: 999,
backgroundColor: colors.close,
alignItems: "center",
justifyContent: "center",
}}
>
<Svg
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke={dark ? colors.text : "#18181b"}
strokeWidth={2}
strokeLinecap="round"
>
<Path d="m18 6-12 12M6 6l12 12" />
</Svg>
</Pressable>
<Animated.View
style={[
{
marginTop:
(boxes[`detail:${selected.id}:body`]?.y ?? 112) - 12,
},
detailFade,
]}
>
<Details
data={selected}
colors={colors}
fontFamily={fontFamily}
/>
</Animated.View>
</Animated.View>
)}
{transactions.map((item) => (
<React.Fragment key={item.id}>
{(["icon", "name", "category", "amount"] as const).map((kind) => {
const start = boxes[`list:${item.id}:${kind}`],
end = boxes[`detail:${item.id}:${kind}`]
return start ? (
<SharedItem
key={kind}
data={item}
kind={kind}
start={start}
end={end}
selected={open === item.id}
listOpacity={listOpacity}
expandedOpacity={expandedOpacity}
colors={colors}
fontFamily={fontFamily}
/>
) : null
})}
</React.Fragment>
))}
</View>
</Animated.View>
)
}
function SharedItem({
data,
kind,
start,
end,
selected,
listOpacity,
expandedOpacity,
colors,
fontFamily,
}: {
data: Transaction
kind: "icon" | "name" | "category" | "amount"
start: Box
end?: Box
selected: boolean
listOpacity: SharedValue<number>
expandedOpacity: SharedValue<number>
colors: Palette
fontFamily?: string
}) {
const x = useSharedValue(start.x),
y = useSharedValue(start.y),
width = useSharedValue(start.width),
height = useSharedValue(start.height)
const progress = useSharedValue(0),
destination = useSharedValue(false),
lead = useSharedValue(false),
crossfade = useSharedValue(1),
outgoingOpacity = useSharedValue(1)
const target = selected && end ? end : start
useEffect(() => {
x.set(withTiming(target.x, MORPH))
y.set(withTiming(target.y, MORPH))
width.set(withTiming(target.width, MORPH))
height.set(withTiming(target.height, MORPH))
}, [target])
useEffect(() => {
progress.set(withTiming(selected && end ? 1 : 0, MORPH))
destination.set(selected && !!end)
}, [selected, !!end])
useAnimatedReaction(
() => destination.get(),
(next) => {
if (next === lead.get()) return
outgoingOpacity.set(crossfadeIn(crossfade.get()))
lead.set(next)
crossfade.set(0)
crossfade.set(withTiming(1, MORPH))
}
)
const frame = useAnimatedStyle(() => ({
...(kind === "icon" ? { width: width.get(), height: height.get() } : {}),
opacity: kind === "icon" ? (destination.get() ? 1 : listOpacity.get()) : 1,
borderRadius: kind === "icon" ? 20 - 14 * progress.get() : 0,
transform: [{ translateX: x.get() }, { translateY: y.get() }],
}))
return (
<Animated.View
pointerEvents="none"
accessible
accessibilityLabel={kind === "icon" ? undefined : data[kind]}
style={[
{
position: "absolute",
left: 0,
top: 0,
justifyContent: "center",
alignItems: kind === "icon" ? "center" : "flex-start",
backgroundColor: kind === "icon" ? colors.icon : undefined,
},
frame,
]}
>
{kind === "icon" ? (
data.icon
) : (
<>
<ProjectedText
text={data[kind]}
kind={kind}
box={start}
detail={false}
width={width}
height={height}
lead={lead}
crossfade={crossfade}
outgoingOpacity={outgoingOpacity}
parentOpacity={listOpacity}
colors={colors}
fontFamily={fontFamily}
/>
{end && (
<ProjectedText
text={data[kind]}
kind={kind}
box={end}
detail
width={width}
height={height}
lead={lead}
crossfade={crossfade}
outgoingOpacity={outgoingOpacity}
parentOpacity={expandedOpacity}
colors={colors}
fontFamily={fontFamily}
/>
)}
</>
)}
</Animated.View>
)
}
function crossfadeIn(progress: number) {
"worklet"
const p = Math.min(1, Math.max(0, progress / 0.5))
return Math.sqrt(1 - (1 - p) ** 2)
}
function crossfadeOut(progress: number) {
"worklet"
return 1 - Math.min(1, Math.max(0, (progress - 0.5) / 0.45))
}
function ProjectedText({
text,
kind,
box,
detail,
width,
height,
lead,
crossfade,
outgoingOpacity,
parentOpacity,
colors,
fontFamily,
}: {
text: string
kind: "name" | "category" | "amount"
box: Box
detail: boolean
width: SharedValue<number>
height: SharedValue<number>
lead: SharedValue<boolean>
crossfade: SharedValue<number>
outgoingOpacity: SharedValue<number>
parentOpacity: SharedValue<number>
colors: Palette
fontFamily?: string
}) {
const projection = useAnimatedStyle(() => ({
opacity:
parentOpacity.get() *
(lead.get() === detail
? crossfadeIn(crossfade.get())
: outgoingOpacity.get() * crossfadeOut(crossfade.get())),
zIndex: lead.get() === detail ? 1 : 0,
transform: [
{ scaleX: width.get() / Math.max(box.width, 0.01) },
{ scaleY: height.get() / Math.max(box.height, 0.01) },
],
}))
return (
<Animated.View
style={[
{
position: "absolute",
left: 0,
top: 0,
width: box.width,
height: box.height,
transformOrigin: "top left",
justifyContent:
!detail && kind === "amount" ? "center" : "flex-start",
},
projection,
]}
>
<Text
style={{
fontFamily,
fontWeight: kind === "name" ? "600" : "400",
fontSize: detail ? (kind === "category" ? 14 : 16) : 12,
lineHeight: detail ? (kind === "category" ? 20 : 24) : 16,
color:
kind === "name"
? colors.text
: detail && kind === "amount"
? colors.detailAmount
: colors.muted,
}}
>
{text}
</Text>
</Animated.View>
)
}
function Details({
data,
colors,
fontFamily,
}: {
data: Transaction
colors: Palette
fontFamily?: string
}) {
const line = { fontFamily, fontSize: 12, lineHeight: 16, color: colors.muted }
const rule = (
<View
style={{
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.border,
}}
/>
)
return (
<View style={{ gap: 8 }}>
{rule}
<Text style={line}>#{data.transactionId}</Text>
<Text style={line}>{data.date}</Text>
<Text style={line}>{data.time}</Text>
{rule}
<Text style={line}>Paid Via {data.paymentMethod}</Text>
<Text style={line}>
XXXX {data.cardNumber}{" "}
<Text
style={{ fontWeight: "700", fontStyle: "italic", color: colors.text }}
>
{data.cardType.toUpperCase()}
</Text>
</Text>
</View>
)
}
export default TransactionList
Give the gestures and motion a run on a real Android phone.