Text and fields
Field
Give a control a label, a description and an error, stacked above it or beside it.
import { Field, FieldDescription, FieldLabel } from "macbetter/field"
import { TextField } from "macbetter/text-field"
export default function Example() {
return (
<div style={{ width: "100%", maxWidth: 320 }}>
<Field name="display-name">
<FieldLabel>Display name</FieldLabel>
<TextField defaultValue="Hollis Grant" autoComplete="name" />
<FieldDescription>Shown to people you share boards with.</FieldDescription>
</Field>
</div>
)
}When to use it
Wrap every form control in a field. The field connects the label, the description and any error to the control for you, so a click on the label focuses it and a screen reader hears all three.
- Stacked puts the label above the control. It's the default, and it suits narrow layouts, iOS forms and sign-in screens.
- Horizontal puts the label beside the control, right-aligned in its own column, the way macOS forms, settings panes and inspectors do it. On macOS, end each label with a colon ("Name:").
- Keep descriptions short and factual. Show an error only after someone tries to submit, and say how to fix it.
Usage
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
Label,
} from "macbetter/field"Side by side
Put fields in a FieldGroup with layout="horizontal" and every label lines up in one column, sized to the longest. A single field can take layout="horizontal" on its own. A field with layout="stacked" inside a horizontal group sits in the control column, for a checkbox or a note that needs no label of its own.
import { Field, FieldDescription, FieldGroup, FieldLabel } from "macbetter/field"
import { SecureField } from "macbetter/secure-field"
import { TextField } from "macbetter/text-field"
export default function Example() {
return (
<FieldGroup layout="horizontal" style={{ width: "100%", maxWidth: 420 }}>
<Field name="account-name">
<FieldLabel>Name:</FieldLabel>
<TextField defaultValue="Hollis Grant" autoComplete="name" />
</Field>
<Field name="email">
<FieldLabel>Email Address:</FieldLabel>
<TextField type="email" placeholder="name@example.com" autoComplete="email" />
</Field>
<Field name="password">
<FieldLabel>Password:</FieldLabel>
<SecureField placeholder="Required" autoComplete="current-password" />
<FieldDescription>Stored in your keychain.</FieldDescription>
</Field>
</FieldGroup>
)
}Validation
Native constraints like required and type="email" work as they do in HTML. Errors stay hidden until a submit, then follow the value as it changes. Give each FieldError a match to write your own message for one problem, or pass invalid to the field when your server decides.
"use client"
import { Button } from "macbetter/button"
import { Field, FieldError, FieldLabel } from "macbetter/field"
import { TextField } from "macbetter/text-field"
export default function Example() {
return (
<form
style={{ display: "flex", flexDirection: "column", gap: 14, width: "100%", maxWidth: 320 }}
onSubmit={(event) => event.preventDefault()}
>
<Field name="invite">
<FieldLabel>Invite by email</FieldLabel>
<TextField type="email" required placeholder="name@example.com" />
<FieldError match="valueMissing">Enter an email address.</FieldError>
<FieldError match="typeMismatch">Enter an email address like name@example.com.</FieldError>
</Field>
<Button type="submit" variant="filled" style={{ alignSelf: "flex-end" }}>
Send Invite
</Button>
</form>
)
}Groups
FieldSet and FieldLegend group related fields under a heading and disable them together. Label names a control that isn't inside a field, pointed at it with htmlFor.
Platform behavior
- macOS. Labels are 13-point regular text in the primary label color. Horizontal labels are right-aligned and centered on the control's first line; descriptions are 11-point secondary text under the control.
- iPadOS and iOS. Stacked labels are footnote-sized secondary text, inset to line up with the text inside the field, like a grouped list's header. Horizontal labels are left-aligned, as in a Contacts edit row. Descriptions are footnotes.
- Every platform. Error text uses the destructive color and stays 4.5:1 against the background in light and dark.
Accessibility
- The label names the control through
aria-labelledbyand a nativefor, so clicking it focuses the control or toggles a checkbox. - The description and any shown error describe the control through
aria-describedby; an invalid control getsaria-invalid. - After a failed submit, the first invalid control takes focus.
API
Field
| Prop | Type | Default | Description |
|---|---|---|---|
layout | "stacked" | "horizontal" | "stacked" | Label above the control, or beside it in its own column. |
name | string | — | The name the control submits under, when it doesn't set its own. |
disabled | boolean | false | Disables the control and marks every part. |
invalid | boolean | — | Shows the field as invalid now (true) or never (false). Leave it out to follow the browser's validation. |
FieldGroup
| Prop | Type | Default | Description |
|---|---|---|---|
layout | "stacked" | "horizontal" | "stacked" | Horizontal lines every field's label up in one column. |
FieldError
| Prop | Type | Default | Description |
|---|---|---|---|
match | boolean | "valueMissing" | "typeMismatch" | … | — | When the error shows: always, never, or only for one validity problem. |
FieldLabel, FieldDescription, FieldSet, FieldLegend and Label take the props of their elements. FieldDescription renders a div.
Data attributes
| Attribute | On | Values |
|---|---|---|
data-layout | Field, FieldGroup | stacked, horizontal |
data-invalid | Every part | Present while the field shows as invalid |
data-disabled | Every part | Present when the field is disabled |
data-required | Every part | Present when the control is required |
CSS variables
| Variable | macOS | iOS | What it sets |
|---|---|---|---|
--mb-field-gap | 6px | 7px | Space between label, control and description |
--mb-field-group-gap | 14px | 20px | Space between fields in a group |
--mb-field-column-gap | 8px | 16px | Space between a horizontal label and its control |
--mb-field-label-font | 13px regular | Footnote | Stacked label type |
--mb-field-label-align | end | start | Horizontal label alignment |