Sign-in flow
StableThe whole front door in one component: email first, password or passkey, GitHub or Google, sign-up with a code, and recovery.
SignInFlow is a small state machine over eleven steps, drawn inside AuthLayout. It talks to your server through one interface, AuthApi; until you write yours it runs on demoAuthApi, which answers after a short pause and sends nothing anywhere. Each step has its own heading that takes focus when the flow moves on, checks its fields on submit, and words every problem the GOV.UK way.
Try the demo account
import { useState } from 'react'import { DEMO_ACCOUNT, SignInFlow, type AuthLink } from '@rchursin/product/forms'import { useAnnounce } from '@rchursin/product/lib/announce'import { Button, useToast } from '@rchursin/product/ui'
/** Sample footer links: '#' addresses, so nothing here points at pages this site doesn't have */const demoLinks: AuthLink[] = [ { label: 'Terms', href: '#terms' }, { label: 'Privacy', href: '#privacy' },]
/** * The whole front door on the demo API: nothing leaves the page. Any email works; the password * and the code are in the strip, and the flow shows them again on the steps that need them. */export default function SignInFlowDemo() { const [run, setRun] = useState(0) const announce = useAnnounce() const toast = useToast()
Usage
import { SignInFlow, demoAuthApi, DEMO_ACCOUNT, flowSteps, type AuthApi } from '@rchursin/product/forms'<SignInFlow api={myAuthApi} as="main" headingLevel={1} className="min-h-dvh" onTitleChange={(title) => (document.title = title)} />Examples
Steps from outside
Pick a step: step and onStepChange keep it in your own state (or your router), and onTitleChange returns the page title. Press Sign in with an empty password and the title gains “Error: ”.
import { useState } from 'react'import { DEMO_ACCOUNT, flowSteps, SignInFlow, type AuthLink, type FlowStep } from '@rchursin/product/forms'import { Field, Select, useToast } from '@rchursin/product/ui'
const groups = ['Sign in', 'Sign up', 'Recovery'] as const
/** Sample footer links: '#' addresses, so nothing here points at pages this site doesn't have */const demoLinks: AuthLink[] = [ { label: 'Terms', href: '#terms' }, { label: 'Privacy', href: '#privacy' },]
/** * Drive the flow from outside: `step` + `onStepChange` keep it in your state (or your router), * and `onTitleChange` hands you each step's page title, with "Error: " in front while it shows errors. */export default function SignInFlowSteps() { const [step, setStep] = useState<FlowStep>('password') const [title, setTitle] = useState('Sign in – RChursin_') const toast = useToast()Your own API
The flow only talks to api. This one is the demo API with every call logged, and a passkey prompt that always comes back canceled: press Sign in with a passkey and the flow stays on the step and announces it.
import { useId, useMemo, useState } from 'react'import { DEMO_ACCOUNT, demoAuthApi, SignInFlow, type AuthApi, type AuthLink } from '@rchursin/product/forms'import { useToast } from '@rchursin/product/ui'
/** Sample footer links: '#' addresses, so nothing here points at pages this site doesn't have */const demoLinks: AuthLink[] = [ { label: 'Terms', href: '#terms' }, { label: 'Privacy', href: '#privacy' },]
/** Wrap every method so each call lands in the log (results only: never log what people type) */function withLog(api: AuthApi, write: (line: string) => void): AuthApi { const methods = Object.entries(api).map(([name, call]) => [ name, async (...args: unknown[]) => { const result = await call(...args) write(`${name} → ${result ?? 'done'}`) return result }, ])API
SignInFlow props
| Prop | Type | Default | Description |
|---|---|---|---|
api | AuthApi | demoAuthApi(demo) | Your backend. Without it the flow runs on the demo API: nothing leaves the page. |
step | FlowStep | – | Controlled step. A step set from outside opens clean (no notice, no errors) and leaves focus where it is. |
onStepChange | (step: FlowStep) => void | – | Called when the flow moves on by itself (Continue, Change, Forgot password? …). |
headingLevel | 1 | 2 | 3 | 1 | Level of each step’s heading: 1 when the flow is the page, lower when it sits under other headings. |
demo | DemoAccount | – | Shows dashed “Demo” notes with the password, the code and a link to open the reset page, and supplies the email for steps opened directly. Leave it out in production. |
onTitleChange | (title: string) => void | – | Receives “Sign in – RChursin_” and so on for each step, with “Error: ” in front while the step shows errors (GOV.UK). Set document.title with it. |
onSignedIn | () => void | – | Adds an “Open account settings” button to the signed-in step. |
links | AuthLink[] | Terms, Privacy | Footer links, passed to AuthLayout. |
onLinkClick | (link: AuthLink, e: MouseEvent<HTMLAnchorElement>) => void | – | Intercept the footer links, e.g. to route them yourself. |
as | 'main' | 'div' | 'div' | Use "main" when the flow is the whole page; only the form column becomes the <main>. |
className | string | – | Classes on the outer frame. Give it a min-height (min-h-dvh on a page): the columns inside inherit it. |
AuthApi (implement it for your backend)
| Prop | Type | Default | Description |
|---|---|---|---|
identifyrequired | (email: string) => Promise<void> | – | Identifier first: look the email up. Real apps branch here to a passkey, a password or SSO. |
signInWithPasswordrequired | (email: string, password: string) => Promise<'ok' | 'incorrect'> | – | “incorrect” shows “Email or password is incorrect” and keeps what was typed. |
signInWithPasskeyrequired | () => Promise<'ok' | 'canceled'> | – | navigator.credentials.get({ publicKey }) in real life. “canceled” keeps the step and announces it. |
signInWithProviderrequired | (provider: Provider) => Promise<'ok'> | – | GitHub or Google. In real life this usually redirects. |
signUprequired | (account: { name: string; email: string; password: string }) => Promise<'ok'> | – | Create the account and send the email code. |
verifyEmailrequired | (email: string, code: string) => Promise<'ok' | 'incorrect'> | – | Check the 6-digit code. |
resendCoderequired | (email: string) => Promise<void> | – | Send a new code. The button waits 30 seconds between sends. |
requestPasswordResetrequired | (email: string) => Promise<void> | – | Send the reset link. Answer the same way whether or not the account exists; the page does too. |
resetPasswordrequired | (password: string) => Promise<'ok'> | – | Save the new password (and sign out other devices, as the done step says). |
createPasskeyrequired | () => Promise<'ok' | 'canceled'> | – | navigator.credentials.create({ publicKey }) in real life. |
demoAuthApi(account, delay)
| Prop | Type | Default | Description |
|---|---|---|---|
account | DemoAccount | DEMO_ACCOUNT | The password and code it accepts. Every other call succeeds. |
delay | number | 700 | Milliseconds before each answer, so loading states show. Passkey and provider calls wait 1.4 to 1.8 times longer. |
DemoAccount (DEMO_ACCOUNT)
| Prop | Type | Default | Description |
|---|---|---|---|
emailrequired | string | – | Shown on steps opened directly, before anyone typed an email. DEMO_ACCOUNT: alex@example.com. |
passwordrequired | string | – | The only password the demo accepts. DEMO_ACCOUNT: quiet-graphite-lamp. |
coderequired | string | – | The only email code the demo accepts. DEMO_ACCOUNT: 424242. |
flowSteps (one entry per step)
| Prop | Type | Default | Description |
|---|---|---|---|
idrequired | FlowStep | – | email, password, passkey-offer, passkey-done, signed-in, sign-up, verify, forgot, check-email, reset or reset-done. |
grouprequired | 'Sign in' | 'Sign up' | 'Recovery' | – | For grouping a step list. |
labelrequired | string | – | Short name for step lists: “Verify email”. |
titlerequired | string | – | Page title without the product name: “Verify your email”. |
pathrequired | string | – | Where the page would live: “/sign-up/verify”. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| TabShiftTab | Moves through the step. When the flow moves on, focus starts on the new step’s heading. |
| Enter | In a field: submits the step (Continue, Sign in, Create account, Verify email…). If a field needs fixing, focus goes to it. |
| EnterSpace | Presses the focused button: Change, Show, Forgot password?, a provider, Not now. |
| CtrlV | Pastes into any field, including the whole email code (“424 242” or “424-242” work). The sixth digit checks the code by itself. |
- Each step has one heading at headingLevel with tabindex="-1". When the flow moves on by itself, focus moves to that heading, so screen readers read the new step first and keyboard users start at its top.
- Fields are checked on submit. The errors are rendered first (flushSync), then focus moves to the first field that needs fixing, so it is read with its message. If focus is already there, the message is announced instead (“Error: Enter your email address”).
- A wrong password says “Email or password is incorrect”, never which half was wrong, and keeps what was typed. The reset request says the same words whether or not the account exists.
- Busy buttons keep focus and set aria-busy. While a passkey prompt is open, a status line says “Follow the prompt from your browser or device.”; a canceled passkey is announced.
- “Send a new code” counts down for 30 seconds with aria-disabled, not disabled, so focus never drops off it.
- Autocomplete is set for password managers: username webauthn on the first email field (ready for passkey autofill), a hidden username field beside each password, current-password to sign in, new-password to sign up and reset, one-time-code for the code.
- Announcements go through AnnouncerProvider: wrap your app in it once.
Guidelines
Do
- Use as="main", headingLevel={1} and a min-height when the flow is the page, and set document.title from onTitleChange.
- Implement AuthApi against your backend and leave the steps alone: the wording, focus and timing already follow GOV.UK and NIST SP 800-63B-4.
- Start passkey autofill (conditional mediation) on the email step where the browser supports it; the field is already marked for it.
Don’t
- Don’t ship the demo prop to real people: it prints the password and the code.
- Don’t say which part of a sign-in was wrong, and don’t clear the password after a miss.
- Don’t add a confirm-password field or composition rules; Show password and the length and blocklist checks do that job.
In use
Related
- Auth layoutThe frame for every sign-in page: a dark brand panel when there is room, one calm form column, the logo on top and legal links below.
- Social buttons“Continue with GitHub” and “Continue with Google” at equal weight, with a waiting state while the provider answers.
- Password strengthAn advisory meter that guesses like an attacker, plus the blocklist check. Four grey segments and a word; it never blocks.
- Form toolkituseForm for values, errors and validation timing, plus the checks and GOV.UK-style messages every form in the kit shares.
- Password inputA password field with a Show / Hide button and a caps-lock warning, where paste and password managers always work.
- One-time codeA one-time code field: one real input painted as six slots, so paste, autofill and screen readers just work.
- Account settingsThe in-app settings form: profile with a picture and time zone, a password change, passkeys and sessions, with an error summary on save.
- Screen-reader announcementsuseAnnounce makes screen readers say a result like “Moved to Done”, politely or, for urgent things, at once. Nothing shows on screen.
Source
The component’s own files, exactly as they run on this site.
/** * The whole front door as one small state machine: sign in (email first, then password, with * passkey and SSO alternatives), the passkey prompt, sign up with an emailed code, and password * recovery. * * <SignInFlow api={myAuthApi} headingLevel={1} onTitleChange={(t) => (document.title = t)} /> * * email ─▶ password ─▶ passkey-offer ─▶ passkey-done ─▶ signed-in * │ └─(passkey / GitHub / Google)───────────────────────▲ * ├─▶ sign-up ─▶ verify ─▶ passkey-offer │ * └─▶ forgot ─▶ check-email ─▶ reset ─▶ reset-done ─▶ email * * Each step has its own heading. When the flow moves on, focus moves to that heading, so * screen readers announce the new step and keyboard users start from the top of it. * Pass `step` + `onStepChange` to control it from outside (the kit page's step switcher does); * jumps from outside keep focus where it is. */import { useCallback, useEffect,