Plus/minus quantity control with hold-to-repeat.
components/ui/stepper/stepper.tsx@lumioui/core@latestreact-native-reanimated@*Live preview — the real component, driven by the props below.
import { Stepper } from "@/components/ui/stepper/stepper";
export function Example() {
return (
<Stepper />
);
} import * as React from "react";
import { forwardRef } from "react";
import { View, type ViewProps, } from "react-native";
import Animated, {
Easing,
useSharedValue,
useAnimatedStyle,
withSequence,
withTiming,
} from "react-native-reanimated";
import { cn, useHaptic, usePressAnimation, useReducedMotion, AnimatedPressable, ease } from "@lumioui/core";
import { Icon, type IconName } from "../icon";
export interface StepperProps extends Omit<ViewProps, "className"> {
/** Controlled value. Omit + use defaultValue for uncontrolled. */
value?: number;
/** Uncontrolled initial value. Default 0. */
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
className?: string;
/** Show the value in the center. Default true. Set false for a compact iOS-style stepper (just -/+). */
showValue?: boolean;
/** Press-and-hold to repeat. Default true. */
repeat?: boolean;
}
interface StepperButtonProps {
onPress: () => void;
onLongPress?: () => void;
onPressOut?: () => void;
disabled?: boolean;
icon: IconName;
accessibilityLabel?: string;
}
/** Bound "nope" nudge — three 50ms taps, subtler than a full shake. */
const NUDGE_SEGMENT = { duration: 50 };
function StepperButton({ onPress, onLongPress, onPressOut, disabled, icon, accessibilityLabel }: StepperButtonProps) {
const haptics = useHaptic();
const reduceMotion = useReducedMotion();
// Scale 0.88 + opacity 0.7 — both press feedbacks live in the animated
// style (a Pressable function-style can't host a reanimated style).
const press = usePressAnimation({ pressedScale: 0.88, pressedOpacity: 0.7 });
/** Icon nudge when the button is at a bound — a small horizontal "nope". */
const nudgeX = useSharedValue(0);
const handlePress = () => {
if (disabled) {
// At a bound (or the whole stepper is disabled) — warn + wiggle the
// icon instead of swallowing the tap in silence.
haptics.warning();
if (!reduceMotion) {
nudgeX.value = withSequence(
withTiming(2, NUDGE_SEGMENT),
withTiming(-2, NUDGE_SEGMENT),
withTiming(0, NUDGE_SEGMENT)
);
}
return;
}
haptics.selection();
onPress();
};
const handlePressIn = () => {
// Disabled buttons nudge on release instead of dipping on press-in.
if (!disabled) press.onPressIn();
};
const handlePressOut = () => {
if (!disabled) press.onPressOut();
onPressOut?.();
};
const nudgeStyle = useAnimatedStyle(() => ({
transform: [{ translateX: nudgeX.value }],
}));
return (
<AnimatedPressable
// Deliberately never disabled at the responder level — a tap on a
// bound/disabled button must still fire so it can nudge "nope" + warn.
// a11y still reports disabled via accessibilityState below.
disabled={false}
onPress={handlePress}
onLongPress={() => {
if (!disabled) onLongPress?.();
}}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={press.animatedStyle}
className={cn("h-9 w-9 items-center justify-center", disabled && "opacity-40")}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityState={{ disabled: disabled ?? false }}
// Never disabled at the responder level (shake feedback), so the DOM
// needs an explicit aria-disabled — accessibilityState isn't forwarded.
aria-disabled={disabled ?? false}
// 36px visual + 4px/side hitSlop = exactly 44pt touch target.
hitSlop={{ top: 4, left: 4, right: 4, bottom: 4 }}
>
<Animated.View style={nudgeStyle}>
<Icon name={icon} size="sm" color={disabled ? "mutedForeground" : "foreground"} />
</Animated.View>
</AnimatedPressable>
);
}
const ROLL_DISTANCE = 14;
/** Digit roll — quick enough to feel mechanical, slow enough to read the
* digit swap. ease.out so the incoming digit settles rather than sliding. */
const ROLL_TIMING = { duration: 200, easing: Easing.bezier(...ease.out) };
/**
* Value readout that rolls vertically on change — the outgoing digit slides
* up (increment) or down (decrement) while the new one slides in.
*/
function RollingNumber({ value }: { value: number }) {
const reducedMotion = useReducedMotion();
const [shown, setShown] = React.useState(value);
const [outgoing, setOutgoing] = React.useState<number | null>(null);
const dir = useSharedValue(1);
const t = useSharedValue(1);
React.useEffect(() => {
if (value === shown) return;
dir.value = value > shown ? 1 : -1;
setOutgoing(shown);
setShown(value);
if (reducedMotion) {
t.value = 1;
return;
}
t.value = 0;
t.value = withTiming(1, ROLL_TIMING);
}, [value, shown, t, dir, reducedMotion]);
const inStyle = useAnimatedStyle(() => ({
opacity: t.value,
transform: [{ translateY: (1 - t.value) * ROLL_DISTANCE * dir.value }],
}));
const outStyle = useAnimatedStyle(() => ({
opacity: 1 - t.value,
transform: [{ translateY: -t.value * ROLL_DISTANCE * dir.value }],
}));
return (
<View className="flex-1 items-center justify-center overflow-hidden" style={{ height: 24 }}>
<Animated.Text
className="text-center text-base font-semibold leading-tight text-foreground tabular-nums"
style={inStyle}
>
{shown}
</Animated.Text>
{outgoing !== null ? (
<Animated.Text
className="absolute text-center text-base font-semibold leading-tight text-foreground tabular-nums"
style={outStyle}
>
{outgoing}
</Animated.Text>
) : null}
</View>
);
}
/**
* Stepper — iOS-style compact stepper with -/+ buttons.
*
* Matches SwiftUI's `Stepper` — a compact control for incrementing
* or decrementing a value. Features:
* - Rolling number animation when value changes
* - Press-and-hold to repeat (like iOS stepper)
* - Haptic feedback on each step
* - Compact mode (just -/+, no value display)
* - Disabled state with visual feedback
*/
export const Stepper = forwardRef<React.ElementRef<typeof View>, StepperProps>(
function Stepper(
{ value: controlledValue, defaultValue = 0, onValueChange, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER, step = 1, disabled, className, showValue = true, repeat = true, ...props },
ref
) {
const haptics = useHaptic();
const [internal, setInternal] = React.useState(defaultValue);
// Resolve during render, never in a passive effect.
const value = controlledValue ?? internal;
const canDecrement = !disabled && value > min;
const canIncrement = !disabled && value < max;
const emit = React.useCallback(
(next: number) => {
if (next === value) return false;
if (controlledValue === undefined) setInternal(next);
onValueChange?.(next);
return true;
},
[value, controlledValue, onValueChange]
);
const decrement = React.useCallback(() => {
return emit(Math.max(value - step, min));
}, [value, step, min, emit]);
const increment = React.useCallback(() => {
return emit(Math.min(value + step, max));
}, [value, step, max, emit]);
// Latest callbacks for the repeat interval — the interval would
// otherwise keep calling the long-press-time closure with a stale value.
const decRef = React.useRef(decrement);
decRef.current = decrement;
const incRef = React.useRef(increment);
incRef.current = increment;
// Press-and-hold repeat — like iOS stepper
const repeatTimer = React.useRef<ReturnType<typeof setTimeout> | ReturnType<typeof setInterval> | null>(null);
const stopRepeat = React.useCallback(() => {
if (repeatTimer.current) {
clearTimeout(repeatTimer.current);
clearInterval(repeatTimer.current);
repeatTimer.current = null;
}
}, []);
const startRepeat = (kind: "dec" | "inc") => {
if (!repeat) return;
// Start repeating after 400ms delay
repeatTimer.current = setTimeout(() => {
repeatTimer.current = setInterval(() => {
// Haptic only when the value actually moves — silent at bounds.
const moved = kind === "dec" ? decRef.current() : incRef.current();
// `light` impact per tick — `selection` at 10Hz is a buzz, not a tick.
if (moved) haptics.light();
}, 100);
}, 400);
};
React.useEffect(() => stopRepeat, [stopRepeat]);
return (
<View
ref={ref}
className={cn(
showValue
? "h-12 flex-row items-center rounded-full border border-input bg-background"
: "flex-row items-center rounded-lg overflow-hidden border border-input bg-background",
disabled && "opacity-50",
className
)}
accessibilityRole="spinbutton"
accessibilityValue={{
min,
max,
now: value,
text: String(value),
}}
accessibilityState={{ disabled }}
// accessibilityValue/accessibilityState aren't forwarded to the DOM
// on web — mirror them with explicit aria-* props.
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={String(value)}
aria-disabled={disabled ?? false}
{...props}
>
<StepperButton
onPress={decrement}
onLongPress={() => startRepeat("dec")}
onPressOut={stopRepeat}
disabled={!canDecrement}
accessibilityLabel="Decrease value"
icon="minus"
/>
{showValue ? <RollingNumber value={value} /> : null}
<StepperButton
onPress={increment}
onLongPress={() => startRepeat("inc")}
onPressOut={stopRepeat}
disabled={!canIncrement}
accessibilityLabel="Increase value"
icon="plus"
/>
</View>
);
}
);
Stepper.displayName = "Stepper";
| value? | number | Controlled value. Omit + use defaultValue for uncontrolled. | |
| defaultValue? | number | Uncontrolled initial value. Default 0. | |
| onValueChange? | (value: number) => void | ||
| min? | number | ||
| max? | number | ||
| step? | number | ||
| disabled? | boolean | ||
| className? | string | ||
| showValue? | boolean | Show the value in the center. Default true. Set false for a compact iOS-style stepper (just -/+). | |
| repeat? | boolean | Press-and-hold to repeat. Default true. |
Plus/minus quantity control with hold-to-repeat.
import { StepperCompact } from "@/components/ui/stepper/variants/compact";
export function Example() {
return (
<StepperCompact />
);
} import * as React from "react";
import { View } from "react-native";
import { Stepper } from "../stepper";
import { Icon } from "../../icon/icon";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** StepperCompact — reservation card: value-hidden guest stepper plus a
* disabled sold-out row, staged with restaurant context. */
export function StepperCompact() {
const [guests, setGuests] = React.useState(1);
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-0.5 rounded-2xl p-4">
<View className="flex-row items-center gap-2">
<Icon name="star" size="sm" color="warning" filled />
<Text variant="small" className="font-semibold">Adega da Graça</Text>
</View>
<Text variant="muted">Petiscos · Fri 8:00 PM · Lisboa</Text>
</Glass>
<Glass interactive curvature="edge" className="w-full rounded-2xl">
<View className="flex-row items-center justify-between px-4 py-3.5">
<View className="gap-0.5">
<Text variant="small" className="font-semibold">Table guests</Text>
<Text variant="muted" className="text-xs">{guests} {guests === 1 ? "seat" : "seats"} reserved</Text>
</View>
<Stepper value={guests} onValueChange={setGuests} min={1} max={6} showValue={false} />
</View>
<View className="flex-row items-center justify-between border-t border-border px-4 py-3.5">
<View className="gap-0.5">
<Text variant="muted">Rooftop counter</Text>
<Text variant="muted" className="text-xs">Sold out for Friday</Text>
</View>
<Stepper value={0} onValueChange={() => {}} min={0} max={8} disabled />
</View>
</Glass>
</View>
);
}
Plus/minus quantity control with hold-to-repeat.
import { StepperQuantity } from "@/components/ui/stepper/variants/quantity";
export function Example() {
return (
<StepperQuantity />
);
} import * as React from "react";
import { View } from "react-native";
import { Stepper } from "../stepper";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** StepperQuantity — quantity stepper with live price total. */
export function StepperQuantity() {
const [qty, setQty] = React.useState(2);
const PRICE = 14.5;
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<View className="flex-row items-center justify-between">
<View className="gap-0.5">
<Text variant="small" className="font-semibold">Tonkotsu Ramen</Text>
<Text variant="muted">${PRICE.toFixed(2)} each</Text>
</View>
<Stepper value={qty} onValueChange={setQty} min={1} max={8} />
</View>
<View className="flex-row items-center justify-between border-t border-border pt-3">
<Text variant="muted">{qty} bowl{qty === 1 ? "" : "s"}</Text>
<Text variant="small" className="font-semibold">${(qty * PRICE).toFixed(2)}</Text>
</View>
</Glass>
</View>
);
}