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
onSubmitas a message or a promise of one.SignInFormandSignUpFormshow 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
FormFieldCaptiontakeshtmlFor,label,hint,hintIdand an optionallabelId, and renders the top of every buttery field: the label and, under it, the hint, when there is one.labelIdis for a control named througharia-labelledby, as theSelectbelow is.The error
FormFieldErrortakesid,name,announceand 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. Withannounce, a new error is also read out politely withnamein 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:
| Property | What it is |
|---|---|
submit | The handler for the form's onSubmit |
errorFor(field) | A field's own error, if it needs fixing |
reported | The fields that needed fixing at the last submit, for the summary |
failure | The message from a submit that didn't work, for the summary |
pending | Whether onSubmit is running |
problems | The fields that need fixing now, once a submit has been tried |
summary | The summary's ref, which takes focus when the summary appears |
Three more parts finish the form:
The error summary
AuthErrorSummarytakes atitleand theformfromuseAuthForm, and renders the summary over the form: a danger callout that's read out, with a link to each field inform.reportedthat moves focus to it.Other ways in
AuthProviderstakesor, the word on its rule, andproviders, 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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | The 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. |
description | ReactNode | A line under the heading. | |
onSubmitrequired | 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. | |
forgotPasswordHref | string | Where "Forgot your password?" goes. Left out, the link isn't shown. | |
signUpHref | string | Where "Create an account" goes. Left out, the line isn't shown. | |
providers | readonly AuthProvider[] | Other ways to sign in, as buttons under the form, named by their text. | |
labels | Partial<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`.
| Field | Type | Default | Description |
|---|---|---|---|
emailrequired | string | The email address, trimmed. | |
passwordrequired | string | The password, as typed. | |
rememberrequired | boolean | Whether 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.
| Field | Type | Default | Description |
|---|---|---|---|
idrequired | string | Identifies it among the others. | |
labelrequired | string | What the button says, such as "Continue with a passkey". | |
icon | ComponentType<IconComponentProps> | A generic icon for it, such as KeyRound from lucide-react. Never someone's brand mark. | |
onSelectrequired | () => void | Runs when the button is pressed. |
SignInLabels
The words a sign-in form shows, for other languages.
| Field | Type | Default | Description |
|---|---|---|---|
titlerequired | string | The heading, when title isn't given. | |
emailrequired | string | The email field's label. | |
passwordrequired | string | The password field's label. | |
showPasswordrequired | string | Names the button that shows the password. | |
rememberrequired | string | The keep-me-signed-in box. | |
submitrequired | string | The submit button. | |
forgotPasswordrequired | string | The link to forgotPasswordHref. | |
noAccountrequired | string | The words before the sign-up link. | |
signUprequired | string | The link to signUpHref. | |
orrequired | string | The word on the rule over the other ways to sign in. | |
problemrequired | string | The heading of the error summary. | |
submitFailedrequired | string | When onSubmit throws or rejects, such as when the network is down. | |
emailMissingrequired | string | When the email is empty. | |
emailInvalidrequired | string | When the email doesn't look like an email address. | |
passwordMissingrequired | string | When 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.
| Keys | What they do |
|---|---|
| Enter | Submits 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.
Related
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
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | The 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. |
description | ReactNode | A line under the heading. | |
onSubmitrequired | 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". | |
passwordRules | readonly 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. | |
termsHref | string | Where the terms link goes. Left out, the terms box isn't shown. | |
signInHref | string | Where "Sign in" goes. Left out, the line isn't shown. | |
providers | readonly AuthProvider[] | Other ways to sign up, as buttons under the form, named by their text. | |
labels | Partial<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`.
| Field | Type | Default | Description |
|---|---|---|---|
namerequired | string | The name, trimmed. | |
emailrequired | string | The email address, trimmed. | |
passwordrequired | string | The new password, as typed. |
PasswordRule
A rule a new password has to meet: what it says, and a check for it.
| Field | Type | Default | Description |
|---|---|---|---|
labelrequired | string | The rule, such as "At least 8 characters". | |
testrequired | (password: string) => boolean | Whether a password meets it. |
AuthProvider
Another way to sign in or sign up: a button under the form, named by its text alone.
| Field | Type | Default | Description |
|---|---|---|---|
idrequired | string | Identifies it among the others. | |
labelrequired | string | What the button says, such as "Continue with a passkey". | |
icon | ComponentType<IconComponentProps> | A generic icon for it, such as KeyRound from lucide-react. Never someone's brand mark. | |
onSelectrequired | () => void | Runs when the button is pressed. |
SignUpLabels
The words a sign-up form shows, for other languages.
| Field | Type | Default | Description |
|---|---|---|---|
titlerequired | string | The heading, when title isn't given. | |
namerequired | string | The name field's label. | |
emailrequired | string | The email field's label. | |
passwordrequired | string | The password field's label. | |
showPasswordrequired | string | Names the button that shows the password. | |
metrequired | string | Read beside a password rule that's met. | |
unmetrequired | string | Read beside a password rule that isn't met yet. | |
termsrequired | string | The terms box, before the link to termsHref. | |
termsLinkrequired | string | The link to the terms. | |
submitrequired | string | The submit button. | |
haveAccountrequired | string | The words before the sign-in link. | |
signInrequired | string | The link to signInHref. | |
orrequired | string | The word on the rule over the other ways to sign up. | |
problemrequired | string | The heading of the error summary. | |
submitFailedrequired | string | When onSubmit throws or rejects, such as when the network is down. | |
nameMissingrequired | string | When the name is empty. | |
emailMissingrequired | string | When the email is empty. | |
emailInvalidrequired | string | When the email doesn't look like an email address. | |
passwordWeakrequired | string | When the password misses a rule. | |
termsMissingrequired | string | When the terms box isn't ticked. |
Also exported
| Export | Description |
|---|---|
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).
| Keys | What they do |
|---|---|
| Enter | Submits 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.
Related
See it in the Halfmoon shop demo.
Related
- 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.