Skip to content
pixelfor Expo
Ctrl K

Interaction

Minimal Carousel

An expressive interaction rebuilt for touch

Try it on your phone

Minimal Carousel

Scan to open Minimal Carousel 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.

minimal-carousel.in-progress.tsx

import React, {
  useCallback,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
} from "react"
import {
  Platform,
  Pressable,
  StyleSheet,
  Text,
  View,
  useWindowDimensions,
  type StyleProp,
  type ViewStyle,
} from "react-native"
import Animated, {
  Easing,
  ReduceMotion,
  useAnimatedStyle,
  useDerivedValue,
  useFrameCallback,
  useReducedMotion,
  useSharedValue,
  withSpring,
  withTiming,
  type SharedValue,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import { BlurView } from "expo-blur"
import Svg, { Circle, Path, Rect } from "react-native-svg"
const WEB = Platform.OS === "web"
const FONT = WEB ? "Arial" : Platform.OS === "ios" ? "Helvetica" : "sans-serif"
const CARD_TIME = {
  duration: 600,
  reduceMotion: ReduceMotion.System,
  easing: (progress: number) => {
    "worklet"
    const t = progress * 0.6
    const decay = 11.992395585723198
    const angular = 8.994296689292393
    return (
      1 -
      Math.exp(-decay * t) *
        (Math.cos(angular * t) + (decay / angular) * Math.sin(angular * t))
    )
  },
} as const
const LAYOUT = {
  duration: 450,
  easing: Easing.bezier(0.4, 0, 0.1, 1),
  reduceMotion: ReduceMotion.System,
} as const
const CSS = {
  duration: 500,
  easing: Easing.bezier(0.4, 0, 0.2, 1),
  reduceMotion: ReduceMotion.System,
} as const
const HOVER = { ...CSS, duration: 150 }
const GRID_EASE = Easing.bezier(0.4, 0, 0.2, 1).factory()
const COPY_SCALE = {
  stiffness: 550,
  damping: 30,
  mass: 1,
  reduceMotion: ReduceMotion.System,
} as const
const COPY_FADE = {
  duration: 300,
  easing: Easing.bezier(0.25, 0.1, 0.35, 1),
  reduceMotion: ReduceMotion.System,
} as const
type Box = { x: number; y: number; width: number; height: number }
type Mode = "expanded" | "compact" | "idle"
export type CarouselCard = {
  id: string
  title: string
  value: string
  color: string
  icon: React.ComponentType<{ size: number; color: string }>
}
export type MinimalCarouselProps = {
  cards: CarouselCard[]
  onCopyClick?: (card: CarouselCard) => void
  onCustomizeClick?: (card: CarouselCard) => void
  defaultActiveId?: string | null
  textColor?: string
  radius?: number
  expandedRadius?: number
  gap?: number
  expandedHeight?: number
  tileHeight?: number
  compactHeight?: number
  blurIntensity?: number
  style?: StyleProp<ViewStyle>
  testID?: string
}
function cardShadow(expanded: boolean): ViewStyle {
  if (Platform.OS === "android" && Number(Platform.Version) < 28)
    return { elevation: expanded ? 8 : 4 }
  return {
    boxShadow: expanded
      ? [
          {
            offsetX: 0,
            offsetY: 25,
            blurRadius: 50,
            spreadDistance: -12,
            color: "rgba(0,0,0,.25)",
          },
        ]
      : [
          {
            offsetX: 0,
            offsetY: 10,
            blurRadius: 15,
            spreadDistance: -3,
            color: "rgba(0,0,0,.1)",
          },
          {
            offsetX: 0,
            offsetY: 4,
            blurRadius: 6,
            spreadDistance: -4,
            color: "rgba(0,0,0,.1)",
          },
        ],
  }
}
function More({ size = 16, color }: { size?: number; color: string }) {
  return (
    <Svg width={size} height={size} viewBox="0 0 24 24" accessible={false}>
      <Circle
        cx={12}
        cy={12}
        r={1}
        fill="none"
        stroke={color}
        strokeWidth={2}
      />
      <Circle
        cx={19}
        cy={12}
        r={1}
        fill="none"
        stroke={color}
        strokeWidth={2}
      />
      <Circle cx={5} cy={12} r={1} fill="none" stroke={color} strokeWidth={2} />
    </Svg>
  )
}
function CopyIcon({ color }: { color: string }) {
  return (
    <Svg width={16} height={16} viewBox="0 0 24 24" accessible={false}>
      <Rect
        width={14}
        height={14}
        x={8}
        y={8}
        rx={2}
        fill="none"
        stroke={color}
        strokeWidth={2}
      />
      <Path
        d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
        fill="none"
        stroke={color}
        strokeWidth={2}
        strokeLinecap="round"
      />
    </Svg>
  )
}
function Action({
  copy,
  tablet,
  color,
  blurIntensity,
  onPress,
  testID,
}: {
  copy?: boolean
  tablet: boolean
  color: string
  blurIntensity: number
  onPress?: () => void
  testID?: string
}) {
  const opacity = useSharedValue(copy ? 0 : 1),
    scale = useSharedValue(copy ? 0.8 : 1),
    background = useSharedValue(
      copy ? "rgba(255,255,255,.1)" : "rgba(255,255,255,.3)"
    )
  useEffect(() => {
    if (!copy) return
    opacity.set(withTiming(1, COPY_FADE))
    scale.set(withSpring(1, COPY_SCALE))
  }, [copy, opacity, scale])
  const style = useAnimatedStyle(() => ({
    opacity: opacity.get(),
    backgroundColor: background.get(),
    transform: [{ scale: scale.get() }],
  }))
  const webBlur = WEB ? ({ backdropFilter: "blur(12px)" } as ViewStyle) : {}
  return (
    <Animated.View
      style={[{ borderRadius: 999, overflow: "hidden" }, webBlur, style]}
    >
      {!WEB && (
        <BlurView
          intensity={blurIntensity}
          experimentalBlurMethod="dimezisBlurView"
          blurReductionFactor={5}
          tint="default"
          style={StyleSheet.absoluteFill}
        />
      )}
      <Pressable
        accessibilityRole="button"
        accessibilityLabel={copy ? "Copy address" : "Edit card"}
        onPress={onPress}
        onHoverIn={() =>
          background.set(
            withTiming(
              copy ? "rgba(255,255,255,.2)" : "rgba(255,255,255,.4)",
              HOVER
            )
          )
        }
        onHoverOut={() =>
          background.set(
            withTiming(
              copy ? "rgba(255,255,255,.1)" : "rgba(255,255,255,.3)",
              HOVER
            )
          )
        }
        testID={testID}
        style={{
          paddingHorizontal: tablet ? 16 : 12,
          paddingVertical: copy ? (tablet ? 8 : 6) : tablet ? 6 : 4,
          flexDirection: "row",
          alignItems: "center",
          gap: 6,
        }}
      >
        <Text
          numberOfLines={1}
          style={{
            fontFamily: FONT,
            fontSize: tablet ? 16 : copy ? 12 : 14,
            lineHeight: tablet ? 24 : copy ? 16 : 20,
            fontWeight: "700",
            color,
          }}
        >
          {copy ? "Copy" : "Edit"}
        </Text>
        {copy && <CopyIcon color={color} />}
      </Pressable>
    </Animated.View>
  )
}
type Appearance = { mode: Mode; width: number; height: number }
type ContentProps = {
  appearance: Appearance
  card: CarouselCard
  tablet: boolean
  color: string
  blurIntensity: number
  onCopy?: (card: CarouselCard) => void
  onEdit?: (card: CarouselCard) => void
  testID?: string
  width: SharedValue<number>
  height: SharedValue<number>
  progress: SharedValue<number>
  outgoing?: boolean
}
function Content({
  appearance,
  card,
  tablet,
  color,
  blurIntensity,
  onCopy,
  onEdit,
  testID,
  width,
  height,
  progress,
  outgoing,
}: ContentProps) {
  const { mode } = appearance
  const expanded = mode === "expanded",
    compact = mode === "compact"
  const padding = expanded ? (tablet ? 20 : 16) : tablet ? 16 : 12
  const style = useAnimatedStyle(() => {
    const p = Math.max(0, Math.min(1, progress.get()))
    const opacity = outgoing
      ? 1 - Math.max(0, Math.min(1, (p - 0.5) / 0.45))
      : Math.sqrt(1 - Math.pow(1 - Math.min(1, p / 0.5), 2))
    return {
      opacity,
      transform: [
        { scaleX: width.get() / appearance.width },
        { scaleY: height.get() / appearance.height },
      ],
    }
  })
  const font = expanded
    ? tablet
      ? 24
      : 20
    : compact
      ? tablet
        ? 12
        : 10
      : tablet
        ? 16
        : 14
  const valueFont = expanded ? (tablet ? 20 : 18) : font
  const Icon = card.icon
  return (
    <Animated.View
      pointerEvents={outgoing ? "none" : "box-none"}
      aria-hidden={outgoing || undefined}
      accessibilityElementsHidden={outgoing}
      importantForAccessibility={outgoing ? "no-hide-descendants" : "auto"}
      style={[
        {
          position: "absolute",
          left: 0,
          top: 0,
          width: appearance.width,
          height: appearance.height,
          padding,
          justifyContent: "space-between",
          transformOrigin: "top left",
        },
        style,
      ]}
    >
      <View
        pointerEvents="box-none"
        style={{
          flexDirection: "row",
          alignItems: "flex-start",
          justifyContent: "space-between",
          gap: expanded ? 8 : 0,
        }}
      >
        {expanded ? (
          <View
            style={{
              width: tablet ? 44 : 40,
              height: tablet ? 44 : 40,
              alignItems: "center",
              justifyContent: "center",
            }}
          >
            <Icon size={tablet ? 44 : 38} color={color} />
          </View>
        ) : (
          <Icon size={compact ? 20 : 28} color={color} />
        )}
        {expanded ? (
          <Action
            copy
            tablet={tablet}
            color={color}
            blurIntensity={blurIntensity}
            onPress={() => onCopy?.(card)}
            testID={testID ? `${testID}-copy` : undefined}
          />
        ) : (
          <View
            style={{
              borderRadius: 999,
              backgroundColor: "rgba(255,255,255,.1)",
              padding: tablet ? 6 : 4,
            }}
          >
            <More color={color} />
          </View>
        )}
      </View>
      <View
        pointerEvents="box-none"
        style={{
          flexDirection: "row",
          alignItems: "flex-end",
          justifyContent: "space-between",
          marginTop: expanded ? 16 : 4,
        }}
      >
        <View
          style={{
            flexShrink: 1,
            minWidth: 0,
            overflow: "hidden",
            marginRight: expanded ? 8 : 0,
          }}
        >
          <Text
            numberOfLines={1}
            style={{
              fontFamily: FONT,
              fontSize: font,
              lineHeight: font * 1.25,
              fontWeight: expanded ? "600" : "500",
              color,
              opacity: 0.9,
            }}
          >
            {card.title}
          </Text>
          <Text
            numberOfLines={1}
            style={{
              fontFamily: FONT,
              fontSize: valueFont,
              lineHeight: expanded
                ? tablet
                  ? 28
                  : 28
                : compact
                  ? tablet
                    ? 16
                    : 15
                  : tablet
                    ? 24
                    : 20,
              fontWeight: "600",
              color,
              opacity: 0.6,
              letterSpacing: expanded ? -valueFont * 0.025 : 0,
            }}
          >
            {card.value}
          </Text>
        </View>
        {expanded && (
          <Action
            tablet={tablet}
            color={color}
            blurIntensity={blurIntensity}
            onPress={() => onEdit?.(card)}
            testID={testID ? `${testID}-edit` : undefined}
          />
        )}
      </View>
    </Animated.View>
  )
}
type GridProjection = {
  fromHeight: number
  toHeight: number
  fromY: number
  toY: number
  fromScene: number
  toScene: number
}
function Tile({
  card,
  box,
  mode,
  tablet,
  color,
  radius,
  blurIntensity,
  selected,
  onSelect,
  onCopy,
  onEdit,
  testID,
  grid,
  layoutProgress,
  projection,
  sceneHeight,
  gridOrigin,
}: {
  card: CarouselCard
  box: Box
  mode: Mode
  tablet: boolean
  color: string
  radius: number
  blurIntensity: number
  selected: boolean
  onSelect: () => void
  onCopy?: (card: CarouselCard) => void
  onEdit?: (card: CarouselCard) => void
  testID?: string
  grid: SharedValue<{ scale: number; translate: number }>
  layoutProgress: SharedValue<number>
  projection: SharedValue<GridProjection>
  sceneHeight: SharedValue<number>
  gridOrigin: number
}) {
  const x = useSharedValue(box.x),
    y = useSharedValue(box.y - (mode === "expanded" ? 0 : gridOrigin)),
    width = useSharedValue(box.width),
    height = useSharedValue(box.height),
    round = useSharedValue(radius)
  const progress = useSharedValue(1)
  const generation = useRef(0)
  const reduced = useReducedMotion()
  const previous = useRef<Appearance>({
    mode,
    width: box.width,
    height: box.height,
  })
  const [outgoing, setOutgoing] = useState<Appearance | null>(null)
  const oldMode = useRef(mode)
  const previousGridOrigin = useRef(gridOrigin)
  const clear = useCallback((id: number) => {
    if (generation.current === id) setOutgoing(null)
  }, [])
  useLayoutEffect(() => {
    if (previous.current.mode !== mode) {
      setOutgoing(previous.current)
      const id = ++generation.current
      progress.set(0)
      progress.set(
        withTiming(1, CARD_TIME, (finished) => {
          if (finished) scheduleOnRN(clear, id)
        })
      )
    }
    previous.current = { mode, width: box.width, height: box.height }
  }, [mode, box.width, box.height, progress, clear])
  useEffect(() => {
    x.set(withTiming(box.x, CARD_TIME))
    if (oldMode.current !== "expanded" && mode === "expanded") {
      y.set(
        y.get() +
          previousGridOrigin.current +
          (oldMode.current === "idle" ? gridOrigin / 2 : 0)
      )
    } else if (oldMode.current === "expanded" && mode !== "expanded")
      y.set(y.get() - gridOrigin)
    oldMode.current = mode
    previousGridOrigin.current = gridOrigin
    y.set(withTiming(box.y - (mode === "expanded" ? 0 : gridOrigin), CARD_TIME))
    width.set(withTiming(box.width, CARD_TIME))
    height.set(withTiming(box.height, CARD_TIME))
    round.set(withTiming(radius, CARD_TIME))
  }, [
    box.x,
    box.y,
    box.width,
    box.height,
    radius,
    mode,
    projection,
    gridOrigin,
    x,
    y,
    width,
    height,
    round,
  ])
  const projectedHeight = useDerivedValue(() => {
    const m = projection.get(),
      q = layoutProgress.get()
    const rawScale =
      m.toHeight > 0 ? 1 + (m.fromHeight / m.toHeight - 1) * (1 - q) : 1
    const parentScale = m.toScene > 0 ? sceneHeight.get() / m.toScene : 1
    return mode === "expanded" || reduced
      ? height.get()
      : Math.max(1, (height.get() / rawScale) * grid.get().scale * parentScale)
  })
  const style = useAnimatedStyle(() => {
    const m = projection.get(),
      q = layoutProgress.get()
    const rawScale =
      m.toHeight > 0 ? 1 + (m.fromHeight / m.toHeight - 1) * (1 - q) : 1
    const parentScale = m.toScene > 0 ? sceneHeight.get() / m.toScene : 1
    const localY = y.get() / rawScale
    const top =
      mode === "expanded"
        ? y.get()
        : reduced
          ? y.get() + gridOrigin
          : (m.toY + grid.get().translate + localY * grid.get().scale) *
            parentScale
    return {
      left: x.get(),
      top,
      width: width.get(),
      height: projectedHeight.get(),
      borderRadius: Math.max(0, round.get()),
    }
  })
  const contentProps = {
    card,
    tablet,
    color,
    blurIntensity,
    onCopy,
    onEdit,
    testID,
    width,
    height: projectedHeight,
    progress,
  }
  const clipStyle = useAnimatedStyle(() => ({
    borderRadius: Math.max(0, round.get()),
  }))
  return (
    <Animated.View
      testID={testID}
      style={[
        {
          position: "absolute",
          backgroundColor: card.color,
          zIndex: selected ? 2 : 1,
        },
        cardShadow(selected),
        style,
      ]}
    >
      <Animated.View style={[{ flex: 1, overflow: "hidden" }, clipStyle]}>
        <Pressable
          accessible={!selected}
          accessibilityRole={selected ? undefined : "button"}
          accessibilityLabel={`${card.title}, ${card.value}`}
          onPress={selected ? undefined : onSelect}
          testID={testID ? `${testID}-select` : undefined}
          style={StyleSheet.absoluteFill}
        >
          {outgoing && (
            <Content {...contentProps} appearance={outgoing} outgoing />
          )}
          <Content
            {...contentProps}
            appearance={{ mode, width: box.width, height: box.height }}
          />
        </Pressable>
      </Animated.View>
    </Animated.View>
  )
}
export function MinimalCarousel({
  cards,
  onCopyClick,
  onCustomizeClick,
  defaultActiveId = null,
  textColor = "#fff",
  radius,
  expandedRadius,
  gap,
  expandedHeight,
  tileHeight,
  compactHeight,
  blurIntensity = 60,
  style,
  testID,
}: MinimalCarouselProps) {
  const { width: screenWidth } = useWindowDimensions()
  const tablet = screenWidth >= 640
  const [measuredWidth, setMeasuredWidth] = useState(0),
    [activeId, setActiveId] = useState<string | null>(defaultActiveId)
  const active = cards.find((card) => card.id === activeId)
  const expanded = Boolean(active)
  const width = Math.max(1, measuredWidth)
  const gutter = gap ?? (tablet ? 12 : 8),
    bigHeight = expandedHeight ?? (tablet ? 192 : 170),
    smallHeight = tileHeight ?? (tablet ? 128 : 112),
    shrunkHeight = compactHeight ?? (tablet ? 112 : 96)
  const secondary = expanded
    ? cards.filter((card) => card.id !== activeId)
    : cards
  const columns = expanded ? 3 : 2,
    itemWidth = (width - (columns - 1) * gutter) / columns
  const rows = Math.ceil(secondary.length / columns)
  const gridHeight = rows
    ? rows * (expanded ? shrunkHeight : smallHeight) + (rows - 1) * gutter
    : 0
  const targetHeight = expanded
    ? bigHeight + (rows ? 12 + gridHeight : 0)
    : gridHeight
  const sceneHeight = useSharedValue(targetHeight),
    layoutProgress = useSharedValue(1)
  const grid = useSharedValue({ scale: 1, translate: 0 })
  const projection = useSharedValue<GridProjection>({
    fromHeight: gridHeight,
    toHeight: gridHeight,
    fromY: expanded ? bigHeight + 12 : 0,
    toY: expanded ? bigHeight + 12 : 0,
    fromScene: targetHeight,
    toScene: targetHeight,
  })
  const old = useRef({
    height: gridHeight,
    y: expanded ? bigHeight + 12 : 0,
    scene: targetHeight,
  })
  const cssState = useSharedValue({
    lastScale: 1,
    lastTranslate: 0,
    fromScale: 1,
    fromTranslate: 0,
    changedAt: 0,
  })
  const reduced = useReducedMotion()
  const gridFrameRef = useRef<{ setActive: (active: boolean) => void } | null>(
    null
  )
  const gridGenerationRef = useRef(0)
  const gridGeneration = useSharedValue(0)
  const queuedStopGeneration = useSharedValue(-1)
  const stopGrid = useCallback((generation: number) => {
    if (generation === gridGenerationRef.current)
      gridFrameRef.current?.setActive(false)
  }, [])
  const gridFrame = useFrameCallback((frame) => {
    const m = projection.get(),
      q = layoutProgress.get()
    const rawScale =
      m.toHeight > 0 ? 1 + (m.fromHeight / m.toHeight - 1) * (1 - q) : 1
    const rawTranslate = (m.fromY - m.toY) * (1 - q)
    const state = cssState.get(),
      current = grid.get()
    const changed =
      Math.abs(rawScale - state.lastScale) > 0.000001 ||
      Math.abs(rawTranslate - state.lastTranslate) > 0.000001
    const p = reduced
      ? 1
      : GRID_EASE(
          Math.max(0, Math.min(1, (frame.timestamp - state.changedAt) / 500))
        )
    const next = {
      scale: state.fromScale + (state.lastScale - state.fromScale) * p,
      translate:
        state.fromTranslate + (state.lastTranslate - state.fromTranslate) * p,
    }
    grid.set(reduced ? { scale: 1, translate: 0 } : next)
    if (changed)
      cssState.set({
        lastScale: rawScale,
        lastTranslate: rawTranslate,
        fromScale: next.scale,
        fromTranslate: next.translate,
        changedAt: frame.timestamp,
      })
    if (
      !changed &&
      p === 1 &&
      current.scale === next.scale &&
      current.translate === next.translate &&
      queuedStopGeneration.get() !== gridGeneration.get()
    ) {
      const generation = gridGeneration.get()
      queuedStopGeneration.set(generation)
      scheduleOnRN(stopGrid, generation)
    }
  }, false)
  gridFrameRef.current = gridFrame
  useEffect(() => {
    const previous = old.current,
      y = expanded ? bigHeight + 12 : 0
    if (previous.height !== gridHeight || previous.y !== y) {
      gridGeneration.set(++gridGenerationRef.current)
      projection.set({
        fromHeight: previous.height,
        toHeight: gridHeight,
        fromY: previous.y,
        toY: y,
        fromScene: previous.scene,
        toScene: targetHeight,
      })
      layoutProgress.set(0)
      layoutProgress.set(withTiming(1, LAYOUT))
      gridFrame.setActive(true)
    }
    sceneHeight.set(withTiming(targetHeight, LAYOUT))
    old.current = { height: gridHeight, y, scene: targetHeight }
  }, [
    targetHeight,
    expanded,
    gridHeight,
    bigHeight,
    sceneHeight,
    layoutProgress,
    projection,
    gridFrame,
    gridGeneration,
  ])
  const scene = useAnimatedStyle(() => ({
    height: Math.max(0, sceneHeight.get()),
  }))
  return (
    <View
      style={[
        {
          width: "100%",
          paddingHorizontal: tablet ? 16 : 12,
          alignItems: "center",
        },
        style,
      ]}
      testID={testID}
    >
      <Pressable
        accessibilityLabel="Collapse selected card"
        accessibilityRole="button"
        onPress={() => setActiveId(null)}
        testID={testID ? `${testID}-background` : undefined}
        style={{ position: "absolute", inset: 0 }}
      />
      <Animated.View
        pointerEvents="box-none"
        onLayout={({ nativeEvent }) =>
          setMeasuredWidth(nativeEvent.layout.width)
        }
        style={[{ width: "100%", maxWidth: 420 }, scene]}
      >
        {measuredWidth > 0 &&
          cards.map((card) => {
            const selected = card.id === active?.id,
              index = secondary.findIndex((item) => item.id === card.id)
            const box = selected
              ? { x: 0, y: 0, width, height: bigHeight }
              : {
                  x: (index % columns) * (itemWidth + gutter),
                  y:
                    (expanded ? bigHeight + 12 : 0) +
                    Math.floor(index / columns) *
                      ((expanded ? shrunkHeight : smallHeight) + gutter),
                  width: itemWidth,
                  height: expanded ? shrunkHeight : smallHeight,
                }
            return (
              <Tile
                key={card.id}
                card={card}
                box={box}
                mode={selected ? "expanded" : expanded ? "compact" : "idle"}
                tablet={tablet}
                color={textColor}
                radius={
                  selected
                    ? (expandedRadius ?? (tablet ? 32 : 28))
                    : (radius ?? (tablet ? 28 : 22))
                }
                blurIntensity={blurIntensity}
                selected={selected}
                onSelect={() => setActiveId(card.id)}
                onCopy={onCopyClick}
                onEdit={onCustomizeClick}
                testID={testID ? `${testID}-${card.id}` : undefined}
                grid={grid}
                layoutProgress={layoutProgress}
                projection={projection}
                sceneHeight={sceneHeight}
                gridOrigin={expanded ? bigHeight + 12 : 0}
              />
            )
          })}
      </Animated.View>
    </View>
  )
}

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

Back to Watermelon UI