Form layout
Single column, 720px measure, labeled controls stacked with consistent rhythm, actions at the end. The form atoms carry their own label and error wiring — the pattern's job is spacing and order.
Recipe
import { useState } from 'react';
import { Button } from '@elirobinson/react/components/atoms/Button';
import { Checkbox } from '@elirobinson/react/components/atoms/Checkbox';
import { Input } from '@elirobinson/react/components/atoms/Input';
import { Textarea } from '@elirobinson/react/components/atoms/Textarea';
import { Select } from '@elirobinson/react/components/organisms/Select';
export function ContactForm() {
const [topic, setTopic] = useState('consulting');
return (
<form className="form" onSubmit={(event) => event.preventDefault()}>
<h2>Get in touch</h2>
<p>Tell me what you need and I'll get back to you within 24 hours.</p>
<Input label="Name" name="name" autoComplete="name" required />
<Input
label="Email"
name="email"
type="email"
autoComplete="email"
hint="I reply from eli@miltinsons.com — add it to your contacts."
required
/>
<Select
label="What do you need?"
name="topic"
value={topic}
onChange={(event) => setTopic(event.target.value)}
>
<option value="consulting">AI consulting</option>
<option value="support">Tech support</option>
<option value="coaching">Coaching guides</option>
</Select>
<Textarea label="The details" name="details" rows={5} />
<Checkbox label="Send me the occasional practical update — no spam" name="updates" />
<div className="form__actions">
<Button type="submit">Send message</Button>
<Button type="button" variant="ghost">
Cancel
</Button>
</div>
</form>
);
}.form {
display: flex;
flex-direction: column;
gap: var(--space-5);
max-width: var(--container-md);
}
.form__actions {
display: flex;
gap: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--border);
}Rules
- One column. Two-column forms trade scanning speed for error rates; don't.
Input,Textarea, andSelectrequire theirlabelprop — there is no unlabeled variant, on purpose. For a control that doesn't wire its own labeling (a third-party date picker, say), wrap it in FormField — and never wrapInputinFormField, which would render two labels.- Errors inline via the
errorprop, which also setsaria-invalidand wiresaria-describedby. On submit failure, move focus to the first invalid control. - Hints before errors: if a rule can be stated up front ("at least 8 characters"),
put it in
hintinstead of springing it as an error. - Primary action first (left), quiet actions after. The submit button stays enabled — a disabled submit hides the error instead of showing it.
autoCompleteattributes on identity fields; the browser filling forms correctly is an accessibility feature.