Skip to content
buttery

v1.0.0

Source on GitHub

Version 1.0.0

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

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

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

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

tsx

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.

ResultWhat it says
wrongThat code isn't right. Check it and try again.
expiredThat code has expired. Send a new one.
too-manyToo many tries. Send a new code.
rate-limitedToo many codes sent. Try again in a minute.
unavailable, sendingWe couldn't send a code. Check your connection.
unavailable, checkingWe 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.

tsx

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

Sign in to pay; a new email or phone makes your account as you go.

Sign in or create an account

You'll get a code instead of a password.

Codes that fail

What each failure says: 111111 is wrong, 222222 has expired, and 333333 is one try too many.

Sign in or create an account

You'll get a code instead of a password.

Shop sign in

Signing in to a coffee shop with a code instead of a password; the demo's code is 246810.

Halfmoon

Sign in or create an account

You'll get a code instead of a password.

With terms

A sign-up with the terms under its button and passkeys offered in its field, in your own words.

Make a Deckhand account

Use your work email or phone. We'll send a code to check it's you.

By going on you agree to Deckhand's terms and privacy policy.

Variants and states

With terms

Links to the terms and the privacy policy under the button, in a small line.

Sign in or create an account

You'll get a code instead of a password.

By going on you agree to the terms and the privacy policy.

Props

AuthFlow

PropTypeDefaultDescription
logoReactNodeA mark over the title, such as the app's logo.
termsReactNodeA small line under the first step's button, such as links to the terms and the privacy policy.
hintReactNodeSomething under the code field, such as where a demo's code is.
passkeybooleanfalseOffers 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.
labelsPartial<AuthFlowLabels>The words, any of them.
onRequestCode

required

(identifier: string) => RequestCodeResult | Promise<RequestCodeResult>Sends a code to identifier, an email address or a phone number as typed.
onVerify

required

(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.
codeLengthnumber6Digits in a code. Defaults to 6.
resendAfternumber30Seconds before a new code can be asked for. Defaults to 30.
successHoldnumber800Milliseconds the success state shows before onSuccess. Defaults to 800.
defaultIdentifierstring""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.

FieldTypeDefaultDescription
title

required

string"Sign in or create an account"The first step's title. Defaults to "Sign in or create an account".
description

required

stringThe line under it.
submit

required

string"Continue"The first step's button. Defaults to "Continue".
sending

required

string"Sending your code…"The button while the code is on its way. Defaults to "Sending your code…".
empty

required

stringSaid when the button is pressed with the field empty.
invalid

required

stringSaid when the button is pressed with something that isn't an email address or a phone number.
codeTitle

required

(kind: "email" | "phone") => stringThe code step's title, for where the code went.
sentTo

required

(destination: string) => stringThe line under it, with where the code went, mostly hidden.
expires

required

(minutes: number) => stringThe note under the code field, when the app says how long a code lasts.
resend

required

string"Send a new code"The way to ask for another code, once it's allowed. Defaults to "Send a new code".
resendIn

required

(seconds: number) => stringWhat shows until another code can be asked for.
back

required

string"Change email or phone"The way back to the first step, the field still filled in. Defaults to "Change email or phone".
verifying

required

string"Checking the code…"Under the code field while the code is checked. Defaults to "Checking the code…".
success

required

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.

FieldTypeDefaultDescription
ok

required

boolean
expiresInnumberWith ok={true}: Seconds until the code stops working, for the note under the field.
reason

required

"invalid" | "rate-limited" | "unavailable"
messagestringWith 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.

FieldTypeDefaultDescription
ok

required

boolean
session

required

Session
reason

required

"wrong" | "expired" | "too-many" | "unavailable"
messagestringWith ok={false}: What to show instead of the default message for reason.

Also exported

ExportDescription
formatIdentifier(edited: Edited): Editededited 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): IdentifierKindWhat value looks like so far: a letter or an @ makes it an email, digits and phone marks a phone.
maskedIdentifier(value: string): stringWhere 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): booleanWhether value is an email address or a phone number a code can be sent to.
useOtpFlow(options: OtpFlowOptions<Session>): OtpFlowA 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.

KeysWhat they do
EnterSends the code from the first step, or checks a full code
EscapeGoes back from the code step, the email or phone still filled in
TabMoves 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.

  • Forms

    Sign-in and sign-up forms that check as you go and sum up errors where they can be fixed.

  • TextField

    A labelled box for a line of text, with a hint, an error and optional icons.