Input
Stepper
Digits roll, blur and spring independently as the count changes
Expo code
Original web component
Watermelon UIOur Expo component
React Native WebComponent 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.