Step-by-step wizard
A sign-up wizard, one question per step under a Progress bar: email, name, password, then a 6-digit code (InputOTP). Lucide chevron-left Back buttons; the Continue button rides above the keyboard.
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-02 --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, progress, 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
Progress bar plus one question per screen: email → name → password → verify 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(this page) – Step-by-step wizard: Progress bar plus one question per screen: email → name → password → verify code.sign-up-03– Social-first + verify: Brand-led and passwordless; Apple/Google lead, and the email path sends an InputOTP code.
Props
| Prop | Type and description |
|---|---|
onSendCode | (details: { email, name, password }) => void | Promise<void>: register the details and email a 6-digit code (after the password step, and on Resend, so make it safe to repeat); a thrown error becomes a toast and the step stays |
onSubmit | (values: { email, name, password, code }) => void | Promise<void>: check the code and finish the account; throw to show the error as a toast, resolve for an "Account created" toast |
Accessibility
- Each step's title has role heading.
- Every step change (Continue, 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.
- The Progress bar is named "Step 2 of 4" and carries its value; the visible "2/4" is hidden from screen readers.
- 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 <SignUp02 onSendCode={register} onSubmit={verify} />; }. The step (email, name, password, code) lives inside the Block, and the Progress bar shows how far along it is. Back (an icon-only Button with a Lucide chevron-left) and Android's back button step back, keeping every answer. Each step validates its own field with zod through react-hook-form; the password needs at least 8 characters. 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-up-02/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 { Progress } from "@/components/progress";
import { Text } from "@/components/text";
import { toast } from "@/components/toast";
import { createStyles, useTheme } from "@/theme";
import { useStepFocus } from "./hooks/use-step-focus";
// sign-up-02, a step-by-step wizard: one question per step under a Progress bar, in the order
// email, name, password, then the 6-digit code you emailed. The step is kept inside the Block.
// Edit the copy, the steps and the rules below; they are yours.
/** The steps, in order. Back goes to the previous one. */
const steps = ["email", "name", "password", "code"] as const;
type Step = (typeof steps)[number];
const email = z.string().trim().min(1, "Enter your email").pipe(z.email("Enter a valid email"));
const name = z.string().trim().min(1, "Enter your name");
const password = z.string().min(8, "Use at least 8 characters");
const code = z.string().regex(/^\d{6}$/, "Enter the 6-digit code");
/** Each step checks its own field and the ones before it; later ones pass through. */
const schemas = {
email: z.object({ email, name: z.string(), password: z.string(), code: z.string() }),
name: z.object({ email, name, password: z.string(), code: z.string() }),
password: z.object({ email, name, password, code: z.string() }),
code: z.object({ email, name, password, code }),
} satisfies Record<Step, z.ZodType<FormValues>>;
type FormValues = { email: string; name: string; password: string; code: string };
/** The details collected before the code step. */
export type SignUp02Details = Omit<FormValues, "code">;
/** The details plus the code the user typed. */
export type SignUp02Values = FormValues;
export type SignUp02Props = {
/**
* Register the details with your auth backend and email a 6-digit code (after the password
* step, and again on "Resend", so make it safe to repeat). Throw to show the error as a toast
* and stay on the password step.
*/
onSendCode?: (details: SignUp02Details) => void | Promise<void>;
/**
* Check the code and finish creating 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: SignUp02Values) => void | Promise<void>;
};
export function SignUp02({ onSendCode, onSubmit }: SignUp02Props) {
const styles = useStyles();
const { controlHeight, spacing } = useTheme();
const [step, setStep] = useState<Step>("email");
const [resending, setResending] = 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: "", name: "", password: "", code: "" },
});
const index = steps.indexOf(step);
const back = steps[index - 1];
const goTo = (next: Step) => {
clearErrors();
setStep(next);
};
// Android's back button steps back through the wizard before it leaves the Screen.
useEffect(() => {
if (!back) return;
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
clearErrors();
setStep(back);
return true;
});
return () => subscription.remove();
}, [back, clearErrors]);
/** Sends the code; says so in a toast. Returns whether it worked. */
const sendCode = async ({ code: _code, ...details }: FormValues) => {
try {
await onSendCode?.(details);
toast.success("Code sent", { description: `Check ${details.email}` });
return true;
} catch (error) {
toast.error("Couldn't send the code", { description: messageOf(error) });
return false;
}
};
const next = handleSubmit(async (values) => {
if (step === "email") return goTo("name");
if (step === "name") return goTo("password");
if (step === "password") {
if (await sendCode(values)) goTo("code");
return;
}
try {
await onSubmit?.(values);
toast.success("Account created");
} catch (error) {
toast.error("Couldn't create your account", { description: messageOf(error) });
}
});
const resend = async () => {
setResending(true);
// The details were validated on the way here; trim them as they were the first time.
const values = getValues();
await sendCode({ ...values, email: values.email.trim(), name: values.name.trim() });
setResending(false);
};
const shownEmail = getValues("email").trim();
const copy = {
email: { title: "What's your email?", subtitle: "We'll send a code to confirm it." },
name: { title: "What should we call you?", subtitle: "This is shown on your profile." },
password: { title: "Create a password", subtitle: "At least 8 characters." },
code: { title: "Verify 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}
>
<View style={styles.bar}>
{back ? (
<Button
variant="ghost"
icon={ChevronLeft}
aria-label="Back"
onPress={() => goTo(back)}
style={styles.back}
/>
) : null}
<Progress
value={((index + 1) / steps.length) * 100}
aria-label={`Step ${index + 1} of ${steps.length}`}
style={styles.progress}
/>
<Text variant="caption" color="mutedForeground" aria-hidden>
{`${index + 1}/${steps.length}`}
</Text>
</View>
<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 === "name" ? (
<Controller
control={control}
name="name"
render={({ field, fieldState }) => (
<FormField label="Full name" error={fieldState.error?.message}>
<Input
ref={field.ref}
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
autoFocus={autoFocus}
placeholder="Jane Doe"
autoCapitalize="words"
autoComplete="name"
textContentType="name"
/>
</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="new-password"
textContentType="newPassword"
/>
</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 === "code" ? (
<View style={styles.resend}>
<Text variant="small" color="mutedForeground">
Didn't get it?
</Text>
<Button
variant="link"
size="sm"
label="Resend"
loading={resending}
disabled={isSubmitting}
onPress={resend}
style={styles.inline}
/>
</View>
) : null}
</Container>
<KeyboardStickyFooter style={styles.footer}>
<Button
size="lg"
label={step === "code" ? "Create account" : "Continue"}
loading={isSubmitting}
disabled={resending}
onPress={next}
/>
</KeyboardStickyFooter>
</View>
);
}
const useStyles = createStyles((t) => ({
root: { flex: 1, backgroundColor: t.colors.background },
content: { paddingHorizontal: t.spacing[6], paddingTop: t.spacing[2] },
// Back, the Progress bar and the step count, on one row as tall as the Back button.
bar: {
flexDirection: "row",
alignItems: "center",
gap: t.spacing[3],
minHeight: t.controlHeight.md,
},
// The chevron lines up with the content's edge; the tap area stays 48.
back: { marginStart: -t.spacing[3] },
progress: { flex: 1 },
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-02/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-02/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 { Progress } from "@/components/progress";
import { Text } from "@/components/text";
import { toast } from "@/components/toast";
import { createStyles, useTheme } from "@/theme";
import { useStepFocus } from "./hooks/use-step-focus";
// sign-up-02, a step-by-step wizard: one question per step under a Progress bar, in the order
// email, name, password, then the 6-digit code you emailed. The step is kept inside the Block.
// Edit the copy, the steps and the rules below; they are yours.
/** The steps, in order. Back goes to the previous one. */
const steps = ["email", "name", "password", "code"] as const;
type Step = (typeof steps)[number];
const email = z.string().trim().min(1, "Enter your email").pipe(z.email("Enter a valid email"));
const name = z.string().trim().min(1, "Enter your name");
const password = z.string().min(8, "Use at least 8 characters");
const code = z.string().regex(/^\d{6}$/, "Enter the 6-digit code");
/** Each step checks its own field and the ones before it; later ones pass through. */
const schemas = {
email: z.object({ email, name: z.string(), password: z.string(), code: z.string() }),
name: z.object({ email, name, password: z.string(), code: z.string() }),
password: z.object({ email, name, password, code: z.string() }),
code: z.object({ email, name, password, code }),
} satisfies Record<Step, z.ZodType<FormValues>>;
type FormValues = { email: string; name: string; password: string; code: string };
/** The details collected before the code step. */
export type SignUp02Details = Omit<FormValues, "code">;
/** The details plus the code the user typed. */
export type SignUp02Values = FormValues;
export type SignUp02Props = {
/**
* Register the details with your auth backend and email a 6-digit code (after the password
* step, and again on "Resend", so make it safe to repeat). Throw to show the error as a toast
* and stay on the password step.
*/
onSendCode?: (details: SignUp02Details) => void | Promise<void>;
/**
* Check the code and finish creating 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: SignUp02Values) => void | Promise<void>;
};
export function SignUp02({ onSendCode, onSubmit }: SignUp02Props) {
const styles = useStyles();
const { controlHeight, spacing } = useTheme();
const [step, setStep] = useState<Step>("email");
const [resending, setResending] = 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: "", name: "", password: "", code: "" },
});
const index = steps.indexOf(step);
const back = steps[index - 1];
const goTo = (next: Step) => {
clearErrors();
setStep(next);
};
// Android's back button steps back through the wizard before it leaves the Screen.
useEffect(() => {
if (!back) return;
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
clearErrors();
setStep(back);
return true;
});
return () => subscription.remove();
}, [back, clearErrors]);
/** Sends the code; says so in a toast. Returns whether it worked. */
const sendCode = async ({ code: _code, ...details }: FormValues) => {
try {
await onSendCode?.(details);
toast.success("Code sent", { description: `Check ${details.email}` });
return true;
} catch (error) {
toast.error("Couldn't send the code", { description: messageOf(error) });
return false;
}
};
const next = handleSubmit(async (values) => {
if (step === "email") return goTo("name");
if (step === "name") return goTo("password");
if (step === "password") {
if (await sendCode(values)) goTo("code");
return;
}
try {
await onSubmit?.(values);
toast.success("Account created");
} catch (error) {
toast.error("Couldn't create your account", { description: messageOf(error) });
}
});
const resend = async () => {
setResending(true);
// The details were validated on the way here; trim them as they were the first time.
const values = getValues();
await sendCode({ ...values, email: values.email.trim(), name: values.name.trim() });
setResending(false);
};
const shownEmail = getValues("email").trim();
const copy = {
email: { title: "What's your email?", subtitle: "We'll send a code to confirm it." },
name: { title: "What should we call you?", subtitle: "This is shown on your profile." },
password: { title: "Create a password", subtitle: "At least 8 characters." },
code: { title: "Verify 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}
>
<View style={styles.bar}>
{back ? (
<Button
variant="ghost"
icon={ChevronLeft}
aria-label="Back"
onPress={() => goTo(back)}
style={styles.back}
/>
) : null}
<Progress
value={((index + 1) / steps.length) * 100}
aria-label={`Step ${index + 1} of ${steps.length}`}
style={styles.progress}
/>
<Text variant="caption" color="mutedForeground" aria-hidden>
{`${index + 1}/${steps.length}`}
</Text>
</View>
<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 === "name" ? (
<Controller
control={control}
name="name"
render={({ field, fieldState }) => (
<FormField label="Full name" error={fieldState.error?.message}>
<Input
ref={field.ref}
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
autoFocus={autoFocus}
placeholder="Jane Doe"
autoCapitalize="words"
autoComplete="name"
textContentType="name"
/>
</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="new-password"
textContentType="newPassword"
/>
</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 === "code" ? (
<View style={styles.resend}>
<Text variant="small" color="mutedForeground">
Didn't get it?
</Text>
<Button
variant="link"
size="sm"
label="Resend"
loading={resending}
disabled={isSubmitting}
onPress={resend}
style={styles.inline}
/>
</View>
) : null}
</Container>
<KeyboardStickyFooter style={styles.footer}>
<Button
size="lg"
label={step === "code" ? "Create account" : "Continue"}
loading={isSubmitting}
disabled={resending}
onPress={next}
/>
</KeyboardStickyFooter>
</View>
);
}
const useStyles = createStyles((t) => ({
root: { flex: 1, backgroundColor: t.colors.background },
content: { paddingHorizontal: t.spacing[6], paddingTop: t.spacing[2] },
// Back, the Progress bar and the step count, on one row as tall as the Back button.
bar: {
flexDirection: "row",
alignItems: "center",
gap: t.spacing[3],
minHeight: t.controlHeight.md,
},
// The chevron lines up with the content's edge; the tap area stays 48.
back: { marginStart: -t.spacing[3] },
progress: { flex: 1 },
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-02/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;
}
