Skip to content
buttery

v1.0.0

Source on GitHub

Version 1.0.0

Forms

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

The form patterns are complete sign-in and sign-up forms: the fields at their large size, a full-width submit, other ways in, and errors summed up at the top with links to the fields that have them. Use them as they are for an app's account screens, and their parts as a model for any form.

How a buttery form behaves

  • Labels sit above their fields

    Every field is labelled above, with a hint under the label when the format matters.

  • A field checks itself as it's left

    Fields check themselves when a person leaves them, not on every key.

  • Errors are summed up at the top

    Submitting a form with errors puts a summary at its top, says how many there are and moves focus to it. Each item links to its field, and the summary holds still until the next submit.

  • Your server's errors show too

    An error from your server, such as a wrong password, comes back from onSubmit as a message or a promise of one. SignInForm and SignUpForm show it over the form, the button busy until it settles. Return nothing when it worked.

Parts for forms of your own

The forms are built from parts you can use in a form of your own: three for each field, and four for the form around them.

Field parts

useFormFieldIds({ id, describedBy, hint, error }): FormFieldIds makes the ids that tie a field together. id and describedBy are the control's own id and aria-describedby, or undefined, and hint and error say whether each one is showing.

It returns control, the control's id (its own id when it has one), label, hint, error, and describedBy, which joins the control's own description with the hint and the error while they show.

Two components draw the field with those ids:

  • The label and the hint

    FormFieldCaption takes htmlFor, label, hint, hintId and an optional labelId, and renders the top of every buttery field: the label and, under it, the hint, when there is one. labelId is for a control named through aria-labelledby, as the Select below is.

  • The error

    FormFieldError takes id, name, announce and the error as its children, and renders the error under the control with an icon, so it doesn't rest on color alone, or nothing while there's no error. With announce, a new error is also read out politely with name in front, as in "Email: Enter an email address"; turn it off when an error summary reads the errors.

Laying out a field. An element with the bt-form-field class lays out the caption, the control and the error 8px apart, as buttery's own fields are. For one control, Field does all of this in one component.

Form parts

useAuthForm(values, check, onSubmit, failed): AuthForm runs a form's submit. check(values) returns the fields that need fixing, each a { field, message } whose field is its control's id. The hook either shows them, with focus on the summary, or passes the values to onSubmit and shows the message it returns, if any.

When onSubmit throws, the hook shows failed and hands the error to reportError, so error tracking still sees it. The AuthForm it returns holds:

PropertyWhat it is
submitThe handler for the form's onSubmit
errorFor(field)A field's own error, if it needs fixing
reportedThe fields that needed fixing at the last submit, for the summary
failureThe message from a submit that didn't work, for the summary
pendingWhether onSubmit is running
problemsThe fields that need fixing now, once a submit has been tried
summaryThe summary's ref, which takes focus when the summary appears

Three more parts finish the form:

  • The error summary

    AuthErrorSummary takes a title and the form from useAuthForm, and renders the summary over the form: a danger callout that's read out, with a link to each field in form.reported that moves focus to it.

  • Other ways in

    AuthProviders takes or, the word on its rule, and providers, and lists other ways in under the rule: a full-width soft button for each, with a generic icon, never someone's brand mark.

  • The email check

    looksLikeEmail(value) is the check the forms use for an email address: something, an @, then something with a dot.

Putting them together

Here they make a small wholesale request form. The email is a TextField, which puts the same parts together itself, and the delivery day is a field of your own around a Select, in an element with the bt-form-field class.

src/WholesaleRequest.tsx

import { useId, useState } from "react";
import type { ReactElement } from "react";
import { Stack, Text } from "@butteryui/core";
import {
  AuthErrorSummary,
  Button,
  FormFieldCaption,
  FormFieldError,
  Select,
  TextField,
  looksLikeEmail,
  useAuthForm,
  useFormFieldIds,
} from "@butteryui/react";

type Day = "tuesday" | "friday";

const DAYS = [
  { value: "tuesday", label: "Tuesday" },
  { value: "friday", label: "Friday" },
] as const;

export function WholesaleRequest(): ReactElement {
  const emailId = useId();
  const dayId = useId();
  const [email, setEmail] = useState("");
  const [day, setDay] = useState<Day | undefined>(undefined);
  const [sent, setSent] = useState("");
  const form = useAuthForm(
    { email: email.trim(), day },
    (values) => [
      ...(looksLikeEmail(values.email)
        ? []
        : [
            { field: emailId, message: "Enter an email like name@example.com" },
          ]),
      ...(values.day === undefined
        ? [{ field: dayId, message: "Choose a delivery day" }]
        : []),
    ],
    (values) => {
      setSent(values.email);
      return undefined;
    },
    "Couldn't send the request. Try again in a moment.",
  );
  const dayError = form.errorFor(dayId);
  const ids = useFormFieldIds({
    id: dayId,
    describedBy: undefined,
    hint: true,
    error: dayError !== undefined,
  });
  return (
    <form noValidate onSubmit={form.submit}>
      <Stack gap={5}>
        <AuthErrorSummary title="Check these fields" form={form} />
        <TextField
          id={emailId}
          label="Café email"
          type="email"
          autoComplete="email"
          value={email}
          onValueChange={setEmail}
          error={form.errorFor(emailId)}
          announceError={false}
        />
        <div className="bt-form-field">
          <FormFieldCaption
            htmlFor={ids.control}
            labelId={ids.label}
            label="Delivery day"
            hint="We roast the day before."
            hintId={ids.hint}
          />
          <Select<Day>
            id={ids.control}
            aria-labelledby={ids.label}
            aria-describedby={ids.describedBy}
            aria-invalid={dayError === undefined ? undefined : true}
            label="Delivery day"
            variant="outline"
            placeholder="Choose a day"
            options={DAYS}
            value={day}
            onValueChange={setDay}
          />
          <FormFieldError id={ids.error} name="Delivery day" announce={false}>
            {dayError}
          </FormFieldError>
        </div>
        <Button type="submit" variant="primary" loading={form.pending}>
          Send request
        </Button>
        {sent === "" ? null : (
          <Text variant="body-sm" tone="secondary">
            {`We'll write to ${sent} within a day.`}
          </Text>
        )}
      </Stack>
    </form>
  );
}

SignInForm

When to use it

Use it for

  • A sign-in page or dialog: pass onSubmit and, if you have them, the forgot-password and sign-up links

Not for

  • Creating an account: use SignUpForm
  • Signing in without a password, with a code sent by email or text: use AuthFlow
  • A one-time code step: use OneTimeCodeField

Examples

Sign in card

A sign-in page's card: the form with a passkey and an emailed link as other ways in, and what each one did.

Sign in page

A sign-in page's form, handing its values to your own sign-in call and showing what it answers.

Translated sign in

A sign-in form in German, through its labels.

Variants and states

Deckhand sign-in

Submit it empty to see the summary and the field errors; sam@example.com with any 8-character password signs in.

Props

SignInForm

PropTypeDefaultDescription
titlestringThe heading over the form. Defaults to "Sign in".
titleAs"h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p""h1"What the heading renders as. Defaults to "h1", for a page of its own.
descriptionReactNodeA line under the heading.
onSubmit

required

AuthSubmit<SignInValues>Called with the values once they pass the form's checks. Return a message, or a promise of one, to show it over the form; the button shows it's busy until the promise settles.
forgotPasswordHrefstringWhere "Forgot your password?" goes. Left out, the link isn't shown.
signUpHrefstringWhere "Create an account" goes. Left out, the line isn't shown.
providersreadonly AuthProvider[]Other ways to sign in, as buttons under the form, named by their text.
labelsPartial<SignInLabels>Words to use instead of the English ones.

It also takes Omit<ComponentPropsWithRef<"form">, "onSubmit" | "title" | "children">.

SignInValues

What a sign-in form hands `onSubmit`.

FieldTypeDefaultDescription
email

required

stringThe email address, trimmed.
password

required

stringThe password, as typed.
remember

required

booleanWhether to keep the person signed in.

AuthProvider

Another way to sign in or sign up: a button under the form, named by its text alone.

FieldTypeDefaultDescription
id

required

stringIdentifies it among the others.
label

required

stringWhat the button says, such as "Continue with a passkey".
iconComponentType<IconComponentProps>A generic icon for it, such as KeyRound from lucide-react. Never someone's brand mark.
onSelect

required

() => voidRuns when the button is pressed.

SignInLabels

The words a sign-in form shows, for other languages.

FieldTypeDefaultDescription
title

required

stringThe heading, when title isn't given.
email

required

stringThe email field's label.
password

required

stringThe password field's label.
showPassword

required

stringNames the button that shows the password.
remember

required

stringThe keep-me-signed-in box.
submit

required

stringThe submit button.
forgotPassword

required

stringThe link to forgotPasswordHref.
noAccount

required

stringThe words before the sign-up link.
signUp

required

stringThe link to signUpHref.
or

required

stringThe word on the rule over the other ways to sign in.
problem

required

stringThe heading of the error summary.
submitFailed

required

stringWhen onSubmit throws or rejects, such as when the network is down.
emailMissing

required

stringWhen the email is empty.
emailInvalid

required

stringWhen the email doesn't look like an email address.
passwordMissing

required

stringWhen the password is empty.

Accessibility

A named form with a real heading. Fields fill in from the password manager (username on the email field, current-password). On a failed submit, each field shows what to fix and a summary with a link to each one appears and takes focus (role alert); a message returned from onSubmit shows there too. The submit button keeps its width while busy. Other ways to sign in are buttons named by their text.

KeysWhat they do
EnterSubmits the form from the email or password field

Motion

It doesn't move.

With reduced motion: Nothing changes.

Layout

It fills its parent's width up to 400px, its parts 20px apart in a column: fields --bt-h-cta (48px) tall, a full-width submit button as tall that keeps its width while busy, and the other ways in as full-width buttons under an "or" rule. Errors wrap under their fields, and the forgot-password link has a 24px target.

See it in the Halfmoon shop demo.

SignUpForm

When to use it

Use it for

  • Creating an account: pass onSubmit, and the terms and sign-in links if you have them

Not for

  • Signing in: use SignInForm
  • Changing a password: use PasswordField with autoComplete="new-password" and its requirements

Examples

Account tabs

An account page's tabs: sign in under one, create an account under the other.

Sign up page

A sign-up page's form with the terms, handing its values to your own call and greeting the new account.

Stricter password

A sign-up form that adds a rule to buttery's password rules.

Variants and states

Deckhand sign-up

The password's rules tick off as you type; submit it empty to see the summary.

Props

SignUpForm

PropTypeDefaultDescription
titlestringThe heading over the form. Defaults to "Create your account".
titleAs"h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p""h1"What the heading renders as. Defaults to "h1", for a page of its own.
descriptionReactNodeA line under the heading.
onSubmit

required

AuthSubmit<SignUpValues>Called with the values once they pass the form's checks and the terms are agreed to. Return a message, or a promise of one, to show it over the form, such as "There's already an account for that email".
passwordRulesreadonly PasswordRule[]The rules a password has to meet, listed under it, each a { label, test }. They replace the defaults, 8 characters with a number: to add one, spread defaultPasswordRules first.
termsHrefstringWhere the terms link goes. Left out, the terms box isn't shown.
signInHrefstringWhere "Sign in" goes. Left out, the line isn't shown.
providersreadonly AuthProvider[]Other ways to sign up, as buttons under the form, named by their text.
labelsPartial<SignUpLabels>Words to use instead of the English ones.

It also takes Omit<ComponentPropsWithRef<"form">, "onSubmit" | "title" | "children">.

SignUpValues

What a sign-up form hands `onSubmit`.

FieldTypeDefaultDescription
name

required

stringThe name, trimmed.
email

required

stringThe email address, trimmed.
password

required

stringThe new password, as typed.

PasswordRule

A rule a new password has to meet: what it says, and a check for it.

FieldTypeDefaultDescription
label

required

stringThe rule, such as "At least 8 characters".
test

required

(password: string) => booleanWhether a password meets it.

AuthProvider

Another way to sign in or sign up: a button under the form, named by its text alone.

FieldTypeDefaultDescription
id

required

stringIdentifies it among the others.
label

required

stringWhat the button says, such as "Continue with a passkey".
iconComponentType<IconComponentProps>A generic icon for it, such as KeyRound from lucide-react. Never someone's brand mark.
onSelect

required

() => voidRuns when the button is pressed.

SignUpLabels

The words a sign-up form shows, for other languages.

FieldTypeDefaultDescription
title

required

stringThe heading, when title isn't given.
name

required

stringThe name field's label.
email

required

stringThe email field's label.
password

required

stringThe password field's label.
showPassword

required

stringNames the button that shows the password.
met

required

stringRead beside a password rule that's met.
unmet

required

stringRead beside a password rule that isn't met yet.
terms

required

stringThe terms box, before the link to termsHref.
termsLink

required

stringThe link to the terms.
submit

required

stringThe submit button.
haveAccount

required

stringThe words before the sign-in link.
signIn

required

stringThe link to signInHref.
or

required

stringThe word on the rule over the other ways to sign up.
problem

required

stringThe heading of the error summary.
submitFailed

required

stringWhen onSubmit throws or rejects, such as when the network is down.
nameMissing

required

stringWhen the name is empty.
emailMissing

required

stringWhen the email is empty.
emailInvalid

required

stringWhen the email doesn't look like an email address.
passwordWeak

required

stringWhen the password misses a rule.
termsMissing

required

stringWhen the terms box isn't ticked.

Also exported

ExportDescription
defaultPasswordRules: readonly PasswordRule[]buttery's default password rules: at least 8 characters, with a number.

Accessibility

A named form with a real heading. The email field is the username for password managers, and the password asks for a new password (new-password) and lists its rules, each ticked as it's met, as part of its description. On a failed submit, each field shows what to fix and a summary with a link to each one appears and takes focus (role alert).

KeysWhat they do
EnterSubmits the form from the name, email or password field

Motion

It doesn't move.

With reduced motion: Nothing changes.

Layout

It fills its parent's width up to 400px, its parts 20px apart in a column: fields --bt-h-cta (48px) tall, the password's rules listed under it, the terms box, a full-width submit button as tall that keeps its width while busy, and the other ways in as full-width buttons under an "or" rule. Labels and errors wrap rather than truncate.

See it in the Halfmoon shop demo.

  • TextField

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

  • PasswordField

    A labelled password box with a button that shows or hides what's typed, and optional requirements.