Wizard checkout: address, payment, and review steps with a live indicator
import * as React from "react";
import { useState } from "react";
import { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Glass, Icon, Input, List, Steps, Text, type IconName } from "@lumioui/ui";
import { cn, useHaptic } from "@lumioui/core";
import { CardBrandMark, LumioMark, Mount, Press, SEPARATOR_INSET, StickyBottomBar, World } from "../shared";
export interface CheckoutStepsScreenProps {
total?: number;
onPlaceOrder?: () => void;
onBack?: () => void;
}
const STEP_LABELS = ["Address", "Payment", "Review"] as const;
const fmt = (n: number) => `$${n.toLocaleString("en-US", { minimumFractionDigits: 2 })}`;
function StepHeader({
icon,
title,
done,
}: {
icon: IconName;
title: string;
done?: boolean;
}) {
return (
<View className="flex-row items-center gap-3">
<View
className={cn(
"h-9 w-9 items-center justify-center rounded-full",
done ? "bg-[#BEF04C]" : "bg-[#1B1B26]"
)}
>
<Icon name={done ? "check" : icon} size="sm" color={done ? "#1B1B26" : "#FFFFFF"} />
</View>
<Text variant="h4">{title}</Text>
</View>
);
}
StepHeader.displayName = "StepHeader";
/** Checkout — wizard variant (Steps idiom): Address → Payment → Review, one
* screen per step with a live Steps indicator, per-step content, and a
* Continue/Place-order bottom bar that follows the step. */
export function CheckoutStepsScreen({
total = 148.0,
onPlaceOrder,
onBack,
}: CheckoutStepsScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [step, setStep] = useState(0);
const [fields, setFields] = useState({
name: "Amara Chen",
street: "421 Harbor Lane, Apt 5B",
city: "San Francisco, CA 94107",
notes: "",
});
const [card, setCard] = useState({ number: "4571 8820 4413 4821", expiry: "09/28" });
const [placed, setPlaced] = useState(false);
const set = (key: keyof typeof fields | keyof typeof card) => (v: string) => {
if (key in fields) setFields((f) => ({ ...f, [key]: v }));
else setCard((c) => ({ ...c, [key]: v }));
};
const next = () => {
haptics.light();
if (step < STEP_LABELS.length - 1) {
setStep((s) => s + 1);
} else {
setPlaced(true);
onPlaceOrder?.();
}
};
const back = () => {
haptics.light();
if (step === 0) onBack?.();
else setStep((s) => s - 1);
};
if (placed) {
return (
<SafeAreaView className="flex-1 items-center justify-center gap-4 bg-[#F4F5F9] px-8 dark:bg-[#111114]" edges={["top", "bottom"]}>
<Mount className="h-16 w-16 items-center justify-center rounded-full bg-[#BEF04C]">
<Icon name="check" size="lg" color="#1B1B26" />
</Mount>
<Text variant="h2" className="text-center">
Order placed
</Text>
<Text variant="muted" className="text-center">
Confirmation #LS-88210 — a receipt is on its way to your inbox.
</Text>
<Button
variant="secondary"
onPress={() => setPlaced(false)}
accessibilityLabel="Back to checkout"
>
Back to checkout
</Button>
</SafeAreaView>
);
}
return (
<World primary="#65A30D">
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Floating chrome — back disc + centered brand/title pill. */}
<View pointerEvents="box-none" className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }}>
<View className="flex-row items-center justify-between">
<Glass interactive effect="regular" className="h-11 w-11 items-center justify-center rounded-full">
<Press
haptic="light"
onPress={back}
accessibilityRole="button"
accessibilityLabel={step === 0 ? "Back to cart" : "Previous step"}
className="h-9 w-9 items-center justify-center"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
</Glass>
<View className="h-11 w-11" />
</View>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<View className="h-11 flex-row items-center justify-center gap-2 rounded-full pl-3.5 pr-5">
<Glass effect="regular" className="absolute inset-0 rounded-full" />
<LumioMark size={18} />
<Text accessibilityRole="header" className="text-sm font-bold tracking-tight">
Checkout
</Text>
</View>
</View>
</View>
<View className="px-6 pb-5" style={{ paddingTop: insets.top + 68 }}>
<Steps steps={[...STEP_LABELS]} current={step} onCurrentChange={setStep} interactive />
{/* Fraction readout — the Steps dots plus the words, for anyone
who missed which stage they're on. */}
<Text variant="muted" className="pt-2 text-center text-xs">
Step {step + 1} of {STEP_LABELS.length} — {STEP_LABELS[step]}
</Text>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 96 }}
className="flex-1 px-6"
>
{step === 0 ? (
<View className="gap-4">
<StepHeader icon="map-pin" title="Shipping address" done={step > 0} />
<Input
glass
label="Full name"
value={fields.name}
onChangeText={set("name")}
accessibilityLabel="Full name"
/>
<Input
glass
label="Street address"
value={fields.street}
onChangeText={set("street")}
accessibilityLabel="Street address"
/>
<Input
glass
label="City / ZIP"
value={fields.city}
onChangeText={set("city")}
accessibilityLabel="City and ZIP"
/>
<Input
glass
label="Delivery notes (optional)"
value={fields.notes}
onChangeText={set("notes")}
placeholder="e.g. Leave at the side door"
accessibilityLabel="Delivery notes"
/>
<View className="flex-row items-center gap-2 rounded-2xl bg-white p-3 dark:bg-[#1A1A20]">
<Icon name="home" size="sm" color="#65A30D" />
<Text variant="muted" className="flex-1 text-xs">
Home · apartment 5B — this is your default shipping address.
</Text>
</View>
</View>
) : null}
{step === 1 ? (
<View className="gap-4">
<StepHeader icon="credit-card" title="Payment" done={step > 1} />
{/* Live card preview — the number types straight onto the face. */}
<View className="aspect-[1.586] w-full overflow-hidden rounded-3xl bg-[#1B1B26] p-5">
<View className="flex-1 justify-between">
<View className="flex-row items-center justify-between">
<View className="h-7 w-10 rounded-md bg-white/25" />
<CardBrandMark network={card.number.replace(/\s/g, "").startsWith("5") ? "mastercard" : "visa"} size={26} />
</View>
<Text className="font-mono text-lg font-semibold tracking-[0.16em] text-white">
{card.number || "•••• •••• •••• ••••"}
</Text>
<View className="flex-row items-end justify-between">
<View className="flex-1 pr-4">
<Text className="text-[10px] uppercase tracking-wider text-white/50">
Card holder
</Text>
<Text numberOfLines={1} className="text-sm font-semibold uppercase tracking-wide text-white">
{fields.name}
</Text>
</View>
<View className="items-end">
<Text className="text-[10px] uppercase tracking-wider text-white/50">
Expires
</Text>
<Text className="text-sm font-semibold tabular-nums text-white">
{card.expiry || "MM/YY"}
</Text>
</View>
</View>
</View>
</View>
<Input
glass
label="Card number"
value={card.number}
onChangeText={set("number")}
keyboardType="number-pad"
accessibilityLabel="Card number"
/>
<Input
glass
label="Expiry"
value={card.expiry}
onChangeText={set("expiry")}
accessibilityLabel="Expiry date"
/>
<View className="flex-row items-center gap-2 rounded-2xl bg-white p-3 dark:bg-[#1A1A20]">
<Icon name="lock" size="sm" color="#65A30D" />
<Text variant="muted" className="flex-1 text-xs">
Payments are encrypted end-to-end. We never store card numbers.
</Text>
</View>
</View>
) : null}
{step === 2 ? (
<View className="gap-4">
<StepHeader icon="package" title="Review order" />
<List inset>
<List.Section title="Ship to">
<List.Item title={fields.name} subtitle={`${fields.street} · ${fields.city}`} />
</List.Section>
<List.Section title="Paying with">
<List.Item
title={`Card ···· ${card.number.slice(-4)}`}
subtitle={`Expires ${card.expiry}`}
leading={<Icon name="credit-card" size="md" color="foreground" />}
/>
</List.Section>
<List.Section title="Summary">
<List.Item title="Subtotal" trailing={<Text variant="small">{fmt(124)}</Text>} />
<List.Separator className={SEPARATOR_INSET} />
<List.Item title="Shipping" trailing={<Text variant="small">{fmt(12)}</Text>} />
<List.Separator className={SEPARATOR_INSET} />
<List.Item title="Tax" trailing={<Text variant="small">{fmt(12)}</Text>} />
</List.Section>
</List>
</View>
) : null}
</ScrollView>
<StickyBottomBar>
<Mount delay={60}>
<Button
size="lg"
onPress={next}
accessibilityLabel={step < STEP_LABELS.length - 1 ? `Continue to ${STEP_LABELS[step + 1]}, total ${fmt(total)}` : `Place order, ${fmt(total)}`}
tone="inverse" className="w-full"
>
<Text className="text-[15px] font-semibold text-background">
{step < STEP_LABELS.length - 1 ? "Continue" : "Place order"} · <Text className="font-bold tabular-nums text-background">{fmt(total)}</Text>
</Text>
</Button>
</Mount>
</StickyBottomBar>
</SafeAreaView>
</World>
);
}
CheckoutStepsScreen.displayName = "CheckoutStepsScreen";
Recreate the "Checkout — Steps Screen" screen in React Native using LumioUI.
Goal: Wizard checkout: address, payment, and review steps with a live indicator Match the reference implementation's layout, behavior, and motion exactly — this is a production screen, not a mockup.
## Setup
Fastest path — install the screen plus its component deps:
```bash
npx lumioui add ecommerce-checkout-steps
```
Or pull just the components: `npx lumioui add button icon input list steps text`
Native packages:
- @lumioui/ui@latest
- @lumioui/core@latest
- react-native-safe-area-context@*
- react-native-reanimated@*
- react-native-svg@*
- nativewind@*
Stack assumptions: Expo SDK 54 / RN 0.81, Reanimated 4, NativeWind v4, TypeScript strict, Expo Go compatible.
## Components — how, where, when
### `button` — Tactile pill button with press spring, ink wash and async loading.
- `<Button>` ×2
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×6
### `input` — Labeled field with focus ring, error shake and success check.
- `<Input>` ×6
### `list` — Settings-style rows with separators and press feedback.
- `<List>` ×1
### `steps` — Horizontal step indicator with numbered or dot markers, check-on-complete pop, and connectors that fill as progress advances
- `<Steps>` ×1
### `text` — Themed type scale from caption to display.
- `<Text>` ×17
## Conventions (non-negotiable)
- Styling via NativeWind `className` with semantic tokens only: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-secondary`, `bg-muted`, `text-muted-foreground`, `bg-accent`, `bg-destructive`, `border-border`. Hard-coded colors only inside media overlays (`text-white`, `bg-black/N`).
- Motion via the token presets from `@lumioui/core` (`spring.press`, `spring.surface`, `spring.glide`, `timing.fast`, `timing.slow`, …) — no bespoke spring configs or durations.
- Gate transform motion behind `useReducedMotion()`; keep opacity/color feedback, drop travel and overshoot.
- Haptics via `useHaptic()`: `light` on press, `selection` on toggle/select, `warning` on invalid input. Never import expo-haptics directly.
- Press feedback via `usePressAnimation()` (~0.97 scale), not `activeOpacity` tricks.
- a11y: `accessibilityRole` + `accessibilityLabel` on every control, touch targets ≥44pt.
- Entrance choreography: staggered `Mount` wrappers (delay 0/60/120…) so regions land in sequence instead of popping in at once.
- Wrap the screen in `SafeAreaView`; keep dark mode working via the semantic tokens.
## Deliverable
One file: `screens/ecommerce/checkout-steps.tsx` exporting CheckoutStepsScreen. Preserve the props interface — the host app wires the callbacks (navigation, submit handlers) through them.
## Reference implementation
The LumioUI source for this screen — reproduce it faithfully:
```tsx
import * as React from "react";
import { useState } from "react";
import { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Glass, Icon, Input, List, Steps, Text, type IconName } from "@lumioui/ui";
import { cn, useHaptic } from "@lumioui/core";
import { CardBrandMark, LumioMark, Mount, Press, SEPARATOR_INSET, StickyBottomBar, World } from "../shared";
export interface CheckoutStepsScreenProps {
total?: number;
onPlaceOrder?: () => void;
onBack?: () => void;
}
const STEP_LABELS = ["Address", "Payment", "Review"] as const;
const fmt = (n: number) => `$${n.toLocaleString("en-US", { minimumFractionDigits: 2 })}`;
function StepHeader({
icon,
title,
done,
}: {
icon: IconName;
title: string;
done?: boolean;
}) {
return (
<View className="flex-row items-center gap-3">
<View
className={cn(
"h-9 w-9 items-center justify-center rounded-full",
done ? "bg-[#BEF04C]" : "bg-[#1B1B26]"
)}
>
<Icon name={done ? "check" : icon} size="sm" color={done ? "#1B1B26" : "#FFFFFF"} />
</View>
<Text variant="h4">{title}</Text>
</View>
);
}
StepHeader.displayName = "StepHeader";
/** Checkout — wizard variant (Steps idiom): Address → Payment → Review, one
* screen per step with a live Steps indicator, per-step content, and a
* Continue/Place-order bottom bar that follows the step. */
export function CheckoutStepsScreen({
total = 148.0,
onPlaceOrder,
onBack,
}: CheckoutStepsScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [step, setStep] = useState(0);
const [fields, setFields] = useState({
name: "Amara Chen",
street: "421 Harbor Lane, Apt 5B",
city: "San Francisco, CA 94107",
notes: "",
});
const [card, setCard] = useState({ number: "4571 8820 4413 4821", expiry: "09/28" });
const [placed, setPlaced] = useState(false);
const set = (key: keyof typeof fields | keyof typeof card) => (v: string) => {
if (key in fields) setFields((f) => ({ ...f, [key]: v }));
else setCard((c) => ({ ...c, [key]: v }));
};
const next = () => {
haptics.light();
if (step < STEP_LABELS.length - 1) {
setStep((s) => s + 1);
} else {
setPlaced(true);
onPlaceOrder?.();
}
};
const back = () => {
haptics.light();
if (step === 0) onBack?.();
else setStep((s) => s - 1);
};
if (placed) {
return (
<SafeAreaView className="flex-1 items-center justify-center gap-4 bg-[#F4F5F9] px-8 dark:bg-[#111114]" edges={["top", "bottom"]}>
<Mount className="h-16 w-16 items-center justify-center rounded-full bg-[#BEF04C]">
<Icon name="check" size="lg" color="#1B1B26" />
</Mount>
<Text variant="h2" className="text-center">
Order placed
</Text>
<Text variant="muted" className="text-center">
Confirmation #LS-88210 — a receipt is on its way to your inbox.
</Text>
<Button
variant="secondary"
onPress={() => setPlaced(false)}
accessibilityLabel="Back to checkout"
>
Back to checkout
</Button>
</SafeAreaView>
);
}
return (
<World primary="#65A30D">
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Floating chrome — back disc + centered brand/title pill. */}
<View pointerEvents="box-none" className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }}>
<View className="flex-row items-center justify-between">
<Glass interactive effect="regular" className="h-11 w-11 items-center justify-center rounded-full">
<Press
haptic="light"
onPress={back}
accessibilityRole="button"
accessibilityLabel={step === 0 ? "Back to cart" : "Previous step"}
className="h-9 w-9 items-center justify-center"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
</Glass>
<View className="h-11 w-11" />
</View>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<View className="h-11 flex-row items-center justify-center gap-2 rounded-full pl-3.5 pr-5">
<Glass effect="regular" className="absolute inset-0 rounded-full" />
<LumioMark size={18} />
<Text accessibilityRole="header" className="text-sm font-bold tracking-tight">
Checkout
</Text>
</View>
</View>
</View>
<View className="px-6 pb-5" style={{ paddingTop: insets.top + 68 }}>
<Steps steps={[...STEP_LABELS]} current={step} onCurrentChange={setStep} interactive />
{/* Fraction readout — the Steps dots plus the words, for anyone
who missed which stage they're on. */}
<Text variant="muted" className="pt-2 text-center text-xs">
Step {step + 1} of {STEP_LABELS.length} — {STEP_LABELS[step]}
</Text>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 96 }}
className="flex-1 px-6"
>
{step === 0 ? (
<View className="gap-4">
<StepHeader icon="map-pin" title="Shipping address" done={step > 0} />
<Input
glass
label="Full name"
value={fields.name}
onChangeText={set("name")}
accessibilityLabel="Full name"
/>
<Input
glass
label="Street address"
value={fields.street}
onChangeText={set("street")}
accessibilityLabel="Street address"
/>
<Input
glass
label="City / ZIP"
value={fields.city}
onChangeText={set("city")}
accessibilityLabel="City and ZIP"
/>
<Input
glass
label="Delivery notes (optional)"
value={fields.notes}
onChangeText={set("notes")}
placeholder="e.g. Leave at the side door"
accessibilityLabel="Delivery notes"
/>
<View className="flex-row items-center gap-2 rounded-2xl bg-white p-3 dark:bg-[#1A1A20]">
<Icon name="home" size="sm" color="#65A30D" />
<Text variant="muted" className="flex-1 text-xs">
Home · apartment 5B — this is your default shipping address.
</Text>
</View>
</View>
) : null}
{step === 1 ? (
<View className="gap-4">
<StepHeader icon="credit-card" title="Payment" done={step > 1} />
{/* Live card preview — the number types straight onto the face. */}
<View className="aspect-[1.586] w-full overflow-hidden rounded-3xl bg-[#1B1B26] p-5">
<View className="flex-1 justify-between">
<View className="flex-row items-center justify-between">
<View className="h-7 w-10 rounded-md bg-white/25" />
<CardBrandMark network={card.number.replace(/\s/g, "").startsWith("5") ? "mastercard" : "visa"} size={26} />
</View>
<Text className="font-mono text-lg font-semibold tracking-[0.16em] text-white">
{card.number || "•••• •••• •••• ••••"}
</Text>
<View className="flex-row items-end justify-between">
<View className="flex-1 pr-4">
<Text className="text-[10px] uppercase tracking-wider text-white/50">
Card holder
</Text>
<Text numberOfLines={1} className="text-sm font-semibold uppercase tracking-wide text-white">
{fields.name}
</Text>
</View>
<View className="items-end">
<Text className="text-[10px] uppercase tracking-wider text-white/50">
Expires
</Text>
<Text className="text-sm font-semibold tabular-nums text-white">
{card.expiry || "MM/YY"}
</Text>
</View>
</View>
</View>
</View>
<Input
glass
label="Card number"
value={card.number}
onChangeText={set("number")}
keyboardType="number-pad"
accessibilityLabel="Card number"
/>
<Input
glass
label="Expiry"
value={card.expiry}
onChangeText={set("expiry")}
accessibilityLabel="Expiry date"
/>
<View className="flex-row items-center gap-2 rounded-2xl bg-white p-3 dark:bg-[#1A1A20]">
<Icon name="lock" size="sm" color="#65A30D" />
<Text variant="muted" className="flex-1 text-xs">
Payments are encrypted end-to-end. We never store card numbers.
</Text>
</View>
</View>
) : null}
{step === 2 ? (
<View className="gap-4">
<StepHeader icon="package" title="Review order" />
<List inset>
<List.Section title="Ship to">
<List.Item title={fields.name} subtitle={`${fields.street} · ${fields.city}`} />
</List.Section>
<List.Section title="Paying with">
<List.Item
title={`Card ···· ${card.number.slice(-4)}`}
subtitle={`Expires ${card.expiry}`}
leading={<Icon name="credit-card" size="md" color="foreground" />}
/>
</List.Section>
<List.Section title="Summary">
<List.Item title="Subtotal" trailing={<Text variant="small">{fmt(124)}</Text>} />
<List.Separator className={SEPARATOR_INSET} />
<List.Item title="Shipping" trailing={<Text variant="small">{fmt(12)}</Text>} />
<List.Separator className={SEPARATOR_INSET} />
<List.Item title="Tax" trailing={<Text variant="small">{fmt(12)}</Text>} />
</List.Section>
</List>
</View>
) : null}
</ScrollView>
<StickyBottomBar>
<Mount delay={60}>
<Button
size="lg"
onPress={next}
accessibilityLabel={step < STEP_LABELS.length - 1 ? `Continue to ${STEP_LABELS[step + 1]}, total ${fmt(total)}` : `Place order, ${fmt(total)}`}
tone="inverse" className="w-full"
>
<Text className="text-[15px] font-semibold text-background">
{step < STEP_LABELS.length - 1 ? "Continue" : "Place order"} · <Text className="font-bold tabular-nums text-background">{fmt(total)}</Text>
</Text>
</Button>
</Mount>
</StickyBottomBar>
</SafeAreaView>
</World>
);
}
CheckoutStepsScreen.displayName = "CheckoutStepsScreen";
```
Screen: https://lumioui.dev/screens/ecommerce-checkout-steps