Skip to content
pixelfor Expo
Ctrl K

Interaction

Run Widget

An expressive interaction rebuilt for touch

Try it on your phone

Run Widget

Scan to open Run Widget 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-widget.in-progress.tsx

import React from "react"
import {
  Platform,
  Pressable,
  Text,
  View,
  useColorScheme,
  type StyleProp,
  type ViewStyle,
} from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import Animated, {
  Easing,
  cancelAnimation,
  defineAnimation,
  useAnimatedReaction,
  useAnimatedStyle,
  useSharedValue,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import Svg, { Path } from "react-native-svg"
import { LinearGradient } from "expo-linear-gradient"
const NUMBER_CURVE = [
  0, 0.005, 0.019, 0.039, 0.066, 0.096, 0.129, 0.165, 0.202, 0.24, 0.278, 0.316,
  0.354, 0.39, 0.426, 0.461, 0.494, 0.526, 0.557, 0.586, 0.614, 0.64, 0.665,
  0.689, 0.711, 0.731, 0.751, 0.769, 0.786, 0.802, 0.817, 0.831, 0.844, 0.856,
  0.867, 0.877, 0.887, 0.896, 0.904, 0.912, 0.919, 0.925, 0.931, 0.937, 0.942,
  0.947, 0.951, 0.955, 0.959, 0.962, 0.965, 0.968, 0.971, 0.973, 0.976, 0.978,
  0.98, 0.981, 0.983, 0.984, 0.986, 0.987, 0.988, 0.989, 0.99, 0.991, 0.992,
  0.992, 0.993, 0.994, 0.994, 0.995, 0.995, 0.996, 0.996, 0.9963, 0.9967,
  0.9969, 0.9972, 0.9975, 0.9977, 0.9979, 0.9981, 0.9982, 0.9984, 0.9985,
  0.9987, 0.9988, 0.9989, 1,
]
function numberEase(t: number) {
  "worklet"
  const p = Math.max(0, Math.min(1, t)) * (NUMBER_CURVE.length - 1)
  const i = Math.floor(p)
  return (
    NUMBER_CURVE[i] +
    (NUMBER_CURVE[Math.min(i + 1, NUMBER_CURVE.length - 1)] - NUMBER_CURVE[i]) *
      (p - i)
  )
}
interface Momentum {
  onStart: (a: Momentum, value: number, time: number) => void
  onFrame: (a: Momentum, time: number) => boolean
  current: number
  start: number
  origin: number
  boundary: number
  boundaryTime: number
  boundaryOrigin: number
  boundaryVelocity: number
}
function momentum(velocity: number, minimum: number): number {
  "worklet"
  return defineAnimation<Momentum>(0, () => ({
    current: 0,
    start: 0,
    origin: 0,
    boundary: 0,
    boundaryTime: -1,
    boundaryOrigin: 0,
    boundaryVelocity: 0,
    onStart(a, value, time) {
      a.current = a.origin = value
      a.start = time
      a.boundaryTime = -1
      if (value < minimum || value > 0) {
        a.boundary = value < minimum ? minimum : 0
        a.boundaryOrigin = value
        a.boundaryVelocity = ((0.8 * velocity) / 750) * 1000
        a.boundaryTime = time
      }
    },
    onFrame(a, time) {
      const ms = time - a.start,
        amplitude = 0.8 * velocity,
        target = a.origin + amplitude
      if (a.boundaryTime < 0) {
        const delta = -amplitude * Math.exp(-ms / 750)
        a.current = target + delta
        if (a.current < minimum || a.current > 0) {
          a.boundary = a.current < minimum ? minimum : 0
          a.boundaryOrigin = a.current
          a.boundaryVelocity = (-delta / 750) * 1000
          a.boundaryTime = time
        } else if (Math.abs(delta) <= 1) {
          a.current = target
          return true
        }
      }
      if (a.boundaryTime >= 0) {
        const t = (time - a.boundaryTime) / 1000,
          d = a.boundaryOrigin - a.boundary,
          r1 = -20 + Math.sqrt(200),
          r2 = -20 - Math.sqrt(200),
          c1 = (a.boundaryVelocity - r2 * d) / (r1 - r2),
          c2 = d - c1,
          e1 = Math.exp(r1 * t),
          e2 = Math.exp(r2 * t)
        a.current = a.boundary + c1 * e1 + c2 * e2
        if (
          Math.abs(a.current - a.boundary) <= 1 &&
          Math.abs(c1 * r1 * e1 + c2 * r2 * e2) <= 10
        ) {
          a.current = a.boundary
          return true
        }
      }
      return false
    },
  })) as unknown as number
}
export interface RunWidgetProps {
  theme?: "light" | "dark" | "system"
  initialMiles?: number
  maximumMiles?: number
  size?: number
  radius?: number
  fontFamily?: string
  colors?: Partial<{
    surface: string
    border: string
    number: string
    muted: string
    tick: string
    marker: string
    action: string
    actionText: string
    arrow: string
  }>
  onBeginRun?: (miles: number) => void
  onOpen?: (miles: number) => void
  onDistanceChange?: (miles: number) => void
  style?: StyleProp<ViewStyle>
  testID?: string
}
interface FlowTiming {
  kind: "number-flow"
  current: number
  target: number
  parts: { start: number; delta: number }[]
  onStart: (
    a: FlowTiming,
    value: number,
    time: number,
    previous?: FlowTiming
  ) => void
  onFrame: (a: FlowTiming, time: number) => boolean
}
function flowTiming(
  target: number,
  duration = 900,
  easing = numberEase
): number {
  "worklet"
  return defineAnimation<FlowTiming>(target, () => ({
    kind: "number-flow",
    current: target,
    target,
    parts: [],
    onStart(a, value, time, previous) {
      const retained = previous?.kind === "number-flow"
      const delta = (retained ? previous.target : value) - target
      a.parts = retained
        ? previous.parts.filter((part) => time - part.start < duration)
        : []
      if (delta !== 0) a.parts = [...a.parts, { start: time, delta }]
      a.current = value
    },
    onFrame(a, time) {
      a.parts = a.parts.filter((part) => time - part.start < duration)
      a.current = target
      for (const part of a.parts)
        a.current +=
          part.delta * (1 - easing(Math.max(0, (time - part.start) / duration)))
      return a.parts.length === 0
    },
  })) as unknown as number
}
function spinDelta(next: number, previous: number, trend: number) {
  "worklet"
  if (trend < 0 && next > previous) return next - 10 - previous
  if (trend > 0 && next < previous) return 10 - previous + next
  return next - previous
}
const opacityEase = Easing.bezier(0, 0, 0.58, 1).factory()
function Wheel({
  value,
  divisor,
  color,
  fontFamily,
  left,
  initial,
  highest,
}: {
  value: ReturnType<typeof useSharedValue<number>>
  divisor: number
  color: string
  fontFamily?: string
  left: number
  initial: number
  highest?: boolean
}) {
  const rotation = useSharedValue(Math.floor(initial / divisor) % 10)
  const targetRotation = useSharedValue(Math.floor(initial / divisor) % 10)
  const position = useSharedValue(left)
  const presence = useSharedValue(!highest || initial >= divisor ? 1 : 0)
  useAnimatedReaction(
    () => Math.max(1, String(Math.floor(value.get() / 10)).length),
    (digits, previous) => {
      if (digits === previous) return
      const offset =
        divisor === 1
          ? 47 * digits + 23
          : 47 * (digits - 1 - Math.log10(divisor / 10))
      const present = !highest || value.get() >= divisor ? 1 : 0
      if (previous === null) {
        position.set(offset)
        presence.set(present)
      } else {
        position.set(flowTiming(offset))
        presence.set(flowTiming(present, 450, opacityEase))
      }
    }
  )
  const placement = useAnimatedStyle(() => ({
    opacity: Math.max(0, Math.min(1, presence.get())),
    transform: [{ translateX: position.get() - left }],
  }))
  useAnimatedReaction(
    () => value.get(),
    (nextValue, previousValue) => {
      if (previousValue === null) return
      const next = Math.floor(nextValue / divisor) % 10
      const previous = Math.floor(previousValue / divisor) % 10
      const delta = spinDelta(
        next,
        previous,
        Math.sign(nextValue - previousValue)
      )
      if (delta === 0) return
      targetRotation.set(targetRotation.get() + delta)
      rotation.set(flowTiming(targetRotation.get()))
    }
  )
  return (
    <Animated.View
      style={[
        {
          position: "absolute",
          left,
          top: 0,
          width: 47,
          height: 84,
          overflow: "hidden",
        },
        placement,
      ]}
    >
      {Array.from({ length: 10 }, (_, digit) => (
        <RollingDigit
          key={digit}
          digit={digit}
          rotation={rotation}
          color={color}
          fontFamily={fontFamily}
        />
      ))}
    </Animated.View>
  )
}
function RollingDigit({
  digit,
  rotation,
  color,
  fontFamily,
}: {
  digit: number
  rotation: ReturnType<typeof useSharedValue<number>>
  color: string
  fontFamily?: string
}) {
  const motion = useAnimatedStyle(() => {
    const offset = ((((digit - rotation.get() + 5) % 10) + 10) % 10) - 5
    return {
      transform: [{ translateY: Math.max(-1, Math.min(1, offset)) * 84 }],
      opacity: Math.abs(offset) > 1.05 ? 0 : 1,
    }
  })
  return (
    <Animated.Text
      style={[
        {
          position: "absolute",
          width: 47,
          height: 84,
          textAlign: "center",
          fontFamily,
          fontSize: 84,
          lineHeight: 84,
          fontWeight: "700",
          fontVariant: ["tabular-nums"],
          color,
          includeFontPadding: false,
        },
        motion,
      ]}
    >
      {digit}
    </Animated.Text>
  )
}
export function RunWidget({
  theme = "system",
  initialMiles = 0.8,
  maximumMiles = 10,
  size = 320,
  radius = 42,
  fontFamily = Platform.OS === "web" ? "Arial" : undefined,
  colors: overrides,
  onBeginRun,
  onOpen,
  onDistanceChange,
  style,
  testID,
}: RunWidgetProps) {
  const scheme = useColorScheme()
  const dark = theme === "dark" || (theme === "system" && scheme === "dark")
  const colors = {
    surface: dark ? "#1c1c1c" : "#fefefe",
    border: dark ? "rgba(255,255,255,.05)" : "#e7e5e1",
    number: dark ? "#fff" : "#282828",
    muted: dark ? "#6b7280" : "#8c8c8a",
    tick: dark ? "rgba(255,255,255,.2)" : "#dfdddc",
    marker: dark ? "#fff" : "#595753",
    action: dark ? "rgba(255,255,255,.1)" : "#f0ece6",
    actionText: dark ? "#fff" : "#2a2620",
    arrow: dark ? "#3a3a38" : "#8d8c85",
    ...overrides,
  }
  const x = useSharedValue(-initialMiles * 120),
    start = useSharedValue(0),
    value = useSharedValue(Math.round(initialMiles * 10))
  useAnimatedReaction(
    () => -maximumMiles * 120,
    (minimum, previous) => {
      if (previous === minimum) return
      if (previous !== null || x.get() < minimum || x.get() > 0)
        x.set(momentum(0, minimum))
    }
  )
  useAnimatedReaction(
    () => Math.round(Math.abs(x.get()) / 12),
    (next, prev) => {
      value.set(next)
      if (next !== prev && onDistanceChange)
        scheduleOnRN(onDistanceChange, next / 10)
    }
  )
  const ruler = useAnimatedStyle(() => ({
    transform: [{ translateX: x.get() }],
  }))
  const decimalX = useSharedValue(
    47 * Math.max(1, String(Math.floor(initialMiles)).length)
  )
  useAnimatedReaction(
    () => Math.max(1, String(Math.floor(value.get() / 10)).length),
    (count, previous) => {
      if (count !== previous) {
        if (previous === null) decimalX.set(47 * count)
        else decimalX.set(flowTiming(47 * count))
      }
    }
  )
  const decimalStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: decimalX.get() - 47 }],
  }))
  const pan = Gesture.Pan()
    .activeOffsetX([-3, 3])
    .onStart((e) => {
      cancelAnimation(x)
      start.set(x.get() - e.translationX)
    })
    .onUpdate((e) => {
      let next = start.get() + e.translationX
      const minimum = -maximumMiles * 120
      if (next > 0) next *= 0.05
      else if (next < minimum) next = minimum + (next - minimum) * 0.05
      x.set(next)
    })
    .onEnd((e) => {
      x.set(momentum(e.velocityX, -maximumMiles * 120))
    })
    .onFinalize((_event, success) => {
      if (!success && (x.get() > 0 || x.get() < -maximumMiles * 120))
        x.set(momentum(0, -maximumMiles * 120))
    })
  return (
    <View
      testID={testID}
      style={[
        {
          alignSelf: "center",
          width: size,
          height: size,
          borderRadius: radius,
          borderWidth: 1.6,
          borderColor: colors.border,
          backgroundColor: colors.surface,
          padding: 7,
          overflow: "hidden",
          boxShadow:
            "0px 10px 15px -3px rgba(0,0,0,.1), 0px 4px 6px -4px rgba(0,0,0,.1)",
        },
        style,
      ]}
    >
      <View
        style={{
          position: "absolute",
          left: 12,
          top: 20,
          width: 180,
          height: 76,
          overflow: "hidden",
        }}
      >
        {Array.from(
          { length: Math.max(2, String(Math.floor(maximumMiles)).length) },
          (_, index) => (
            <Wheel
              key={index}
              value={value}
              divisor={10 ** (index + 1)}
              color={colors.number}
              fontFamily={fontFamily}
              left={0}
              initial={Math.round(initialMiles * 10)}
              highest={index > 0}
            />
          )
        )}
        <Animated.Text
          style={[
            {
              position: "absolute",
              left: 47,
              top: 0,
              fontSize: 84,
              lineHeight: 84,
              fontWeight: "700",
              color: colors.number,
              fontFamily,
            },
            decimalStyle,
          ]}
        >
          .
        </Animated.Text>
        <Wheel
          value={value}
          divisor={1}
          color={colors.number}
          fontFamily={fontFamily}
          left={70}
          initial={Math.round(initialMiles * 10)}
        />
        <LinearGradient
          pointerEvents="none"
          colors={[colors.surface, "transparent"]}
          style={{
            position: "absolute",
            top: 0,
            height: 10,
            left: 0,
            right: 0,
          }}
        />
        <LinearGradient
          pointerEvents="none"
          colors={["transparent", colors.surface]}
          style={{
            position: "absolute",
            bottom: 0,
            height: 10,
            left: 0,
            right: 0,
          }}
        />
      </View>
      <Text
        style={{
          position: "absolute",
          left: 20,
          top: 104,
          fontSize: 24,
          fontWeight: "500",
          color: colors.muted,
          fontFamily,
        }}
      >
        miles
      </Text>
      <Pressable
        accessibilityRole="button"
        accessibilityLabel="Open run details"
        onPress={() => onOpen?.(value.get() / 10)}
        style={(state) => ({
          position: "absolute",
          right: 20,
          top: 20,
          width: 54,
          height: 54,
          borderRadius: 12,
          alignItems: "center",
          justifyContent: "center",
          backgroundColor: (state as { hovered?: boolean }).hovered
            ? dark
              ? "#4a4a48"
              : "rgba(143,141,139,.8)"
            : colors.arrow,
        })}
      >
        <Svg width={34} height={34} viewBox="0 0 24 24">
          <Path
            d="M7 17 17 7M7 7h10v10"
            stroke="white"
            strokeWidth={3}
            strokeLinecap="round"
            strokeLinejoin="round"
            fill="none"
          />
        </Svg>
      </Pressable>
      <GestureDetector gesture={pan}>
        <View
          accessible
          accessibilityLabel="Distance ruler"
          style={{
            position: "absolute",
            left: 0,
            right: 0,
            top: 144,
            height: 96,
            overflow: "hidden",
          }}
        >
          <Animated.View
            style={[
              {
                position: "absolute",
                left: size / 2,
                top: 30,
                flexDirection: "row",
                gap: 12,
              },
              ruler,
            ]}
          >
            {Array.from({ length: 200 }, (_, i) => (
              <View
                key={i}
                style={{
                  width: 4,
                  height: 36,
                  borderRadius: 2,
                  backgroundColor: colors.tick,
                }}
              />
            ))}
          </Animated.View>
          <View
            pointerEvents="none"
            style={{
              position: "absolute",
              left: size / 2 - 2,
              top: 24,
              width: 4,
              height: 48,
              borderRadius: 2,
              backgroundColor: colors.marker,
            }}
          />
        </View>
      </GestureDetector>
      <Pressable
        accessibilityRole="button"
        accessibilityLabel="Begin Run"
        onPress={() => onBeginRun?.(value.get() / 10)}
        style={(state) => ({
          position: "absolute",
          left: 7,
          right: 7,
          bottom: 7,
          height: 69,
          borderRadius: 8,
          borderBottomLeftRadius: 32,
          borderBottomRightRadius: 32,
          alignItems: "center",
          justifyContent: "center",
          flexDirection: "row",
          gap: 8,
          backgroundColor: (state as { hovered?: boolean }).hovered
            ? dark
              ? "rgba(255,255,255,.2)"
              : "#e9e4dc"
            : colors.action,
        })}
      >
        <Svg width={28} height={28} viewBox="0 0 416 512">
          <Path
            d="M272 96c26.51 0 48-21.49 48-48S298.51 0 272 0s-48 21.49-48 48 21.49 48 48 48zM113.69 317.47l-14.8 34.52H32c-17.67 0-32 14.33-32 32s14.33 32 32 32h77.45c19.25 0 36.58-11.44 44.11-29.09l8.79-20.52-10.67-6.3c-17.32-10.23-30.06-25.37-37.99-42.61zM384 223.99h-44.03l-26.06-53.25c-12.5-25.55-35.45-44.23-61.78-50.94l-71.08-21.14c-28.3-6.8-57.77-.55-80.84 17.14l-39.67 30.41c-14.03 10.75-16.69 30.83-5.92 44.86s30.84 16.66 44.86 5.92l39.69-30.41c7.67-5.89 17.44-8 25.27-6.14l14.7 4.37-37.46 87.39c-12.62 29.48-1.31 64.01 26.3 80.31l84.98 50.17-27.47 87.73c-5.28 16.86 4.11 34.81 20.97 40.09 3.19 1 6.41 1.48 9.58 1.48 13.61 0 26.23-8.77 30.52-22.45l31.64-101.06c5.91-20.77-2.89-43.08-21.64-54.39l-61.24-36.14 31.31-78.28 20.27 41.43c8 16.34 24.92 26.89 43.11 26.89H384c17.67 0 32-14.33 32-32s-14.33-31.99-32-31.99z"
            fill={colors.actionText}
          />
        </Svg>
        <Text
          style={{
            fontSize: 22,
            fontWeight: "600",
            color: colors.actionText,
            fontFamily,
          }}
        >
          Begin Run
        </Text>
      </Pressable>
    </View>
  )
}
export default RunWidget

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

Back to Watermelon UI