KYC stepper — document-type radio choice, requirement facts, step progress, and continue CTA
import * as React from "react";
import { View, Pressable, ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useState } from "react";
import { Button, Icon, RadioGroup, RadioGroupItem, Steps, Text, type IconName } from "@lumioui/ui";
import { cn, useHaptic } from "@lumioui/core";
import { Mount, Overline, World } from "../shared";
export interface IdentityDocument {
value: string;
label: string;
/** What the camera needs to see — e.g. "Photo page". */
detail: string;
icon: IconName;
}
export interface VerifyIdentityScreenProps {
/** Wizard steps shown at the top — current index marks where the user is. */
steps?: string[];
currentStep?: number;
documents?: IdentityDocument[];
onContinue?: (document: string) => void;
onWhyVerify?: () => void;
onBack?: () => void;
}
const DEFAULT_DOCUMENTS: IdentityDocument[] = [
{ value: "passport", label: "Passport", detail: "Photo page", icon: "globe" },
{ value: "drivers-license", label: "Driver's license", detail: "Front and back", icon: "credit-card" },
{ value: "national-id", label: "National ID card", detail: "Front and back", icon: "user" },
];
const REQUIREMENTS: { icon: IconName; text: string }[] = [
{ icon: "clock", text: "Takes about 2 minutes" },
{ icon: "camera", text: "A clear photo in good light" },
{ icon: "shield", text: "Encrypted and never shared" },
];
/** Verify identity — document-picker grammar: each option carries a little
* document mock (booklet, license card, ID) so the choice reads as the
* object it produces, not three identical radio rows. */
export function VerifyIdentityScreen({
steps = ["Document", "Selfie", "Review"],
currentStep = 0,
documents = DEFAULT_DOCUMENTS,
onContinue,
onWhyVerify,
onBack,
}: VerifyIdentityScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [document, setDocument] = useState(documents[0]?.value ?? "");
const [loading, setLoading] = useState(false);
const submit = () => {
setLoading(true);
haptics.medium();
onContinue?.(document);
setTimeout(() => setLoading(false), 800);
};
return (
<World primary="#65A30D">
<View className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]">
{/* Top bar — back affordance clears the status bar */}
<View className="px-2" style={{ paddingTop: insets.top + 4 }}>
<Pressable
onPress={() => {
haptics.light();
onBack?.();
}}
accessibilityRole="button"
accessibilityLabel="Go back"
hitSlop={8}
className="h-11 flex-row items-center gap-1 self-start px-2"
>
<Icon name="chevron-left" size="sm" color="#65A30D" />
<Text className="text-base text-[#65A30D]">Back</Text>
</Pressable>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerClassName="flex-grow px-6"
contentContainerStyle={{ paddingTop: 8, paddingBottom: insets.bottom + 24 }}
>
<Mount delay={0}>
<Overline>Step {currentStep + 1} of {steps.length}</Overline>
<Text variant="h1" className="mt-2 tracking-tight">
Verify your{"\n"}<Text className="italic text-[#65A30D]">identity</Text>
</Text>
<Text variant="muted" className="mt-3 max-w-xs text-base leading-relaxed">
Choose a document to photograph — the next step walks you through it.
</Text>
</Mount>
<Mount delay={100}>
<View className="mt-7">
<Steps steps={steps} current={currentStep} interactive={false} />
</View>
</Mount>
{/* Document choice — each option shows the physical object */}
<Mount delay={180}>
<View className="mt-8 gap-3">
<Overline>Choose your document</Overline>
<RadioGroup
value={document}
onValueChange={(v) => {
haptics.selection();
setDocument(v);
}}
accessibilityLabel="Document type"
className="gap-2.5"
>
{documents.map((doc) => (
<RadioGroupItem
key={doc.value}
value={doc.value}
accessibilityLabel={doc.label}
className="items-center overflow-hidden rounded-2xl"
>
<View className="absolute inset-0 rounded-2xl bg-white dark:bg-[#1A1A20]" pointerEvents="none" />
<DocMock kind={doc.value} />
<View className="ml-1 flex-1">
<Text className="text-base font-medium text-foreground">{doc.label}</Text>
<Text className="text-sm text-muted-foreground" numberOfLines={1}>
{doc.detail}
</Text>
</View>
</RadioGroupItem>
))}
</RadioGroup>
</View>
</Mount>
{/* Requirement facts — grouped on one tonal card */}
<Mount delay={260}>
<View className="mt-8 gap-3.5 overflow-hidden rounded-3xl bg-white p-5 dark:bg-[#1A1A20]">
{REQUIREMENTS.map((req) => (
<View key={req.text} className="flex-row items-center gap-3">
<Icon name={req.icon} size="sm" color="mutedForeground" />
<Text variant="small" className="text-muted-foreground">
{req.text}
</Text>
</View>
))}
</View>
</Mount>
<Mount delay={340} className="mt-auto">
<View className="gap-1 pt-9">
<Button
size="lg"
loading={loading}
onPress={submit}
haptic={false}
className="bg-[#65A30D] w-full"
accessibilityLabel="Continue to document photo"
>
Continue
</Button>
{onWhyVerify ? (
<Pressable
onPress={() => {
haptics.light();
onWhyVerify();
}}
accessibilityRole="link"
accessibilityLabel="Why we verify your identity"
hitSlop={8}
className="min-h-11 items-center justify-center px-2"
>
<Text variant="muted" className="text-center text-sm">
Why do we ask for this?
</Text>
</Pressable>
) : null}
</View>
</Mount>
</ScrollView>
</View>
</World>
);
}
VerifyIdentityScreen.displayName = "VerifyIdentityScreen";
/** Miniature document facsimile shown inside each picker row — a booklet
* for passports, a landscape card with photo block for licenses/IDs. */
function DocMock({ kind }: { kind: string }) {
if (kind === "passport") {
return (
<View className="h-12 w-9 items-center justify-center rounded-md bg-[#2B3566] py-1.5">
<View className="h-2 w-2 items-center justify-center rounded-full border border-white/70" />
<View className="mt-1.5 h-px w-4 bg-white/60" />
<View className="mt-1 h-px w-3 bg-white/40" />
</View>
);
}
return (
<View className="h-12 w-16 flex-row items-center gap-1.5 rounded-md border border-border bg-muted/60 p-1.5">
<View className="h-7 w-6 items-center justify-center rounded-sm bg-[#65A30D]/15">
<Icon name="user" size="sm" color="#65A30D" />
</View>
<View className="flex-1 gap-1">
<View className={cn("h-1 rounded-full bg-foreground/25", kind === "national-id" ? "w-8" : "w-7")} />
<View className="h-1 w-5 rounded-full bg-foreground/20" />
<View className="h-1 w-6 rounded-full bg-foreground/15" />
</View>
</View>
);
}
DocMock.displayName = "DocMock";
Recreate the "Verify Identity Screen" screen in React Native using LumioUI.
Goal: KYC stepper — document-type radio choice, requirement facts, step progress, and continue CTA 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 auth-verify-identity
```
Or pull just the components: `npx lumioui add button icon radio-group 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>` ×1
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×3
### `radio-group` — Single-select options with animated indicator.
- `<RadioGroup>` ×1
- `<RadioGroupItem>` ×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>` ×8
## 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/auth/verify-identity.tsx` exporting VerifyIdentityScreen. 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 { View, Pressable, ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useState } from "react";
import { Button, Icon, RadioGroup, RadioGroupItem, Steps, Text, type IconName } from "@lumioui/ui";
import { cn, useHaptic } from "@lumioui/core";
import { Mount, Overline, World } from "../shared";
export interface IdentityDocument {
value: string;
label: string;
/** What the camera needs to see — e.g. "Photo page". */
detail: string;
icon: IconName;
}
export interface VerifyIdentityScreenProps {
/** Wizard steps shown at the top — current index marks where the user is. */
steps?: string[];
currentStep?: number;
documents?: IdentityDocument[];
onContinue?: (document: string) => void;
onWhyVerify?: () => void;
onBack?: () => void;
}
const DEFAULT_DOCUMENTS: IdentityDocument[] = [
{ value: "passport", label: "Passport", detail: "Photo page", icon: "globe" },
{ value: "drivers-license", label: "Driver's license", detail: "Front and back", icon: "credit-card" },
{ value: "national-id", label: "National ID card", detail: "Front and back", icon: "user" },
];
const REQUIREMENTS: { icon: IconName; text: string }[] = [
{ icon: "clock", text: "Takes about 2 minutes" },
{ icon: "camera", text: "A clear photo in good light" },
{ icon: "shield", text: "Encrypted and never shared" },
];
/** Verify identity — document-picker grammar: each option carries a little
* document mock (booklet, license card, ID) so the choice reads as the
* object it produces, not three identical radio rows. */
export function VerifyIdentityScreen({
steps = ["Document", "Selfie", "Review"],
currentStep = 0,
documents = DEFAULT_DOCUMENTS,
onContinue,
onWhyVerify,
onBack,
}: VerifyIdentityScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [document, setDocument] = useState(documents[0]?.value ?? "");
const [loading, setLoading] = useState(false);
const submit = () => {
setLoading(true);
haptics.medium();
onContinue?.(document);
setTimeout(() => setLoading(false), 800);
};
return (
<World primary="#65A30D">
<View className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]">
{/* Top bar — back affordance clears the status bar */}
<View className="px-2" style={{ paddingTop: insets.top + 4 }}>
<Pressable
onPress={() => {
haptics.light();
onBack?.();
}}
accessibilityRole="button"
accessibilityLabel="Go back"
hitSlop={8}
className="h-11 flex-row items-center gap-1 self-start px-2"
>
<Icon name="chevron-left" size="sm" color="#65A30D" />
<Text className="text-base text-[#65A30D]">Back</Text>
</Pressable>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerClassName="flex-grow px-6"
contentContainerStyle={{ paddingTop: 8, paddingBottom: insets.bottom + 24 }}
>
<Mount delay={0}>
<Overline>Step {currentStep + 1} of {steps.length}</Overline>
<Text variant="h1" className="mt-2 tracking-tight">
Verify your{"\n"}<Text className="italic text-[#65A30D]">identity</Text>
</Text>
<Text variant="muted" className="mt-3 max-w-xs text-base leading-relaxed">
Choose a document to photograph — the next step walks you through it.
</Text>
</Mount>
<Mount delay={100}>
<View className="mt-7">
<Steps steps={steps} current={currentStep} interactive={false} />
</View>
</Mount>
{/* Document choice — each option shows the physical object */}
<Mount delay={180}>
<View className="mt-8 gap-3">
<Overline>Choose your document</Overline>
<RadioGroup
value={document}
onValueChange={(v) => {
haptics.selection();
setDocument(v);
}}
accessibilityLabel="Document type"
className="gap-2.5"
>
{documents.map((doc) => (
<RadioGroupItem
key={doc.value}
value={doc.value}
accessibilityLabel={doc.label}
className="items-center overflow-hidden rounded-2xl"
>
<View className="absolute inset-0 rounded-2xl bg-white dark:bg-[#1A1A20]" pointerEvents="none" />
<DocMock kind={doc.value} />
<View className="ml-1 flex-1">
<Text className="text-base font-medium text-foreground">{doc.label}</Text>
<Text className="text-sm text-muted-foreground" numberOfLines={1}>
{doc.detail}
</Text>
</View>
</RadioGroupItem>
))}
</RadioGroup>
</View>
</Mount>
{/* Requirement facts — grouped on one tonal card */}
<Mount delay={260}>
<View className="mt-8 gap-3.5 overflow-hidden rounded-3xl bg-white p-5 dark:bg-[#1A1A20]">
{REQUIREMENTS.map((req) => (
<View key={req.text} className="flex-row items-center gap-3">
<Icon name={req.icon} size="sm" color="mutedForeground" />
<Text variant="small" className="text-muted-foreground">
{req.text}
</Text>
</View>
))}
</View>
</Mount>
<Mount delay={340} className="mt-auto">
<View className="gap-1 pt-9">
<Button
size="lg"
loading={loading}
onPress={submit}
haptic={false}
className="bg-[#65A30D] w-full"
accessibilityLabel="Continue to document photo"
>
Continue
</Button>
{onWhyVerify ? (
<Pressable
onPress={() => {
haptics.light();
onWhyVerify();
}}
accessibilityRole="link"
accessibilityLabel="Why we verify your identity"
hitSlop={8}
className="min-h-11 items-center justify-center px-2"
>
<Text variant="muted" className="text-center text-sm">
Why do we ask for this?
</Text>
</Pressable>
) : null}
</View>
</Mount>
</ScrollView>
</View>
</World>
);
}
VerifyIdentityScreen.displayName = "VerifyIdentityScreen";
/** Miniature document facsimile shown inside each picker row — a booklet
* for passports, a landscape card with photo block for licenses/IDs. */
function DocMock({ kind }: { kind: string }) {
if (kind === "passport") {
return (
<View className="h-12 w-9 items-center justify-center rounded-md bg-[#2B3566] py-1.5">
<View className="h-2 w-2 items-center justify-center rounded-full border border-white/70" />
<View className="mt-1.5 h-px w-4 bg-white/60" />
<View className="mt-1 h-px w-3 bg-white/40" />
</View>
);
}
return (
<View className="h-12 w-16 flex-row items-center gap-1.5 rounded-md border border-border bg-muted/60 p-1.5">
<View className="h-7 w-6 items-center justify-center rounded-sm bg-[#65A30D]/15">
<Icon name="user" size="sm" color="#65A30D" />
</View>
<View className="flex-1 gap-1">
<View className={cn("h-1 rounded-full bg-foreground/25", kind === "national-id" ? "w-8" : "w-7")} />
<View className="h-1 w-5 rounded-full bg-foreground/20" />
<View className="h-1 w-6 rounded-full bg-foreground/15" />
</View>
</View>
);
}
DocMock.displayName = "DocMock";
```
Screen: https://lumioui.dev/screens/auth-verify-identity