Skip to content
pixelfor Expo
Ctrl K

Button

Run Action Button

Six progress steps with rolling letters, icon swaps and a width morph

Try it on your phone

Run Action Button

Scan to open Run Action Button 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.

run-action-button.tsx

import React, {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
} from "react"
import {
  Platform,
  Pressable,
  StyleSheet,
  Text,
  View,
  useColorScheme,
  useWindowDimensions,
  type StyleProp,
  type ViewStyle,
} from "react-native"
import Animated, {
  defineAnimation,
  type AnimationObject,
  ReduceMotion,
  useAnimatedProps,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withSpring,
  type SharedValue,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import Svg, {
  Defs,
  FeGaussianBlur,
  Filter,
  G,
  Path,
  Rect,
  Text as SvgText,
} 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
}
const WEB = Platform.OS === "web"
const FONT = WEB ? "Arial" : Platform.OS === "ios" ? "Helvetica" : "sans-serif"
const SPRING = {
  stiffness: 260,
  damping: 22,
  mass: 0.8,
  reduceMotion: ReduceMotion.System,
} as const
const LETTER = {
  stiffness: 240,
  damping: 16,
  mass: 1.2,
  reduceMotion: ReduceMotion.System,
} as const
const AnimatedG = Animated.createAnimatedComponent(G)
const AnimatedRect = Animated.createAnimatedComponent(Rect)
type IconData = {
  path: string
  size: number
  stroke?: boolean
  evenodd?: boolean
}
const ZAP: IconData = {
  size: 24,
  stroke: true,
  path: "M15.914 4a1.5 1.5 0 00-2.474-1.561l-9 9A1.5 1.5 0 005.5 14h4.002a.5.5 0 01.471.666L8.086 20a1.5 1.5 0 002.475 1.56l9-9A1.5 1.5 0 0018.5 10h-3.997a.5.5 0 01-.472-.667z",
}
const CHECK: IconData = {
  size: 20,
  evenodd: true,
  path: "M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z",
}
const CLOSE: IconData = {
  size: 512,
  path: "M400 145.49 366.51 112 256 222.51 145.49 112 112 145.49 222.51 256 112 366.51 145.49 400 256 289.49 366.51 400 400 366.51 289.49 256z",
}
export type RunActionStep = {
  id: string | number
  label: string
  icon: IconData
}
const DEFAULT_STEPS: RunActionStep[] = [
  {
    id: 1,
    label: "Importing Survey Data",
    icon: {
      size: 512,
      path: "M121 32C91.6 32 66 52 58.9 80.5L1.9 308.4C.6 313.5 0 318.7 0 323.9L0 416c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-92.1c0-5.2-.6-10.4-1.9-15.5l-57-227.9C446 52 420.4 32 391 32L121 32zm0 64l270 0 48 192-51.2 0c-12.1 0-23.2 6.8-28.6 17.7l-14.3 28.6c-5.4 10.8-16.5 17.7-28.6 17.7l-120.4 0c-12.1 0-23.2-6.8-28.6-17.7l-14.3-28.6c-5.4-10.8-16.5-17.7-28.6-17.7L73 288 121 96z",
    },
  },
  {
    id: 2,
    label: "Refining Responses",
    icon: {
      size: 24,
      path: "M16 16C17.6569 16 19 17.3431 19 19C19 20.6569 17.6569 22 16 22C14.3431 22 13 20.6569 13 19C13 17.3431 14.3431 16 16 16ZM6 12C8.20914 12 10 13.7909 10 16C10 18.2091 8.20914 20 6 20C3.79086 20 2 18.2091 2 16C2 13.7909 3.79086 12 6 12ZM14.5 2C17.5376 2 20 4.46243 20 7.5C20 10.5376 17.5376 13 14.5 13C11.4624 13 9 10.5376 9 7.5C9 4.46243 11.4624 2 14.5 2Z",
    },
  },
  {
    id: 3,
    label: "Labelling Responses",
    icon: {
      size: 16,
      path: "M2 1a1 1 0 0 0-1 1v4.586a1 1 0 0 0 .293.707l7 7a1 1 0 0 0 1.414 0l4.586-4.586a1 1 0 0 0 0-1.414l-7-7A1 1 0 0 0 6.586 1zm4 3.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0",
    },
  },
  {
    id: 4,
    label: "Analyzing Sentiment",
    icon: {
      size: 24,
      path: "M17 3.34a10 10 0 1 1 -15 8.66l.005 -.324a10 10 0 0 1 14.995 -8.336m-5 2.66a1 1 0 0 0 -1 1v5a1 1 0 0 0 2 0v-5a1 1 0 0 0 -1 -1",
    },
  },
  {
    id: 5,
    label: "Creating Reports",
    icon: {
      size: 16,
      path: "M12 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2M5 4h6a.5.5 0 0 1 0 1H5a.5.5 0 0 1 0-1m-.5 2.5A.5.5 0 0 1 5 6h6a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5M5 8h6a.5.5 0 0 1 0 1H5a.5.5 0 0 1 0-1m0 2h3a.5.5 0 0 1 0 1H5a.5.5 0 0 1 0-1",
    },
  },
  {
    id: 6,
    label: "Sharing Survey Report",
    icon: {
      size: 16,
      path: "M15.964.686a.5.5 0 0 0-.65-.65L.767 5.855H.766l-.452.18a.5.5 0 0 0-.082.887l.41.26.001.002 4.995 3.178 3.178 4.995.002.002.26.41a.5.5 0 0 0 .886-.083zm-1.833 1.89L6.637 10.07l-.215-.338a.5.5 0 0 0-.154-.154l-.338-.215 7.494-7.494 1.178-.471z",
    },
  },
]
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 AnimatedBlur = Animated.createAnimatedComponent(BlurTarget)
type Layer<T> = { id: number; value: T; entering: boolean; leaving: boolean }
function usePresence<T>(value: T, key: string, entering = false) {
  const previous = useRef(key)
  const next = useRef(1)
  const [layers, setLayers] = useState<Layer<T>[]>([
    { id: 0, value, entering, leaving: false },
  ])
  useLayoutEffect(() => {
    if (previous.current === key) return
    previous.current = key
    const id = next.current++
    setLayers((old) => [
      ...old.map((layer) => ({ ...layer, leaving: true })),
      { id, value, entering: true, leaving: false },
    ])
  }, [key, value])
  const remove = useCallback(
    (id: number) => setLayers((old) => old.filter((layer) => layer.id !== id)),
    []
  )
  return { layers, remove }
}
type FadeProps = {
  entering: boolean
  leaving: boolean
  blurAmount: number
  startScale: number
  centerX: number
  centerY: number
  centerWidth?: SharedValue<number>
  delay?: number
  remove?: () => void
  children: React.ReactNode
}
function Fade({
  entering,
  leaving,
  blurAmount,
  startScale,
  centerX,
  centerY,
  centerWidth,
  delay = 0,
  remove,
  children,
}: FadeProps) {
  const removal = useRef(remove)
  removal.current = remove
  const finish = useCallback(() => removal.current?.(), [])
  const p = useSharedValue(entering ? 0 : 1)
  const reduced = useReducedMotion()
  const filter = `fade-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`
  useEffect(() => {
    if (!entering && !leaving) return
    p.set(
      motionDelay(
        reduced ? 0 : delay,
        withSpring(leaving ? 0 : 1, SPRING, (finished) => {
          if (finished && leaving) scheduleOnRN(finish)
        })
      )
    )
  }, [entering, leaving, p, delay, finish, reduced])
  const props = useAnimatedProps(() => {
    const progress = p.get()
    const scale = reduced ? 1 : startScale + (1 - startScale) * progress
    const x = (centerWidth ? centerWidth.get() / 2 : centerX) * (1 - scale),
      y = centerY * (1 - scale)
    return {
      opacity: Math.max(0, Math.min(1, progress)),
      ...(WEB
        ? { transform: `matrix(${scale} 0 0 ${scale} ${x} ${y})` }
        : { matrix: [scale, 0, 0, scale, x, y] }),
    }
  })
  const blur = useAnimatedProps(() => {
    const amount = reduced ? 0 : Math.max(0, blurAmount * (1 - p.get()))
    return {
      stdDeviation: amount,
      stdDeviationX: amount,
      stdDeviationY: amount,
    }
  })
  return (
    <>
      <Defs>
        <Filter id={filter} x="-50%" y="-100%" width="200%" height="300%">
          <AnimatedBlur animatedProps={blur} />
        </Filter>
      </Defs>
      <AnimatedG animatedProps={props} filter={`url(#${filter})`}>
        {children}
      </AnimatedG>
    </>
  )
}
type Metrics = Record<string, number>
function Character({
  char,
  index,
  x,
  advance,
  fontSize,
  weight,
  color,
  entering,
  leaving,
  last,
  remove,
}: {
  char: string
  index: number
  x: number
  advance: number
  fontSize: number
  weight: "500" | "700"
  color: string
  entering: boolean
  leaving: boolean
  last: boolean
  remove: () => void
}) {
  const removal = useRef(remove)
  removal.current = remove
  const finish = useCallback(() => removal.current(), [])
  const p = useSharedValue(entering ? 0 : 1)
  const reduced = useReducedMotion()
  const filter = `char-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`
  useEffect(() => {
    if (!entering && !leaving) return
    p.set(
      motionDelay(
        reduced ? 0 : index * 14,
        withSpring(leaving ? 0 : 1, LETTER, (finished) => {
          if (finished && leaving && last) scheduleOnRN(finish)
        })
      )
    )
  }, [entering, leaving, index, last, finish, p, reduced])
  const props = useAnimatedProps(() => {
    const progress = p.get(),
      scale = reduced ? 1 : 0.5 + progress * 0.5
    const cx = x + advance / 2
    const tx = cx * (1 - scale),
      ty =
        32 * (1 - scale) + (reduced ? 0 : (1 - progress) * (leaving ? -10 : 10))
    return {
      opacity: Math.max(0, Math.min(1, progress)),
      ...(WEB
        ? { transform: `matrix(${scale} 0 0 ${scale} ${tx} ${ty})` }
        : { matrix: [scale, 0, 0, scale, tx, ty] }),
    }
  })
  const blur = useAnimatedProps(() => {
    const amount = reduced ? 0 : Math.max(0, 2 * (1 - p.get()))
    return {
      stdDeviation: amount,
      stdDeviationX: amount,
      stdDeviationY: amount,
    }
  })
  return (
    <>
      <Defs>
        <Filter id={filter} x="-150%" y="-100%" width="400%" height="300%">
          <AnimatedBlur animatedProps={blur} />
        </Filter>
      </Defs>
      <AnimatedG animatedProps={props} filter={`url(#${filter})`}>
        <SvgText
          x={x}
          y={32 + fontSize * 0.35}
          fontFamily={FONT}
          fontSize={fontSize}
          fontWeight={weight}
          fill={color}
        >
          {char === " " ? "\u00a0" : char}
        </SvgText>
      </AnimatedG>
    </>
  )
}
function TextRun({
  label,
  x,
  color,
  weight,
  fontSize,
  metrics,
  entering,
  leaving,
}: {
  label: string
  x: number
  color: string
  weight: "500" | "700"
  fontSize: number
  metrics: Metrics
  entering: boolean
  leaving: boolean
}) {
  const { layers, remove } = usePresence(label, label, entering)
  return (
    <>
      {layers.map((layer) => {
        let position = x
        return (
          <G key={layer.id}>
            {Array.from(layer.value).map((char, index, chars) => {
              const advance = metrics[`${weight}-${char}`] ?? 0,
                left = position
              position += advance
              return (
                <Character
                  key={index}
                  char={char}
                  index={index}
                  x={left}
                  advance={advance}
                  color={color}
                  weight={weight}
                  fontSize={fontSize}
                  entering={layer.entering}
                  leaving={leaving || layer.leaving}
                  last={index === chars.length - 1}
                  remove={() => remove(layer.id)}
                />
              )
            })}
          </G>
        )
      })}
    </>
  )
}
function Icon({
  data,
  x,
  y,
  size,
  color,
}: {
  data: IconData
  x: number
  y: number
  size: number
  color: string
}) {
  return (
    <G transform={`translate(${x} ${y}) scale(${size / data.size})`}>
      <Path
        d={data.path}
        fill={data.stroke ? "none" : color}
        stroke={data.stroke ? color : undefined}
        strokeWidth={data.stroke ? 2 : undefined}
        strokeLinecap="round"
        strokeLinejoin="round"
        fillRule={data.evenodd ? "evenodd" : "nonzero"}
      />
    </G>
  )
}
function StepIcon({
  step,
  color,
  leaving,
}: {
  step: RunActionStep
  color: string
  leaving: boolean
}) {
  const { layers, remove } = usePresence(step.icon, String(step.id), true)
  return (
    <>
      {layers.map((layer) => (
        <Fade
          key={layer.id}
          entering={layer.entering}
          leaving={leaving || layer.leaving}
          startScale={0}
          blurAmount={4}
          centerX={30}
          centerY={32}
          remove={() => remove(layer.id)}
        >
          <Icon data={layer.value} x={18} y={20} size={24} color={color} />
        </Fade>
      ))}
    </>
  )
}
type Phase = "idle" | "running" | "done"
type Palette = {
  text: string
  idle: string
  done: string
  success: string
  border: string
  cancel: string
  cancelIcon: string
}
function Stage({
  layer,
  active,
  step,
  metrics,
  fontSize,
  palette,
  targetWidth,
  runningWidth,
  width,
  remove,
}: {
  layer: Layer<Phase>
  active: boolean
  step: RunActionStep
  metrics: Metrics
  fontSize: number
  palette: Palette
  targetWidth: number
  runningWidth: number
  width: SharedValue<number>
  remove: () => void
}) {
  const lastStep = useRef(step)
  if (active) lastStep.current = step
  const frozen = lastStep.current
  const running = layer.value === "running",
    done = layer.value === "done"
  const color = done ? palette.success : palette.text
  const height = fontSize * 1.5 + 24
  const rect = useAnimatedProps(() => ({
    width: (active ? width.get() : targetWidth) - 4,
  }))
  return (
    <Fade
      entering={layer.entering}
      leaving={layer.leaving}
      startScale={0.8}
      blurAmount={4}
      centerX={targetWidth / 2}
      centerY={32}
      centerWidth={active ? width : undefined}
      remove={remove}
    >
      {!running && (
        <AnimatedRect
          animatedProps={rect}
          x={2}
          y={(64 - height) / 2}
          height={height}
          rx={height / 2}
          fill={done ? palette.done : palette.idle}
        />
      )}
      {running ? (
        <StepIcon step={frozen} color={color} leaving={layer.leaving} />
      ) : (
        <Icon data={done ? CHECK : ZAP} x={22} y={20} size={24} color={color} />
      )}
      <TextRun
        label={running ? frozen.label : done ? "Action Done" : "Run Action"}
        x={running ? 50 : 54}
        color={color}
        weight={layer.value === "idle" ? "500" : "700"}
        fontSize={fontSize}
        metrics={metrics}
        entering={layer.entering}
        leaving={layer.leaving}
      />
      {running && (
        <Fade
          entering={true}
          leaving={layer.leaving}
          startScale={0.8}
          blurAmount={4}
          centerX={runningWidth - 32}
          centerY={32}
          delay={150}
        >
          <Rect
            x={runningWidth - 46}
            y={18}
            width={28}
            height={28}
            rx={14}
            fill={palette.cancel}
          />
          <Icon
            data={CLOSE}
            x={runningWidth - 40}
            y={24}
            size={16}
            color={palette.cancelIcon}
          />
        </Fade>
      )}
    </Fade>
  )
}
export type RunActionButtonProps = {
  steps?: RunActionStep[]
  stepDuration?: number
  onComplete?: () => void
  onCancel?: () => void
  theme?: "light" | "dark" | "system"
  textColor?: string
  backgroundColor?: string
  successColor?: string
  successBackgroundColor?: string
  borderColor?: string
  cancelColor?: string
  cancelIconColor?: string
  widths?: { idle: number; running: number; done: number }
  radius?: number
  fontSize?: number
  style?: StyleProp<ViewStyle>
  testID?: string
}
export function RunActionButton({
  steps = DEFAULT_STEPS,
  stepDuration = 1200,
  onComplete,
  onCancel,
  theme = "system",
  textColor,
  backgroundColor,
  successColor = "#22c55e",
  successBackgroundColor,
  borderColor,
  cancelColor,
  cancelIconColor,
  widths = { idle: 180, running: 360, done: 200 },
  radius = 999,
  fontSize = 18,
  style,
  testID,
}: RunActionButtonProps = {}) {
  const system = useColorScheme(),
    { width: screenWidth, fontScale } = useWindowDimensions()
  const dark = theme === "dark" || (theme === "system" && system === "dark")
  const [status, setStatus] = useState<Phase>("idle"),
    [currentStep, setCurrentStep] = useState(0)
  const [metrics, setMetrics] = useState<Metrics>({})
  const [availableWidth, setAvailableWidth] = useState(screenWidth)
  const phaseRef = useRef<Phase>("idle")
  const stepRef = useRef(0)
  const callbacks = useRef({ onComplete, onCancel })
  callbacks.current = { onComplete, onCancel }
  const list = steps.length ? steps : DEFAULT_STEPS
  const current = list[Math.min(currentStep, list.length - 1)]
  const runningWidth = Math.min(widths.running, Math.max(180, availableWidth))
  const targetWidth = status === "running" ? runningWidth : widths[status]
  const width = useSharedValue(widths.idle)
  const { layers, remove } = usePresence(status, status)
  const palette: Palette = {
    text: textColor ?? (dark ? "#f4f4f5" : "#26262b"),
    idle: backgroundColor ?? (dark ? "#27272a" : "#f4f4f9"),
    done: successBackgroundColor ?? (dark ? "#b9f8cf" : "#eaf9ea"),
    success: successColor,
    border: borderColor ?? (dark ? "rgba(255,255,255,.2)" : "#d6d6dd"),
    cancel: cancelColor ?? (dark ? "#fff" : "#d6d5e2"),
    cancelIcon: cancelIconColor ?? (dark ? "#000" : "#fff"),
  }
  const chars = Array.from(
    new Set(
      Array.from(
        ["Run Action", "Action Done", ...list.map((item) => item.label)].join(
          ""
        )
      )
    )
  )
  const measurements = (["500", "700"] as const).flatMap((weight) =>
    chars.map((char) => ({ char, weight, key: `${weight}-${char}` }))
  )
  const ready = measurements.every((item) => metrics[item.key] !== undefined)
  const change = (next: Phase) => {
    phaseRef.current = next
    setStatus(next)
  }
  useEffect(() => {
    width.set(withSpring(targetWidth, SPRING))
  }, [targetWidth, width])
  useEffect(() => {
    if (status !== "running") return
    const timer = setInterval(
      () => {
        if (phaseRef.current !== "running") return
        if (stepRef.current < list.length - 1) {
          stepRef.current += 1
          setCurrentStep(stepRef.current)
        } else {
          phaseRef.current = "done"
          setStatus("done")
          clearInterval(timer)
          callbacks.current.onComplete?.()
        }
      },
      Math.max(1, stepDuration)
    )
    return () => clearInterval(timer)
  }, [status, list.length, stepDuration])
  const animatedStyle = useAnimatedStyle(() => ({ width: width.get() }))
  const reset = () => {
    if (phaseRef.current === "running") callbacks.current.onCancel?.()
    stepRef.current = 0
    change("idle")
    setCurrentStep(0)
  }
  const start = () => {
    if (phaseRef.current !== "idle") return
    stepRef.current = 0
    setCurrentStep(0)
    change("running")
  }
  return (
    <View
      style={[styles.wrapper, style]}
      testID={testID}
      onLayout={({ nativeEvent }) =>
        setAvailableWidth(nativeEvent.layout.width)
      }
    >
      <View
        pointerEvents="none"
        accessibilityElementsHidden
        importantForAccessibility="no-hide-descendants"
        style={styles.measure}
      >
        {measurements.map(({ char, weight, key }) => (
          <Text
            key={key}
            allowFontScaling={false}
            style={{
              fontFamily: FONT,
              fontWeight: weight,
              fontSize: fontSize * fontScale * 10,
            }}
            onLayout={({ nativeEvent }) => {
              const advance = nativeEvent.layout.width / 10
              setMetrics((old) =>
                old[key] === advance ? old : { ...old, [key]: advance }
              )
            }}
          >
            {char}
          </Text>
        ))}
      </View>
      {ready && (
        <Animated.View
          style={[
            styles.card,
            {
              borderRadius: Math.min(radius, 32),
              borderColor:
                status === "running" ? palette.border : "transparent",
              borderStyle: status === "running" ? "dashed" : "solid",
            },
            animatedStyle,
          ]}
        >
          <Svg
            width={Math.max(widths.idle, runningWidth, widths.done)}
            height={64}
            style={{ position: "absolute", left: -2, top: -2 }}
            accessible={false}
            pointerEvents="none"
          >
            {layers.map((layer) => (
              <Stage
                key={layer.id}
                layer={layer}
                active={!layer.leaving && layer.value === status}
                step={current}
                metrics={metrics}
                fontSize={fontSize * fontScale}
                palette={palette}
                targetWidth={
                  layer.value === "running" ? runningWidth : widths[layer.value]
                }
                runningWidth={runningWidth}
                width={width}
                remove={() => remove(layer.id)}
              />
            ))}
          </Svg>
          {status === "running" ? (
            <>
              <View
                accessible
                accessibilityRole="progressbar"
                accessibilityLabel={current.label}
                accessibilityValue={{
                  min: 0,
                  max: list.length,
                  now: currentStep + 1,
                }}
                accessibilityLiveRegion="polite"
                style={StyleSheet.absoluteFill}
              />
              <Pressable
                accessibilityRole="button"
                accessibilityLabel="Cancel action"
                onPress={reset}
                hitSlop={10}
                testID={testID ? `${testID}-cancel` : undefined}
                style={{
                  position: "absolute",
                  right: 16,
                  top: 16,
                  width: 28,
                  height: 28,
                }}
              />
            </>
          ) : (
            <Pressable
              accessibilityRole="button"
              accessibilityLabel={
                status === "done" ? "Action Done, reset" : "Run Action"
              }
              accessibilityLiveRegion="polite"
              onPress={status === "done" ? reset : start}
              testID={testID ? `${testID}-${status}` : undefined}
              style={StyleSheet.absoluteFill}
            />
          )}
        </Animated.View>
      )}
    </View>
  )
}
const styles = StyleSheet.create({
  wrapper: {
    width: "100%",
    minHeight: 64,
    alignItems: "center",
    justifyContent: "center",
  },
  card: { height: 64, borderWidth: 2, overflow: "hidden" },
  measure: {
    position: "absolute",
    opacity: 0,
    height: 0,
    overflow: "hidden",
    alignItems: "flex-start",
    pointerEvents: "none",
  },
})

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

Back to Watermelon UI