Authentication
A passwordless sign-in that's also the sign-up, with an email or phone number and then a one-time code, on one surface.
AuthFlow signs people in with a code sent to their email or phone, and signs up the ones who are new on the same screen, with no password to make or forget. It's built from parts you can use on their own: IdentifierField for the email or phone, OneTimeCodeField for the code, and useOtpFlow for the steps without the UI. buttery never sends a code or talks to a server; your app passes in the calls that do.
How the flow goes
Who's signing in
One field takes an email address or a phone number. It tells them apart as they're typed, formats a North American number as it goes, and asks phones for the matching keyboard. The button holds back until the field is whole, and while the code is on its way it says so without changing width.
The code
The code step slides in with focus already in the code field, and says where the code went with most of it hidden. The field is one real input, so a code the phone offers from a message, a password manager's or a pasted one goes in at once. Under it are how long the code lasts, a countdown to asking for a new one, and the way back with the email or phone still filled in.
Signed in
The last digit checks the code. Accepted, the field turns the success color and a check pops in, and the session goes to your app a moment later. Turned down, the field clears and takes focus back, with a message that says what to do next.
Connecting it to your server
AuthFlow takes three calls: one that sends a code, one that checks it, and one that gets the session once a code is accepted. The first two return a result instead of throwing, so each failure can say what to do next; a call that throws counts as a connection failure.
import { AuthFlow } from "@butteryui/react";
export function SignInScreen({ api, enter }) {
return (
<AuthFlow
onRequestCode={async (identifier) => {
const response = await api.sendCode(identifier);
return response.ok
? { ok: true, expiresIn: 600 }
: {
ok: false,
reason: response.status === 429 ? "rate-limited" : "unavailable",
};
}}
onVerify={async (identifier, code) => {
const response = await api.verify(identifier, code);
return response.ok
? { ok: true, session: await response.json() }
: {
ok: false,
reason: response.status === 410 ? "expired" : "wrong",
};
}}
onSuccess={enter}
/>
);
}Send a code whether or not an account exists, and let the first verified code create one: AuthFlow never says which happened, so the screen can't be used to find out who has an account.
| Result | What it says |
|---|---|
wrong | That code isn't right. Check it and try again. |
expired | That code has expired. Send a new one. |
too-many | Too many tries. Send a new code. |
rate-limited | Too many codes sent. Try again in a minute. |
unavailable, sending | We couldn't send a code. Check your connection. |
unavailable, checking | We couldn't check the code. Check your connection. |
A result's message replaces the words for its reason, and labels changes any of the screen's own words.
Your own layout
useOtpFlow is the flow without the UI: the step, the identifier and code, sending, checking, the countdown and the errors. Draw it with IdentifierField and OneTimeCodeField when the steps need a layout of their own, such as beside a picture.
import {
Button,
IdentifierField,
OneTimeCodeField,
useOtpFlow,
} from "@butteryui/react";
import { Stack } from "@butteryui/core";
export function SideBySideSignIn({ api, enter }) {
const flow = useOtpFlow({
onRequestCode: async (identifier) => {
const response = await api.sendCode(identifier);
return response.ok
? { ok: true, expiresIn: 600 }
: { ok: false, reason: "unavailable" };
},
onVerify: async (identifier, code) => {
const response = await api.verify(identifier, code);
return response.ok
? { ok: true, session: await response.json() }
: { ok: false, reason: "wrong" };
},
onSuccess: enter,
});
if (flow.step === "identify") {
return (
<form
onSubmit={(event) => {
event.preventDefault();
void flow.sendCode();
}}
>
<Stack gap={3}>
<IdentifierField
value={flow.identifier}
onValueChange={flow.setIdentifier}
error={flow.requestError ?? undefined}
/>
<Button type="submit" variant="primary" size="cta">
Continue
</Button>
</Stack>
</form>
);
}
return (
<OneTimeCodeField
value={flow.code}
onValueChange={flow.setCode}
onComplete={flow.verify}
status={flow.status}
message={flow.codeError ?? undefined}
/>
);
}AuthFlow
When to use it
Use it for
- Signing in and signing up with a code sent by email or text, with no password
- A sign-in screen of its own, or the sign-in step of a demo or a checkout
Not for
- Signing in with an email and a password: use SignInForm
- Asking for a code somewhere other than signing in: use OneTimeCodeField
- Your own layout of the steps: use useOtpFlow with IdentifierField and OneTimeCodeField
Examples
Checkout sign in
The sign-in step of a checkout, with what's in the bag above it; the demo's code is 135790.
Two bags of Morning Fog, $32
Codes that fail
What each failure says: 111111 is wrong, 222222 has expired, and 333333 is one try too many.
Shop sign in
Signing in to a coffee shop with a code instead of a password; the demo's code is 246810.
With terms
A sign-up with the terms under its button and passkeys offered in its field, in your own words.
Variants and states
With terms
Links to the terms and the privacy policy under the button, in a small line.
Props
AuthFlow
| Prop | Type | Default | Description |
|---|---|---|---|
logo | ReactNode | A mark over the title, such as the app's logo. | |
terms | ReactNode | A small line under the first step's button, such as links to the terms and the privacy policy. | |
hint | ReactNode | Something under the code field, such as where a demo's code is. | |
passkey | boolean | false | Offers saved passkeys in the first step's field too. Defaults to false. |
titleAs | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "h1" | What the titles render as. Defaults to "h1", for a screen of its own. |
labels | Partial<AuthFlowLabels> | The words, any of them. | |
onRequestCoderequired | (identifier: string) => RequestCodeResult | Promise<RequestCodeResult> | Sends a code to identifier, an email address or a phone number as typed. | |
onVerifyrequired | (identifier: string, code: string) => VerifyResult<Session> | Promise<VerifyResult<Session>> | Checks code for identifier. | |
onSuccess | ((session: Session) => void) | Called with the session once the code is accepted and the success state has shown for successHold. | |
codeLength | number | 6 | Digits in a code. Defaults to 6. |
resendAfter | number | 30 | Seconds before a new code can be asked for. Defaults to 30. |
successHold | number | 800 | Milliseconds the success state shows before onSuccess. Defaults to 800. |
defaultIdentifier | string | "" | The identifier the field starts with, such as one the app remembers. Defaults to "". |
It also takes Omit<ComponentPropsWithRef<"div">, "title" | "children">.
AuthFlowLabels
Everything an `AuthFlow` says, for another language or another voice.
| Field | Type | Default | Description |
|---|---|---|---|
titlerequired | string | "Sign in or create an account" | The first step's title. Defaults to "Sign in or create an account". |
descriptionrequired | string | The line under it. | |
submitrequired | string | "Continue" | The first step's button. Defaults to "Continue". |
sendingrequired | string | "Sending your code…" | The button while the code is on its way. Defaults to "Sending your code…". |
emptyrequired | string | Said when the button is pressed with the field empty. | |
invalidrequired | string | Said when the button is pressed with something that isn't an email address or a phone number. | |
codeTitlerequired | (kind: "email" | "phone") => string | The code step's title, for where the code went. | |
sentTorequired | (destination: string) => string | The line under it, with where the code went, mostly hidden. | |
expiresrequired | (minutes: number) => string | The note under the code field, when the app says how long a code lasts. | |
resendrequired | string | "Send a new code" | The way to ask for another code, once it's allowed. Defaults to "Send a new code". |
resendInrequired | (seconds: number) => string | What shows until another code can be asked for. | |
backrequired | string | "Change email or phone" | The way back to the first step, the field still filled in. Defaults to "Change email or phone". |
verifyingrequired | string | "Checking the code…" | Under the code field while the code is checked. Defaults to "Checking the code…". |
successrequired | string | "You're signed in" | Under the code field once it's accepted. Defaults to "You're signed in". |
RequestCodeResult
What sending a code came to: sent, with how long it lasts, or why not.
| Field | Type | Default | Description |
|---|---|---|---|
okrequired | boolean | ||
expiresIn | number | With ok={true}: Seconds until the code stops working, for the note under the field. | |
reasonrequired | "invalid" | "rate-limited" | "unavailable" | ||
message | string | With ok={false}: What to show instead of the default message for reason. |
VerifyResult
What checking a code came to: a session to hand on, or why not.
| Field | Type | Default | Description |
|---|---|---|---|
okrequired | boolean | ||
sessionrequired | Session | ||
reasonrequired | "wrong" | "expired" | "too-many" | "unavailable" | ||
message | string | With ok={false}: What to show instead of the default message for reason. |
Also exported
| Export | Description |
|---|---|
formatIdentifier(edited: Edited): Edited | edited with a phone number formatted as it's typed and the caret after the same digit it followed, so
typing or deleting in the middle never throws the caret to the end. An email is left as written. |
identifierKind(value: string): IdentifierKind | What value looks like so far: a letter or an @ makes it an email, digits and phone marks a phone. |
maskedIdentifier(value: string): string | Where a code went, with most of it hidden: "••• ••• 0187" for a phone, "a•••@example.com" for an email, enough to recognize without showing it whole. |
validIdentifier(value: string): boolean | Whether value is an email address or a phone number a code can be sent to. |
useOtpFlow(options: OtpFlowOptions<Session>): OtpFlow | A passwordless sign-in or sign-up with a one-time code, without the UI: the step it's on, the identifier
and code, sending, checking, the resend countdown and errors that say what to do next. It never says
whether an account exists, so one flow serves signing in and signing up. AuthFlow draws it. |
Accessibility
Each step is a form with a heading. The first step's button says it can't act yet (aria-disabled) until the field holds an email address or a phone number, and pressing it then says why in the field's error. The code field takes focus the moment its step appears, and the first step's field when going back, which Escape does from the code step. Checking, success and errors are read out from the code field. It never says whether an account exists.
| Keys | What they do |
|---|---|
| Enter | Sends the code from the first step, or checks a full code |
| Escape | Goes back from the code step, the email or phone still filled in |
| Tab | Moves between the field, the button and the links |
Motion
It changes shape as one surface, on `spring.smooth`, or `spring.morph` for a big change, rather than closing and reopening.
With reduced motion: The steps swap at once, with no slide, and the height jumps; the code field's colors still change.
Layout
A column at most --bt-w-auth (360px) wide, centered, with room above it that shrinks in a short window, so with a phone's keyboard up the field and the button stay above it. The height follows the step through Morph, and the line under the code field and the resend line keep their height in every state.