Skip to content
pixelfor Expo
Ctrl K

Interaction

Transaction List

An expressive interaction rebuilt for touch

Try it on your phone

Transaction List

Scan to open Transaction List 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.

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.

Back to Watermelon UI