Social-first + verify
A brand-led, passwordless sign-up Screen: the logo and headline centred above Apple and Google; "Sign up with email" reveals the email field, and Send code moves on to a 6-digit code (InputOTP).
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-up-03 --route "(auth)/sign-up"--route "(auth)/sign-up"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, icon, 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
Brand-led and passwordless; Apple/Google lead, and the email path sends an InputOTP code.
Block Variants
sign-up-01– Classic single form: Social on top, then name, email, password and a terms Checkbox; one form.sign-up-02– Step-by-step wizard: Progress bar plus one question per screen: email → name → password → verify code.sign-up-03(this page) – Social-first + verify: Brand-led and passwordless; Apple/Google lead, and the email path sends an InputOTP code.
Props
| Prop | Type and description |
|---|---|
onSocialSignIn | (provider: "apple" | "google") => void | Promise<void>: run that provider's sign-up; a thrown error becomes a toast |
onSendCode | (email: string) => void | Promise<void>: email a 6-digit code (Send code, Resend); a thrown error becomes a toast and the email field stays |
onSubmit | (values: { email, code }) => void | Promise<void>: check the code and create the account; throw to show the error as a toast, resolve for an "Account created" toast |
Accessibility
- The headline and "Check your inbox" have role heading; the logo icon is decorative.
- Moving to the code step puts screen-reader focus on "Check your inbox"; Back and Android's back button put it on the headline.
- 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.
- Apple and Google are labelled Buttons ("Continue with Apple", "Continue with Google"); their logos are decorative.
- 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 <SignUp03 onSocialSignIn={signUpWith} onSendCode={sendCode} onSubmit={verify} />; }. There is no password: the email path checks the address with zod through react-hook-form, calls onSendCode, then asks for the 6-digit code. The step lives inside the Block; Back (an icon-only Button with a Lucide chevron-left) and Android's back button return to the email field, keeping it. The Create account 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 Apple and Google logos are bundled SVGs in components/social-buttons.tsx; the buttons only call onSocialSignIn. 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-up-03/index.tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronLeft, Hexagon } from "lucide-react-native";
import { useEffect, useRef, useState, type RefObject } from "react";
import { Controller, useForm, type UseFormReturn } from "react-hook-form";
import { BackHandler, View, type Text as RNText } from "react-native";
import { z } from "zod";
import { Button } from "@/components/button";
import { Container } from "@/components/container";
import { FormField } from "@/components/form-field";
import { Icon } from "@/components/icon";
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 { SocialButtons, type SocialProvider } from "./components/social-buttons";
import { useStepFocus } from "./hooks/use-step-focus";
export type { SocialProvider } from "./components/social-buttons";
// sign-up-03, social first, then a code: brand-led and passwordless. Apple and Google are the
// main path; "Sign up with email" reveals the email field, and "Send code" moves on to a 6-digit
// code (InputOTP). There is no password. The step is kept inside the Block. Edit the copy, the
// logo and the rules below; they are yours.
/** `social`: the buttons (and, once revealed, the email field). `code`: the code check. */
type Step = "social" | "code";
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 other passes through. */
const schemas = {
social: z.object({ email, code: z.string() }),
code: z.object({ email, code: z.string().regex(/^\d{6}$/, "Enter the 6-digit code") }),
} satisfies Record<Step, z.ZodType<SignUp03Values>>;
/** The email and the code the user typed. */
export type SignUp03Values = { email: string; code: string };
export type SignUp03Props = {
/** Run the provider's sign-up. Throw to show the error as a toast. */
onSocialSignIn?: (provider: SocialProvider) => void | Promise<void>;
/**
* Email a 6-digit sign-up code (on "Send code" and on "Resend"). Throw to show the error as a
* toast and stay on the email field.
*/
onSendCode?: (email: string) => void | Promise<void>;
/**
* Check the code and create the account. Throw to show the error as a toast; resolve and an
* "Account created" toast appears (navigate away here). Store any session in
* expo-secure-store.
*/
onSubmit?: (values: SignUp03Values) => void | Promise<void>;
};
export function SignUp03({ onSocialSignIn, onSendCode, onSubmit }: SignUp03Props) {
const [step, setStep] = useState<Step>("social");
const [emailOpen, setEmailOpen] = useState(false);
const [sending, setSending] = useState(false);
// Each step change moves the screen reader to the new heading (or, without one, the keyboard to
// the new field).
const { headingRef, autoFocus } = useStepFocus(step);
const form = useForm<SignUp03Values>({
resolver: zodResolver(schemas[step]),
defaultValues: { email: "", code: "" },
});
const { handleSubmit, getValues, clearErrors } = form;
const goTo = (next: Step) => {
clearErrors();
setStep(next);
};
// On the code step, Android's back button returns to the email field before it leaves.
useEffect(() => {
if (step !== "code") return;
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
clearErrors();
setStep("social");
return true;
});
return () => subscription.remove();
}, [step, clearErrors]);
/** Emails the code; says so in a toast. Returns whether it worked. */
const sendCode = async (address: string) => {
setSending(true);
try {
await onSendCode?.(address);
toast.success("Code sent", { description: `Check ${address}` });
return true;
} catch (error) {
toast.error("Couldn't send the code", { description: messageOf(error) });
return false;
} finally {
setSending(false);
}
};
const next = handleSubmit(async (values) => {
if (step === "social") {
if (await sendCode(values.email)) goTo("code");
return;
}
try {
await onSubmit?.(values);
toast.success("Account created");
} catch (error) {
toast.error("Couldn't create your account", { description: messageOf(error) });
}
});
const socialSignIn = async (provider: SocialProvider) => {
try {
await onSocialSignIn?.(provider);
} catch (error) {
toast.error("Couldn't sign up", { description: messageOf(error) });
}
};
return step === "social" ? (
<SocialStep
form={form}
headingRef={headingRef}
autoFocus={autoFocus}
emailOpen={emailOpen}
onOpenEmail={() => setEmailOpen(true)}
onSocialSignIn={socialSignIn}
onNext={next}
/>
) : (
<CodeStep
form={form}
headingRef={headingRef}
autoFocus={autoFocus}
email={getValues("email").trim()}
sending={sending}
onBack={() => goTo("social")}
onResend={() => void sendCode(getValues("email").trim())}
onNext={next}
/>
);
}
type StepProps = {
form: UseFormReturn<SignUp03Values>;
/** On the step's heading, so a step change can move the screen reader to it. */
headingRef: RefObject<RNText | null>;
/** Whether the step's field takes the keyboard when it appears (off while a screen reader runs). */
autoFocus: boolean;
onNext: () => void;
};
/** The brand, Apple and Google, and the email field behind "Sign up with email". */
function SocialStep({
form: {
control,
formState: { isSubmitting },
},
headingRef,
autoFocus,
emailOpen,
onOpenEmail,
onSocialSignIn,
onNext,
}: StepProps & {
emailOpen: boolean;
onOpenEmail: () => void;
onSocialSignIn: (provider: SocialProvider) => void;
}) {
const styles = useStyles();
// Opened here by "Sign up with email": the field takes focus. Already open when this step
// appears (Back from the code): the heading gets the screen reader, so only take the keyboard
// without one.
const openedOnArrival = useRef(emailOpen).current;
return (
<Container contentContainerStyle={styles.socialContent}>
<View style={styles.brand}>
{/* Your logo goes here. */}
<View style={styles.logo}>
<Icon icon={Hexagon} size="lg" color="primaryForeground" />
</View>
<Text variant="h1" align="center" ref={headingRef}>
Get started
</Text>
<Text variant="body" color="mutedForeground" align="center">
Create your account in seconds.
</Text>
</View>
<SocialButtons size="lg" onPress={onSocialSignIn} disabled={isSubmitting} />
{emailOpen ? (
<>
<FocusChain onSubmit={onNext}>
<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}
autoFocus={autoFocus || !openedOnArrival}
placeholder="you@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
textContentType="emailAddress"
/>
</FormField>
)}
/>
</FocusChain>
<Button size="lg" label="Send code" loading={isSubmitting} onPress={onNext} />
</>
) : (
<Button variant="ghost" size="lg" label="Sign up with email" onPress={onOpenEmail} />
)}
<Text variant="caption" color="mutedForeground" align="center">
By continuing you agree to the Terms and Privacy Policy.
</Text>
</Container>
);
}
/** The 6-digit code check, with Back and Resend. */
function CodeStep({
form: {
control,
formState: { isSubmitting },
},
headingRef,
autoFocus,
email,
sending,
onBack,
onResend,
onNext,
}: StepProps & { email: string; sending: boolean; onBack: () => void; onResend: () => void }) {
const styles = useStyles();
const { controlHeight, spacing } = useTheme();
return (
<View style={styles.root}>
<Container
edges={["top"]}
// Keep the code clear of the footer that rides above the keyboard.
extraKeyboardSpace={controlHeight.lg + spacing[3] * 2}
contentContainerStyle={styles.codeContent}
>
<Button
variant="ghost"
icon={ChevronLeft}
aria-label="Back"
onPress={onBack}
style={styles.back}
/>
<View style={styles.heading}>
<Text variant="h1" ref={headingRef}>
Check your inbox
</Text>
<Text variant="body" color="mutedForeground">
{`Enter the 6-digit code sent to ${email}`}
</Text>
</View>
<FocusChain onSubmit={onNext}>
<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>
<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={onResend}
style={styles.inline}
/>
</View>
</Container>
<KeyboardStickyFooter style={styles.footer}>
<Button
size="lg"
label="Create account"
loading={isSubmitting}
disabled={sending}
onPress={onNext}
/>
</KeyboardStickyFooter>
</View>
);
}
const useStyles = createStyles((t) => ({
root: { flex: 1, backgroundColor: t.colors.background },
socialContent: {
gap: t.spacing[3],
paddingHorizontal: t.spacing[6],
paddingTop: t.spacing[10],
paddingBottom: t.spacing[8],
},
// The brand fills the space above the buttons, centred.
brand: {
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
gap: t.spacing[3],
paddingBottom: t.spacing[6],
},
logo: {
width: t.scaleValue(72),
height: t.scaleValue(72),
marginBottom: t.spacing[2],
alignItems: "center",
justifyContent: "center",
borderRadius: t.radius["2xl"],
borderCurve: "continuous",
backgroundColor: t.colors.primary,
},
codeContent: { 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] },
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-up-03/components/social-buttons.tsx
import type { LucideIcon, LucideProps } from "lucide-react-native";
import { forwardRef } from "react";
import { View } from "react-native";
import Svg, { Path } from "react-native-svg";
import { Button, type ButtonSize } from "@/components/button";
import { createStyles } from "@/theme";
export type SocialProvider = "apple" | "google";
// Brand logos, drawn to each brand's guidelines and shaped like a Lucide icon so Button can
// size and colour them. Apple's mark takes the text colour (black on light, white on dark);
// Google's "G" always keeps its four brand colours.
/** The Apple logo (Simple Icons, CC0), in the colour Button gives it. */
export const AppleLogo: LucideIcon = forwardRef<Svg, LucideProps>(function AppleLogo(
{ size = 24, color = "currentColor" },
ref,
) {
return (
<Svg ref={ref} width={size} height={size} viewBox="0 0 24 24">
<Path
fill={color}
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
/>
</Svg>
);
});
/** Google's four-colour "G" (its colours are part of the brand, so they never follow the Theme). */
export const GoogleLogo: LucideIcon = forwardRef<Svg, LucideProps>(function GoogleLogo(
{ size = 24 },
ref,
) {
return (
<Svg ref={ref} width={size} height={size} viewBox="0 0 48 48">
<Path
fill="#EA4335"
d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
/>
<Path
fill="#4285F4"
d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
/>
<Path
fill="#FBBC05"
d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
/>
<Path
fill="#34A853"
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
/>
</Svg>
);
});
const providers = [
{ provider: "apple", label: "Continue with Apple", icon: AppleLogo },
{ provider: "google", label: "Continue with Google", icon: GoogleLogo },
] as const satisfies { provider: SocialProvider; label: string; icon: LucideIcon }[];
const useStyles = createStyles((t) => ({
root: { gap: t.spacing[3] },
}));
export type SocialButtonsProps = {
/** Called with the provider; your code runs that provider's sign-in. */
onPress: (provider: SocialProvider) => void;
/** Default `md`. */
size?: ButtonSize;
disabled?: boolean;
};
/** "Continue with Apple" and "Continue with Google", as outline Buttons. */
export function SocialButtons({ onPress, size = "md", disabled = false }: SocialButtonsProps) {
const styles = useStyles();
return (
<View style={styles.root}>
{providers.map(({ provider, label, icon }) => (
<Button
key={provider}
variant="outline"
size={size}
label={label}
icon={icon}
disabled={disabled}
onPress={() => onPress(provider)}
/>
))}
</View>
);
}{screens}/sign-up-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-up-03/index.tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronLeft, Hexagon } from "lucide-react-native";
import { useEffect, useRef, useState, type RefObject } from "react";
import { Controller, useForm, type UseFormReturn } from "react-hook-form";
import { BackHandler, View, type Text as RNText } from "react-native";
import { z } from "zod";
import { Button } from "@/components/button";
import { Container } from "@/components/container";
import { FormField } from "@/components/form-field";
import { Icon } from "@/components/icon";
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 { SocialButtons, type SocialProvider } from "./components/social-buttons";
import { useStepFocus } from "./hooks/use-step-focus";
export type { SocialProvider } from "./components/social-buttons";
// sign-up-03, social first, then a code: brand-led and passwordless. Apple and Google are the
// main path; "Sign up with email" reveals the email field, and "Send code" moves on to a 6-digit
// code (InputOTP). There is no password. The step is kept inside the Block. Edit the copy, the
// logo and the rules below; they are yours.
/** `social`: the buttons (and, once revealed, the email field). `code`: the code check. */
type Step = "social" | "code";
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 other passes through. */
const schemas = {
social: z.object({ email, code: z.string() }),
code: z.object({ email, code: z.string().regex(/^\d{6}$/, "Enter the 6-digit code") }),
} satisfies Record<Step, z.ZodType<SignUp03Values>>;
/** The email and the code the user typed. */
export type SignUp03Values = { email: string; code: string };
export type SignUp03Props = {
/** Run the provider's sign-up. Throw to show the error as a toast. */
onSocialSignIn?: (provider: SocialProvider) => void | Promise<void>;
/**
* Email a 6-digit sign-up code (on "Send code" and on "Resend"). Throw to show the error as a
* toast and stay on the email field.
*/
onSendCode?: (email: string) => void | Promise<void>;
/**
* Check the code and create the account. Throw to show the error as a toast; resolve and an
* "Account created" toast appears (navigate away here). Store any session in
* expo-secure-store.
*/
onSubmit?: (values: SignUp03Values) => void | Promise<void>;
};
export function SignUp03({ onSocialSignIn, onSendCode, onSubmit }: SignUp03Props) {
const [step, setStep] = useState<Step>("social");
const [emailOpen, setEmailOpen] = useState(false);
const [sending, setSending] = useState(false);
// Each step change moves the screen reader to the new heading (or, without one, the keyboard to
// the new field).
const { headingRef, autoFocus } = useStepFocus(step);
const form = useForm<SignUp03Values>({
resolver: zodResolver(schemas[step]),
defaultValues: { email: "", code: "" },
});
const { handleSubmit, getValues, clearErrors } = form;
const goTo = (next: Step) => {
clearErrors();
setStep(next);
};
// On the code step, Android's back button returns to the email field before it leaves.
useEffect(() => {
if (step !== "code") return;
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
clearErrors();
setStep("social");
return true;
});
return () => subscription.remove();
}, [step, clearErrors]);
/** Emails the code; says so in a toast. Returns whether it worked. */
const sendCode = async (address: string) => {
setSending(true);
try {
await onSendCode?.(address);
toast.success("Code sent", { description: `Check ${address}` });
return true;
} catch (error) {
toast.error("Couldn't send the code", { description: messageOf(error) });
return false;
} finally {
setSending(false);
}
};
const next = handleSubmit(async (values) => {
if (step === "social") {
if (await sendCode(values.email)) goTo("code");
return;
}
try {
await onSubmit?.(values);
toast.success("Account created");
} catch (error) {
toast.error("Couldn't create your account", { description: messageOf(error) });
}
});
const socialSignIn = async (provider: SocialProvider) => {
try {
await onSocialSignIn?.(provider);
} catch (error) {
toast.error("Couldn't sign up", { description: messageOf(error) });
}
};
return step === "social" ? (
<SocialStep
form={form}
headingRef={headingRef}
autoFocus={autoFocus}
emailOpen={emailOpen}
onOpenEmail={() => setEmailOpen(true)}
onSocialSignIn={socialSignIn}
onNext={next}
/>
) : (
<CodeStep
form={form}
headingRef={headingRef}
autoFocus={autoFocus}
email={getValues("email").trim()}
sending={sending}
onBack={() => goTo("social")}
onResend={() => void sendCode(getValues("email").trim())}
onNext={next}
/>
);
}
type StepProps = {
form: UseFormReturn<SignUp03Values>;
/** On the step's heading, so a step change can move the screen reader to it. */
headingRef: RefObject<RNText | null>;
/** Whether the step's field takes the keyboard when it appears (off while a screen reader runs). */
autoFocus: boolean;
onNext: () => void;
};
/** The brand, Apple and Google, and the email field behind "Sign up with email". */
function SocialStep({
form: {
control,
formState: { isSubmitting },
},
headingRef,
autoFocus,
emailOpen,
onOpenEmail,
onSocialSignIn,
onNext,
}: StepProps & {
emailOpen: boolean;
onOpenEmail: () => void;
onSocialSignIn: (provider: SocialProvider) => void;
}) {
const styles = useStyles();
// Opened here by "Sign up with email": the field takes focus. Already open when this step
// appears (Back from the code): the heading gets the screen reader, so only take the keyboard
// without one.
const openedOnArrival = useRef(emailOpen).current;
return (
<Container contentContainerStyle={styles.socialContent}>
<View style={styles.brand}>
{/* Your logo goes here. */}
<View style={styles.logo}>
<Icon icon={Hexagon} size="lg" color="primaryForeground" />
</View>
<Text variant="h1" align="center" ref={headingRef}>
Get started
</Text>
<Text variant="body" color="mutedForeground" align="center">
Create your account in seconds.
</Text>
</View>
<SocialButtons size="lg" onPress={onSocialSignIn} disabled={isSubmitting} />
{emailOpen ? (
<>
<FocusChain onSubmit={onNext}>
<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}
autoFocus={autoFocus || !openedOnArrival}
placeholder="you@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
textContentType="emailAddress"
/>
</FormField>
)}
/>
</FocusChain>
<Button size="lg" label="Send code" loading={isSubmitting} onPress={onNext} />
</>
) : (
<Button variant="ghost" size="lg" label="Sign up with email" onPress={onOpenEmail} />
)}
<Text variant="caption" color="mutedForeground" align="center">
By continuing you agree to the Terms and Privacy Policy.
</Text>
</Container>
);
}
/** The 6-digit code check, with Back and Resend. */
function CodeStep({
form: {
control,
formState: { isSubmitting },
},
headingRef,
autoFocus,
email,
sending,
onBack,
onResend,
onNext,
}: StepProps & { email: string; sending: boolean; onBack: () => void; onResend: () => void }) {
const styles = useStyles();
const { controlHeight, spacing } = useTheme();
return (
<View style={styles.root}>
<Container
edges={["top"]}
// Keep the code clear of the footer that rides above the keyboard.
extraKeyboardSpace={controlHeight.lg + spacing[3] * 2}
contentContainerStyle={styles.codeContent}
>
<Button
variant="ghost"
icon={ChevronLeft}
aria-label="Back"
onPress={onBack}
style={styles.back}
/>
<View style={styles.heading}>
<Text variant="h1" ref={headingRef}>
Check your inbox
</Text>
<Text variant="body" color="mutedForeground">
{`Enter the 6-digit code sent to ${email}`}
</Text>
</View>
<FocusChain onSubmit={onNext}>
<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>
<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={onResend}
style={styles.inline}
/>
</View>
</Container>
<KeyboardStickyFooter style={styles.footer}>
<Button
size="lg"
label="Create account"
loading={isSubmitting}
disabled={sending}
onPress={onNext}
/>
</KeyboardStickyFooter>
</View>
);
}
const useStyles = createStyles((t) => ({
root: { flex: 1, backgroundColor: t.colors.background },
socialContent: {
gap: t.spacing[3],
paddingHorizontal: t.spacing[6],
paddingTop: t.spacing[10],
paddingBottom: t.spacing[8],
},
// The brand fills the space above the buttons, centred.
brand: {
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
gap: t.spacing[3],
paddingBottom: t.spacing[6],
},
logo: {
width: t.scaleValue(72),
height: t.scaleValue(72),
marginBottom: t.spacing[2],
alignItems: "center",
justifyContent: "center",
borderRadius: t.radius["2xl"],
borderCurve: "continuous",
backgroundColor: t.colors.primary,
},
codeContent: { 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] },
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-up-03/components/social-buttons.tsx
import type { LucideIcon, LucideProps } from "lucide-react-native";
import { forwardRef } from "react";
import { View } from "react-native";
import Svg, { Path } from "react-native-svg";
import { Button, type ButtonSize } from "@/components/button";
import { createStyles } from "@/theme";
export type SocialProvider = "apple" | "google";
// Brand logos, drawn to each brand's guidelines and shaped like a Lucide icon so Button can
// size and colour them. Apple's mark takes the text colour (black on light, white on dark);
// Google's "G" always keeps its four brand colours.
/** The Apple logo (Simple Icons, CC0), in the colour Button gives it. */
export const AppleLogo: LucideIcon = forwardRef<Svg, LucideProps>(function AppleLogo(
{ size = 24, color = "currentColor" },
ref,
) {
return (
<Svg ref={ref} width={size} height={size} viewBox="0 0 24 24">
<Path
fill={color}
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
/>
</Svg>
);
});
/** Google's four-colour "G" (its colours are part of the brand, so they never follow the Theme). */
export const GoogleLogo: LucideIcon = forwardRef<Svg, LucideProps>(function GoogleLogo(
{ size = 24 },
ref,
) {
return (
<Svg ref={ref} width={size} height={size} viewBox="0 0 48 48">
<Path
fill="#EA4335"
d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
/>
<Path
fill="#4285F4"
d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
/>
<Path
fill="#FBBC05"
d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
/>
<Path
fill="#34A853"
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
/>
</Svg>
);
});
const providers = [
{ provider: "apple", label: "Continue with Apple", icon: AppleLogo },
{ provider: "google", label: "Continue with Google", icon: GoogleLogo },
] as const satisfies { provider: SocialProvider; label: string; icon: LucideIcon }[];
const useStyles = createStyles((t) => ({
root: { gap: t.spacing[3] },
}));
export type SocialButtonsProps = {
/** Called with the provider; your code runs that provider's sign-in. */
onPress: (provider: SocialProvider) => void;
/** Default `md`. */
size?: ButtonSize;
disabled?: boolean;
};
/** "Continue with Apple" and "Continue with Google", as outline Buttons. */
export function SocialButtons({ onPress, size = "md", disabled = false }: SocialButtonsProps) {
const styles = useStyles();
return (
<View style={styles.root}>
{providers.map(({ provider, label, icon }) => (
<Button
key={provider}
variant="outline"
size={size}
label={label}
icon={icon}
disabled={disabled}
onPress={() => onPress(provider)}
/>
))}
</View>
);
}{screens}/sign-up-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;
}
