# 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.

Block · auth. Registry item `sign-in-03`, shown in the vega Style. JSON: https://nativecn.dev/r/styles/vega/sign-in-03.json

## Installation

```sh
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`).
- `add` never overwrites an existing route file.

Also installs: [theme](https://nativecn.dev/docs/theme/theme), [container](https://nativecn.dev/docs/components/container), [text](https://nativecn.dev/docs/components/text), [button](https://nativecn.dev/docs/components/button), [form-field](https://nativecn.dev/docs/components/form-field), [input](https://nativecn.dev/docs/components/input), [focus-chain](https://nativecn.dev/docs/primitives/focus-chain), [input-otp](https://nativecn.dev/docs/components/input-otp), [keyboard](https://nativecn.dev/docs/primitives/keyboard), [toast](https://nativecn.dev/docs/components/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](https://nativecn.dev/docs/blocks/sign-in-01) – Classic centred: Logo, email and password, "or", then social; everything on one screen.
- [sign-in-02](https://nativecn.dev/docs/blocks/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

### {screens}/sign-in-03/index.tsx

```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&apos;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

```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;
}
```
