Email-first, two steps
An email-first sign-in Screen in two steps: the email, then the password, with "Email me a code instead" switching to a 6-digit code (InputOTP). Lucide chevron-left Back buttons.
Light
Dark
Your app is ready.
Inter body text, Inter headings.
a0: Vega, neutral, neutral accent, default radius, Inter.Change PresetInstallation
npx nativecn-cli@latest add sign-in-03 --route "(auth)/sign-in"--route "(auth)/sign-in"is the Block's suggested route: change it to put the Screen somewhere else in your app folder.- In a project with the feature Structure, also pass
--feature <name>(for example--feature auth). addnever overwrites an existing route file.
Also installs: theme, container, text, button, form-field, input, focus-chain, input-otp, keyboard, toast.
Packages: react-hook-form@^7.89.0, zod@^4.6.5, @hookform/resolvers@^5.9.1, lucide-react-native@^1.51.0, react-native-svg.
What makes it different
Email → password, one question per step, with "Email me a code instead" (InputOTP).
Block Variants
sign-in-01– Classic centred: Logo, email and password, "or", then social; everything on one screen.sign-in-02– Social-first hero: Dark brand hero, centred; Apple/Google are the primary path; "Continue with email" reveals the fields.sign-in-03(this page) – Email-first, two steps: Email → password, one question per step, with "Email me a code instead" (InputOTP).
Props
| Prop | Type and description |
|---|---|
onSubmit | (values: { method: "password", email, password } | { method: "code", email, code }) => void | Promise<void>: sign in; throw to show the error as a toast, resolve for a "Signed in" toast |
onSendCode | (email: string) => void | Promise<void>: email a 6-digit code (Email me a code instead, Resend); a thrown error becomes a toast and the step stays |
Accessibility
- Each step's title has role heading.
- Every step change (Continue, Email me a code instead, Back, Android's back button) moves screen-reader focus to the new step's title.
- While a screen reader runs, the new step's field doesn't take the keyboard, so focus stays on the heading; without one, the field takes the keyboard as before.
- Each field is in a FormField: its label is the field's accessible name, and a validation error becomes its hint and is announced once.
- Errors from your callbacks and the success message are toast()s, which screen readers announce.
- The submit Button is announced busy while your callback runs.
- Back is an icon-only Button named "Back"; Android's back button steps back too.
- The code is one InputOTP field, read as "Verification code, 6 digits".
Notes
Render it from a route: export default function Screen() { return <SignIn03 onSubmit={signIn} onSendCode={sendCode} />; }. The step (email, password, code) lives inside the Block; Back (an icon-only Button with a Lucide chevron-left) and Android's back button step back, keeping what was typed. Each step validates only its own field with zod through react-hook-form; the main button sits in a KeyboardStickyFooter above the keyboard. hooks/use-step-focus.ts moves screen-reader focus to the new step's heading on every step change; it is this Block's own copy. Validation errors appear under each field. Errors thrown by your callbacks, and the success message, appear only as toast()s: render <Toaster /> in the root Layout (init and create already do). The Block never talks to a server: wire your auth backend in the route file through the props, and keep any session or token in expo-secure-store, never AsyncStorage.
Source
The Vega Style's code, exactly as add copies it into your app.
{screens}/sign-in-03/index.tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronLeft } from "lucide-react-native";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { BackHandler, View } from "react-native";
import { z } from "zod";
import { Button } from "@/components/button";
import { Container } from "@/components/container";
import { FormField } from "@/components/form-field";
import { Input } from "@/components/input";
import { InputOTP } from "@/components/input-otp";
import { FocusChain } from "@/components/primitives/focus-chain";
import { KeyboardStickyFooter } from "@/components/primitives/keyboard";
import { Text } from "@/components/text";
import { toast } from "@/components/toast";
import { createStyles, useTheme } from "@/theme";
import { useStepFocus } from "./hooks/use-step-focus";
// sign-in-03, email first, in two steps: the email, then the password, with "Email me a code
// instead" switching to a 6-digit code. The step is kept inside the Block. Edit the copy and the
// rules below; they are yours.
/** The steps, in order. Back goes to the previous one. */
type Step = "email" | "password" | "code";
const previous: Record<Step, Step | undefined> = {
email: undefined,
password: "email",
code: "password",
};
const email = z.string().trim().min(1, "Enter your email").pipe(z.email("Enter a valid email"));
/** Each step checks only its own field; the others pass through. */
const schemas = {
email: z.object({ email, password: z.string(), code: z.string() }),
password: z.object({
email,
password: z.string().min(1, "Password is required"),
code: z.string(),
}),
code: z.object({
email,
password: z.string(),
code: z.string().regex(/^\d{6}$/, "Enter the 6-digit code"),
}),
} satisfies Record<Step, z.ZodType<FormValues>>;
type FormValues = { email: string; password: string; code: string };
/** What the user signed in with: a password, or the code you emailed them. */
export type SignIn03Values =
| { method: "password"; email: string; password: string }
| { method: "code"; email: string; code: string };
export type SignIn03Props = {
/**
* Sign the user in with your auth backend, with a password or the emailed code. Throw to show
* the error as a toast; resolve and a "Signed in" toast appears (navigate away here). Store any
* session in expo-secure-store.
*/
onSubmit?: (values: SignIn03Values) => void | Promise<void>;
/**
* Email a 6-digit sign-in code (on "Email me a code instead" and on "Resend"). Throw to show
* the error as a toast and stay on the password step.
*/
onSendCode?: (email: string) => void | Promise<void>;
};
export function SignIn03({ onSubmit, onSendCode }: SignIn03Props) {
const styles = useStyles();
const { controlHeight, spacing } = useTheme();
const [step, setStep] = useState<Step>("email");
const [sending, setSending] = useState(false);
// Each step change moves the screen reader to the new title (or, without one, the keyboard to
// the new field).
const { headingRef, autoFocus } = useStepFocus(step);
const {
control,
handleSubmit,
getValues,
clearErrors,
formState: { isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schemas[step]),
defaultValues: { email: "", password: "", code: "" },
});
const goTo = (next: Step) => {
clearErrors();
setStep(next);
};
const back = previous[step];
// Android's back button steps back through the form before it leaves the Screen.
useEffect(() => {
if (!back) return;
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
clearErrors();
setStep(back);
return true;
});
return () => subscription.remove();
}, [back, clearErrors]);
const next = handleSubmit(async ({ email, password, code }) => {
if (step === "email") return goTo("password");
const values: SignIn03Values =
step === "password"
? { method: "password", email, password }
: { method: "code", email, code };
try {
await onSubmit?.(values);
toast.success("Signed in");
} catch (error) {
toast.error("Couldn't sign in", { description: messageOf(error) });
}
});
const sendCode = async () => {
const address = getValues("email").trim();
setSending(true);
try {
await onSendCode?.(address);
toast.success("Code sent", { description: `Check ${address}` });
goTo("code");
} catch (error) {
toast.error("Couldn't send the code", { description: messageOf(error) });
} finally {
setSending(false);
}
};
const shownEmail = getValues("email").trim();
const copy = {
email: { title: "What's your email?", subtitle: "Enter the email for your account." },
password: { title: "Enter your password", subtitle: `for ${shownEmail}` },
code: { title: "Check your email", subtitle: `Enter the 6-digit code sent to ${shownEmail}` },
}[step];
return (
<View style={styles.root}>
<Container
edges={["top"]}
// Keep the focused field clear of the footer that rides above the keyboard.
extraKeyboardSpace={controlHeight.lg + spacing[3] * 2}
contentContainerStyle={styles.content}
>
{back ? (
<Button
variant="ghost"
icon={ChevronLeft}
aria-label="Back"
onPress={() => goTo(back)}
style={styles.back}
/>
) : null}
<View style={styles.heading}>
<Text variant="h1" ref={headingRef}>
{copy.title}
</Text>
<Text variant="body" color="mutedForeground">
{copy.subtitle}
</Text>
</View>
<FocusChain onSubmit={next}>
{step === "email" ? (
<Controller
control={control}
name="email"
render={({ field, fieldState }) => (
<FormField label="Email" error={fieldState.error?.message}>
<Input
ref={field.ref}
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
placeholder="you@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
textContentType="emailAddress"
/>
</FormField>
)}
/>
) : step === "password" ? (
<Controller
control={control}
name="password"
render={({ field, fieldState }) => (
<FormField label="Password" error={fieldState.error?.message}>
<Input
ref={field.ref}
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
autoFocus={autoFocus}
secureTextEntry
autoComplete="current-password"
textContentType="password"
/>
</FormField>
)}
/>
) : (
<Controller
control={control}
name="code"
render={({ field, fieldState }) => (
<FormField error={fieldState.error?.message}>
<InputOTP
ref={field.ref}
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
aria-label="Verification code"
autoFocus={autoFocus}
autoComplete="one-time-code"
textContentType="oneTimeCode"
/>
</FormField>
)}
/>
)}
</FocusChain>
{step === "password" ? (
<Button
variant="link"
size="sm"
label="Email me a code instead"
loading={sending}
disabled={isSubmitting}
onPress={sendCode}
style={styles.link}
/>
) : null}
{step === "code" ? (
<View style={styles.resend}>
<Text variant="small" color="mutedForeground">
Didn't get it?
</Text>
<Button
variant="link"
size="sm"
label="Resend"
loading={sending}
disabled={isSubmitting}
onPress={sendCode}
style={styles.inline}
/>
</View>
) : null}
</Container>
<KeyboardStickyFooter style={styles.footer}>
<Button
size="lg"
label={step === "email" ? "Continue" : step === "password" ? "Sign in" : "Verify"}
loading={isSubmitting}
disabled={sending}
onPress={next}
/>
</KeyboardStickyFooter>
</View>
);
}
const useStyles = createStyles((t) => ({
root: { flex: 1, backgroundColor: t.colors.background },
content: { paddingHorizontal: t.spacing[6], paddingTop: t.spacing[4] },
// The chevron lines up with the content's edge; the tap area stays 48.
back: { alignSelf: "flex-start", marginStart: -t.spacing[3] },
heading: { gap: t.spacing[2], marginTop: t.spacing[4], marginBottom: t.spacing[4] },
link: { alignSelf: "flex-start", paddingHorizontal: 0 },
resend: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "center",
gap: t.spacing[1],
},
inline: { paddingHorizontal: 0 },
footer: { paddingHorizontal: t.spacing[6], backgroundColor: t.colors.background },
}));
/** The message to show for an error thrown by your callbacks. */
function messageOf(error: unknown): string {
return error instanceof Error && error.message
? error.message
: "Something went wrong. Try again.";
}{screens}/sign-in-03/hooks/use-step-focus.ts
import { useEffect, useRef, useState } from "react";
import { AccessibilityInfo, type Text } from "react-native";
// Private to this Block. Each multi-step Block (sign-in-03, sign-up-02, sign-up-03) keeps its own
// copy, so it installs on its own; if a second feature needs it, move it to {hooks} (ADR 0008).
/** Lets the new step reach the screen before the screen reader is pointed at it. */
const FOCUS_DELAY_MS = 150;
/**
* Moves screen-reader focus (VoiceOver, TalkBack) to the step's heading every time `step`
* changes, forward or back; not on the first render, where the Screen's own focus applies.
*
* Put `headingRef` on the step's heading (`<Text variant="h1" ref={headingRef}>`). Pass
* `autoFocus` to the step's first field: it is `false` while a screen reader runs, so the
* keyboard doesn't pull focus off the heading, and `true` otherwise.
*/
export function useStepFocus(step: string) {
const headingRef = useRef<Text>(null);
const screenReader = useScreenReaderEnabled();
const shown = useRef(step);
useEffect(() => {
if (shown.current === step) return;
shown.current = step;
const timer = setTimeout(() => {
const heading = headingRef.current;
if (heading) AccessibilityInfo.sendAccessibilityEvent(heading, "focus");
}, FOCUS_DELAY_MS);
return () => clearTimeout(timer);
}, [step]);
return { headingRef, autoFocus: !screenReader };
}
/** Whether VoiceOver or TalkBack is running, kept up to date. */
function useScreenReaderEnabled(): boolean {
const [enabled, setEnabled] = useState(false);
useEffect(() => {
let live = true;
AccessibilityInfo.isScreenReaderEnabled().then(
(on) => live && setEnabled(on),
() => {},
);
const subscription = AccessibilityInfo.addEventListener("screenReaderChanged", setEnabled);
return () => {
live = false;
subscription.remove();
};
}, []);
return enabled;
}{screens}/sign-in-03/index.tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronLeft } from "lucide-react-native";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { BackHandler, View } from "react-native";
import { z } from "zod";
import { Button } from "@/components/button";
import { Container } from "@/components/container";
import { FormField } from "@/components/form-field";
import { Input } from "@/components/input";
import { InputOTP } from "@/components/input-otp";
import { FocusChain } from "@/components/primitives/focus-chain";
import { KeyboardStickyFooter } from "@/components/primitives/keyboard";
import { Text } from "@/components/text";
import { toast } from "@/components/toast";
import { createStyles, useTheme } from "@/theme";
import { useStepFocus } from "./hooks/use-step-focus";
// sign-in-03, email first, in two steps: the email, then the password, with "Email me a code
// instead" switching to a 6-digit code. The step is kept inside the Block. Edit the copy and the
// rules below; they are yours.
/** The steps, in order. Back goes to the previous one. */
type Step = "email" | "password" | "code";
const previous: Record<Step, Step | undefined> = {
email: undefined,
password: "email",
code: "password",
};
const email = z.string().trim().min(1, "Enter your email").pipe(z.email("Enter a valid email"));
/** Each step checks only its own field; the others pass through. */
const schemas = {
email: z.object({ email, password: z.string(), code: z.string() }),
password: z.object({
email,
password: z.string().min(1, "Password is required"),
code: z.string(),
}),
code: z.object({
email,
password: z.string(),
code: z.string().regex(/^\d{6}$/, "Enter the 6-digit code"),
}),
} satisfies Record<Step, z.ZodType<FormValues>>;
type FormValues = { email: string; password: string; code: string };
/** What the user signed in with: a password, or the code you emailed them. */
export type SignIn03Values =
| { method: "password"; email: string; password: string }
| { method: "code"; email: string; code: string };
export type SignIn03Props = {
/**
* Sign the user in with your auth backend, with a password or the emailed code. Throw to show
* the error as a toast; resolve and a "Signed in" toast appears (navigate away here). Store any
* session in expo-secure-store.
*/
onSubmit?: (values: SignIn03Values) => void | Promise<void>;
/**
* Email a 6-digit sign-in code (on "Email me a code instead" and on "Resend"). Throw to show
* the error as a toast and stay on the password step.
*/
onSendCode?: (email: string) => void | Promise<void>;
};
export function SignIn03({ onSubmit, onSendCode }: SignIn03Props) {
const styles = useStyles();
const { controlHeight, spacing } = useTheme();
const [step, setStep] = useState<Step>("email");
const [sending, setSending] = useState(false);
// Each step change moves the screen reader to the new title (or, without one, the keyboard to
// the new field).
const { headingRef, autoFocus } = useStepFocus(step);
const {
control,
handleSubmit,
getValues,
clearErrors,
formState: { isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schemas[step]),
defaultValues: { email: "", password: "", code: "" },
});
const goTo = (next: Step) => {
clearErrors();
setStep(next);
};
const back = previous[step];
// Android's back button steps back through the form before it leaves the Screen.
useEffect(() => {
if (!back) return;
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
clearErrors();
setStep(back);
return true;
});
return () => subscription.remove();
}, [back, clearErrors]);
const next = handleSubmit(async ({ email, password, code }) => {
if (step === "email") return goTo("password");
const values: SignIn03Values =
step === "password"
? { method: "password", email, password }
: { method: "code", email, code };
try {
await onSubmit?.(values);
toast.success("Signed in");
} catch (error) {
toast.error("Couldn't sign in", { description: messageOf(error) });
}
});
const sendCode = async () => {
const address = getValues("email").trim();
setSending(true);
try {
await onSendCode?.(address);
toast.success("Code sent", { description: `Check ${address}` });
goTo("code");
} catch (error) {
toast.error("Couldn't send the code", { description: messageOf(error) });
} finally {
setSending(false);
}
};
const shownEmail = getValues("email").trim();
const copy = {
email: { title: "What's your email?", subtitle: "Enter the email for your account." },
password: { title: "Enter your password", subtitle: `for ${shownEmail}` },
code: { title: "Check your email", subtitle: `Enter the 6-digit code sent to ${shownEmail}` },
}[step];
return (
<View style={styles.root}>
<Container
edges={["top"]}
// Keep the focused field clear of the footer that rides above the keyboard.
extraKeyboardSpace={controlHeight.lg + spacing[3] * 2}
contentContainerStyle={styles.content}
>
{back ? (
<Button
variant="ghost"
icon={ChevronLeft}
aria-label="Back"
onPress={() => goTo(back)}
style={styles.back}
/>
) : null}
<View style={styles.heading}>
<Text variant="h1" ref={headingRef}>
{copy.title}
</Text>
<Text variant="body" color="mutedForeground">
{copy.subtitle}
</Text>
</View>
<FocusChain onSubmit={next}>
{step === "email" ? (
<Controller
control={control}
name="email"
render={({ field, fieldState }) => (
<FormField label="Email" error={fieldState.error?.message}>
<Input
ref={field.ref}
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
placeholder="you@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
textContentType="emailAddress"
/>
</FormField>
)}
/>
) : step === "password" ? (
<Controller
control={control}
name="password"
render={({ field, fieldState }) => (
<FormField label="Password" error={fieldState.error?.message}>
<Input
ref={field.ref}
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
autoFocus={autoFocus}
secureTextEntry
autoComplete="current-password"
textContentType="password"
/>
</FormField>
)}
/>
) : (
<Controller
control={control}
name="code"
render={({ field, fieldState }) => (
<FormField error={fieldState.error?.message}>
<InputOTP
ref={field.ref}
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
aria-label="Verification code"
autoFocus={autoFocus}
autoComplete="one-time-code"
textContentType="oneTimeCode"
/>
</FormField>
)}
/>
)}
</FocusChain>
{step === "password" ? (
<Button
variant="link"
size="sm"
label="Email me a code instead"
loading={sending}
disabled={isSubmitting}
onPress={sendCode}
style={styles.link}
/>
) : null}
{step === "code" ? (
<View style={styles.resend}>
<Text variant="small" color="mutedForeground">
Didn't get it?
</Text>
<Button
variant="link"
size="sm"
label="Resend"
loading={sending}
disabled={isSubmitting}
onPress={sendCode}
style={styles.inline}
/>
</View>
) : null}
</Container>
<KeyboardStickyFooter style={styles.footer}>
<Button
size="lg"
label={step === "email" ? "Continue" : step === "password" ? "Sign in" : "Verify"}
loading={isSubmitting}
disabled={sending}
onPress={next}
/>
</KeyboardStickyFooter>
</View>
);
}
const useStyles = createStyles((t) => ({
root: { flex: 1, backgroundColor: t.colors.background },
content: { paddingHorizontal: t.spacing[6], paddingTop: t.spacing[4] },
// The chevron lines up with the content's edge; the tap area stays 48.
back: { alignSelf: "flex-start", marginStart: -t.spacing[3] },
heading: { gap: t.spacing[2], marginTop: t.spacing[4], marginBottom: t.spacing[4] },
link: { alignSelf: "flex-start", paddingHorizontal: 0 },
resend: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "center",
gap: t.spacing[1],
},
inline: { paddingHorizontal: 0 },
footer: { paddingHorizontal: t.spacing[6], backgroundColor: t.colors.background },
}));
/** The message to show for an error thrown by your callbacks. */
function messageOf(error: unknown): string {
return error instanceof Error && error.message
? error.message
: "Something went wrong. Try again.";
}{screens}/sign-in-03/hooks/use-step-focus.ts
import { useEffect, useRef, useState } from "react";
import { AccessibilityInfo, type Text } from "react-native";
// Private to this Block. Each multi-step Block (sign-in-03, sign-up-02, sign-up-03) keeps its own
// copy, so it installs on its own; if a second feature needs it, move it to {hooks} (ADR 0008).
/** Lets the new step reach the screen before the screen reader is pointed at it. */
const FOCUS_DELAY_MS = 150;
/**
* Moves screen-reader focus (VoiceOver, TalkBack) to the step's heading every time `step`
* changes, forward or back; not on the first render, where the Screen's own focus applies.
*
* Put `headingRef` on the step's heading (`<Text variant="h1" ref={headingRef}>`). Pass
* `autoFocus` to the step's first field: it is `false` while a screen reader runs, so the
* keyboard doesn't pull focus off the heading, and `true` otherwise.
*/
export function useStepFocus(step: string) {
const headingRef = useRef<Text>(null);
const screenReader = useScreenReaderEnabled();
const shown = useRef(step);
useEffect(() => {
if (shown.current === step) return;
shown.current = step;
const timer = setTimeout(() => {
const heading = headingRef.current;
if (heading) AccessibilityInfo.sendAccessibilityEvent(heading, "focus");
}, FOCUS_DELAY_MS);
return () => clearTimeout(timer);
}, [step]);
return { headingRef, autoFocus: !screenReader };
}
/** Whether VoiceOver or TalkBack is running, kept up to date. */
function useScreenReaderEnabled(): boolean {
const [enabled, setEnabled] = useState(false);
useEffect(() => {
let live = true;
AccessibilityInfo.isScreenReaderEnabled().then(
(on) => live && setEnabled(on),
() => {},
);
const subscription = AccessibilityInfo.addEventListener("screenReaderChanged", setEnabled);
return () => {
live = false;
subscription.remove();
};
}, []);
return enabled;
}
