Skip to content
pixelfor Expo
Ctrl K

Input

Stepper

Digits roll, blur and spring independently as the count changes

Try it on your phone

Stepper

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

stepper.tsx

import React, { useEffect, useId, useRef, useState } from "react"
import {
  Platform,
  Pressable,
  StyleSheet,
  View,
  useColorScheme,
  useWindowDimensions,
  type StyleProp,
  type ViewStyle,
} from "react-native"
import Animated, {
  ReduceMotion,
  useAnimatedProps,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withSpring,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import Svg, {
  Defs,
  FeGaussianBlur,
  Filter,
  G,
  Path,
  Text as SvgText,
} from "react-native-svg"
const WEB = Platform.OS === "web"
const DIGIT_SPRING = {
  stiffness: 200,
  damping: 16,
  mass: 1.2,
  reduceMotion: ReduceMotion.System,
} as const
const PRESS_SPRING = {
  stiffness: 300,
  damping: 22,
  reduceMotion: ReduceMotion.System,
} as const
const AnimatedGroup = Animated.createAnimatedComponent(G)
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 = {
  id: number
  digit: string
  leaving: boolean
  entering: boolean
  direction: number
}
function Glyph({
  layer,
  color,
  size,
  width,
  height,
  remove,
}: {
  layer: Layer
  color: string
  size: number
  width: number
  height: number
  remove: (id: number) => void
}) {
  const reduced = useReducedMotion()
  const progress = useSharedValue(layer.entering ? 0 : 1)
  const filter = `digit-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`
  useEffect(() => {
    if (!layer.leaving && !layer.entering) return
    progress.set(
      withSpring(layer.leaving ? 0 : 1, DIGIT_SPRING, (finished) => {
        if (finished && layer.leaving) scheduleOnRN(remove, layer.id)
      })
    )
  }, [layer.leaving, layer.entering, layer.id, progress, remove])
  const props = useAnimatedProps<{
    opacity: number
    transform?: string
    matrix?: number[]
  }>(() => {
    const p = progress.get()
    const y = reduced
      ? 0
      : (1 - p) * 20 * layer.direction * (layer.leaving ? -1 : 1)
    const scale = reduced ? 1 : ((0.5 + p * 0.5) * 800) / (800 - 10 * p)
    const x = (width / 2) * (1 - scale)
    const ty = y + (height / 2) * (1 - scale)
    return {
      opacity: Math.max(0, Math.min(1, p)),
      ...(WEB
        ? { transform: `matrix(${scale} 0 0 ${scale} ${x} ${ty})` }
        : { matrix: [scale, 0, 0, scale, x, ty] }),
    }
  })
  const blur = useAnimatedProps(() => {
    const amount = reduced ? 0 : Math.max(0, 2 * (1 - progress.get()))
    return {
      stdDeviation: amount,
      stdDeviationX: amount,
      stdDeviationY: amount,
    }
  })
  return (
    <Svg
      width={width + 16}
      height={height + 48}
      style={{ position: "absolute", left: -8, top: -24 }}
      accessible={false}
    >
      <Defs>
        <Filter id={filter} x="-100%" y="-200%" width="300%" height="500%">
          <AnimatedBlur animatedProps={blur} />
        </Filter>
      </Defs>
      <G transform="translate(8 24)">
        <AnimatedGroup animatedProps={props} filter={`url(#${filter})`}>
          <SvgText
            x={width / 2}
            y={height / 2 + size * 0.35}
            fill={color}
            fontSize={size}
            fontWeight="700"
            textAnchor="middle"
          >
            {layer.digit}
          </SvgText>
        </AnimatedGroup>
      </G>
    </Svg>
  )
}
function Digit({
  digit,
  direction,
  color,
  size,
  width,
  height,
}: {
  digit: string
  direction: number
  color: string
  size: number
  width: number
  height: number
}) {
  const nextId = useRef(1)
  const previous = useRef(digit)
  const [layers, setLayers] = useState<Layer[]>([
    { id: 0, digit, leaving: false, entering: false, direction },
  ])
  const remove = React.useCallback(
    (id: number) =>
      setLayers((layers) => layers.filter((layer) => layer.id !== id)),
    []
  )
  useEffect(() => {
    if (previous.current === digit) return
    previous.current = digit
    const id = nextId.current++
    setLayers((layers) => [
      ...layers.map((layer) =>
        layer.leaving ? layer : { ...layer, leaving: true, direction }
      ),
      { id, digit, leaving: false, entering: true, direction },
    ])
  }, [digit, direction])
  return (
    <View style={{ width, height, overflow: "visible" }}>
      {layers.map((layer) => (
        <Glyph
          key={layer.id}
          layer={layer}
          color={color}
          size={size}
          width={width}
          height={height}
          remove={remove}
        />
      ))}
    </View>
  )
}
function StepButton({
  plus,
  disabled,
  onPress,
  size,
  color,
  backgroundColor,
  testID,
}: {
  plus: boolean
  disabled: boolean
  onPress: () => void
  size: number
  color: string
  backgroundColor: string
  testID?: string
}) {
  const scale = useSharedValue(1)
  const hovered = useRef(false)
  const style = useAnimatedStyle(() => ({
    transform: [{ scale: scale.get() }],
  }))
  return (
    <Animated.View style={[{ opacity: disabled ? 0.5 : 1 }, style]}>
      <Pressable
        testID={testID}
        disabled={disabled}
        accessibilityRole="button"
        accessibilityLabel={plus ? "Increase" : "Decrease"}
        accessibilityState={{ disabled }}
        hitSlop={Math.max(0, (48 - size) / 2)}
        pressRetentionOffset={12}
        onPress={onPress}
        onHoverIn={() => {
          hovered.current = true
          if (!disabled) scale.set(withSpring(1.05, PRESS_SPRING))
        }}
        onHoverOut={() => {
          hovered.current = false
          scale.set(withSpring(1, PRESS_SPRING))
        }}
        onPressIn={() => scale.set(withSpring(0.92, PRESS_SPRING))}
        onPressOut={() =>
          scale.set(withSpring(hovered.current ? 1.05 : 1, PRESS_SPRING))
        }
        style={{
          width: size,
          height: size,
          borderRadius: size / 2,
          backgroundColor,
          justifyContent: "center",
          alignItems: "center",
        }}
      >
        <Svg
          width={size >= 56 ? 20 : 16}
          height={size >= 56 ? 20 : 16}
          viewBox="0 0 20 20"
          fill={color}
          accessible={false}
        >
          <Path
            d={
              plus
                ? "M10 3a1 1 0 0 1 1 1v5h5a1 1 0 1 1 0 2h-5v5a1 1 0 1 1-2 0v-5H4a1 1 0 1 1 0-2h5V4a1 1 0 0 1 1-1Z"
                : "M4 9a1 1 0 1 0 0 2h12a1 1 0 1 0 0-2H4Z"
            }
          />
        </Svg>
      </Pressable>
    </Animated.View>
  )
}
export type StepperProps = {
  value?: number
  defaultValue?: number
  min?: number
  max?: number
  onChange?: (value: number) => void
  theme?: "light" | "dark" | "system"
  borderColor?: string
  backgroundColor?: string
  buttonColor?: string
  iconColor?: string
  textColor?: string
  buttonSize?: number
  fontSize?: number
  digitWidth?: number
  radius?: number
  gap?: number
  style?: StyleProp<ViewStyle>
  testID?: string
}
export function Stepper({
  value,
  defaultValue = 0,
  min = 0,
  max = 999,
  onChange,
  theme = "system",
  borderColor,
  backgroundColor = "transparent",
  buttonColor,
  iconColor,
  textColor,
  buttonSize,
  fontSize,
  digitWidth,
  radius = 999,
  gap,
  style,
  testID,
}: StepperProps = {}) {
  const system = useColorScheme()
  const { width: viewportWidth, fontScale } = useWindowDimensions()
  const dark = theme === "dark" || (theme === "system" && system === "dark")
  const large = viewportWidth >= 640
  const [internal, setInternal] = useState(defaultValue)
  const [direction, setDirection] = useState(0)
  const controlled = value !== undefined
  const current = controlled ? value : internal
  const latest = useRef(current)
  latest.current = current
  const digits = String(current).split("")
  const size = buttonSize ?? (large ? 56 : 44)
  const textSize = (fontSize ?? (large ? 30 : 20)) * fontScale
  const columnWidth = (digitWidth ?? (large ? 16 : 12)) * fontScale
  const digitHeight = Math.max(large ? 32 : 28, textSize * 1.15)
  const step = (dir: number) => {
    const next = Math.min(max, Math.max(min, latest.current + dir))
    if (next === latest.current) return
    setDirection(dir)
    if (!controlled) {
      latest.current = next
      setInternal(next)
    }
    onChange?.(next)
  }
  return (
    <View style={[styles.wrapper, style]} testID={testID}>
      <View
        style={[
          styles.pill,
          {
            borderRadius: radius,
            gap: gap ?? (large ? 20 : 12),
            borderColor: borderColor ?? (dark ? "#27272a" : "#e6e6ef"),
            backgroundColor,
          },
        ]}
      >
        <StepButton
          plus={false}
          disabled={current <= min}
          onPress={() => step(-1)}
          size={size}
          color={iconColor ?? (dark ? "#a1a1aa" : "#5a5a63")}
          backgroundColor={buttonColor ?? (dark ? "#27272a" : "#f0eff6")}
          testID={testID ? `${testID}-decrease` : undefined}
        />
        <View
          style={styles.digits}
          accessibilityRole="text"
          accessibilityLabel={String(current)}
          accessibilityLiveRegion="polite"
          testID={testID ? `${testID}-value` : undefined}
        >
          {digits.map((digit, index) => (
            <Digit
              key={`${index}-${digits.length}`}
              digit={digit}
              direction={direction}
              color={textColor ?? (dark ? "#ffffff" : "#242426")}
              size={textSize}
              width={columnWidth}
              height={digitHeight}
            />
          ))}
        </View>
        <StepButton
          plus
          disabled={current >= max}
          onPress={() => step(1)}
          size={size}
          color={iconColor ?? (dark ? "#a1a1aa" : "#5a5a63")}
          backgroundColor={buttonColor ?? (dark ? "#27272a" : "#f0eff6")}
          testID={testID ? `${testID}-increase` : undefined}
        />
      </View>
    </View>
  )
}
const styles = StyleSheet.create({
  wrapper: { width: "100%", alignItems: "center" },
  pill: {
    flexDirection: "row",
    alignItems: "center",
    padding: 4,
    borderWidth: 2,
    boxShadow: "0px 1px 2px rgba(0,0,0,0.05)",
  },
  digits: {
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "center",
    gap: 4,
  },
})

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

Back to Watermelon UI