Interaction
Run Widget
An expressive interaction rebuilt for touch
Expo code
Original web component
Watermelon UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
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.