Form
Fields on the grid and one statement button. A failed send gives each field its callout and focuses the first; while sending, the button says so. Or write it as a letter with a postscript, or have it postmarked when it's sent.
Report an issue with FormExample
grid
letter
postmark
grid, after a failed send
Sending
letter, after a failed send
postmark, sent
Install
Run it in a project that has the shadcn CLI set up. It brings the 0nlyType base, button, and field along.
npx shadcn@latest add https:// thedirectors.agency/ ui/ r/form.jsonUsage
This example also uses Select.
npx shadcn@latest add https:// thedirectors.agency/ ui/ r/ select.json"use client"import { Button } from "@/components/ui/button"import { Field, Input, Textarea } from "@/components/ui/field"import { Form, FormPostmark, FormPostscript, FormSubmit } from "@/components/ui/form"import { Select } from "@/components/ui/select"const send = async (data: FormData) => {await new Promise((done) => setTimeout(done, 1400))console.log(Object.fromEntries(data))}// Try sending each one empty: the grid hangs a callout from each line, the letter writes a postscript.export default function Example() {return (<div className="grid w-full gap-16"><div className="grid gap-6"><span className="ot-label">grid</span><Form className="w-full max-w-md" onSubmit={send}><Field label="Your name"><Input name="name" required data-error="Add your name so we know who to answer." autoComplete="name" /></Field><Field label="Email"><Input name="email" type="email" required data-error="Check the address. It needs a domain after the @, like studio.com." autoComplete="email" /></Field><Select label="Budget is" name="budget" defaultValue="under 20k"><option>under 20k</option><option>20k to 60k</option><option>over 60k</option></Select><Field label="The brief" count maxLength={400} hint="What should we know before the call?"><Textarea name="brief" rows={4} required data-error="Tell us a line or two about the project." /></Field><FormSubmit busy="Sending" sent="Sent">Send</FormSubmit></Form></div><div className="grid gap-6"><span className="ot-label">letter</span><Form variant="letter" onSubmit={send}><div>Dear Studio,</div><div>My name is{" "}<Field label="Your name"><Input name="name" required placeholder="your name" data-error="Sign it with your name, so we know who to answer." autoComplete="name" /></Field>{" "}and you can write back to me at{" "}<Field label="Email"><Input name="email" type="email" required placeholder="you@studio.com" data-error="Check the address: it needs a domain after the @, like studio.com." autoComplete="email" /></Field>{" "}We have{" "}<Select aria-label="Budget" name="budget" defaultValue="under 20k"><option>under 20k</option><option>20k to 60k</option><option>over 60k</option></Select>{" "}to spend, and this is what you should know before we speak:</div><Field label="The brief"><Textarea name="brief" rows={3} required data-error="Write a line or two about the project." /></Field><FormSubmit busy="Sending" sent="Sent">Send the letter</FormSubmit></Form></div><div className="grid gap-6"><span className="ot-label">postmark</span><Form variant="postmark" className="w-full max-w-md" onSubmit={send}><Field label="Email"><Input name="email" type="email" required data-error="Check the address. It needs a domain after the @, like studio.com." autoComplete="email" /></Field><FormSubmit busy="Subscribing" sent="Subscribed">Subscribe</FormSubmit></Form></div></div>)}const noop = () => {}const POSTED = new Date(Date.UTC(2026, 8, 30, 14, 2))Contract
- Underneath: a native
<form>withnoValidate, plus a hook. The browser's own constraint validation decides what's wrong; the form only says it the 0nlyType way. - Anatomy:
<form class="ot-form">holdingot-fields on a grid and oneFormSubmit, a statement button. - On submit it checks every control. Each invalid one gets its Field's error callout (the control's
data-error, else the browser's message),aria-invalidandaria-describedby, and focus goes to the first. Typing clears or updates that field's error. - Sending:
FormSubmitgoes busy and says so (busy="Sending"), with the breathing periods. A second submit while one runs is ignored. - Copy: the button names the action (Send, Save), never Submit. Errors say what to fix.
- Sent:
FormSubmit sent="Sent"says so onceonSubmitresolves, until anything is changed, so the action keeps one name: Send, Sending, Sent. - Variants go on
data-variant: grid (the default), letter, postmark. - Letter, after Weingart's letter to AIGA: the form is a letter, our words in roman prose and each field a blank in it that you fill in italic. The prose names each blank, so its label, hint and callout are kept for screen readers only (the control still gets
aria-invalidandaria-describedby). A textarea stays a block, the letter's body. After a failed send, what's still wrong is said once, in a postscript under the letter (FormPostscript): "P.S." in ink, then each thing to fix in the signal, each a link that takes focus to its blank. It arrives once and shortens as you fix things. - Postmark, after the postal hand stamp: once sent, a postmark comes down on the form's end corner, beside the button that sent it and clear of what you wrote (
FormPostmark,role="status"). A double ring set askew, "Sent", the day large as the dot calendar sets it, the month and year, the time. It is ink, so the one accent stays with you. It lands once with spiccato, and lifts off as soon as anything is changed. Right to left it leans the other way; reduced motion, it is simply there; forced colours,CanvasTextrings onCanvas.
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
variant | "grid" | "letter" | "postmark" | "grid" | grid: fields on the grid, each error hung from its own line. letter: write the form as a letter, with each Field a blank in the prose (use divs for its paragraphs: a Field is a div). The prose names the blanks, so their labels, hints and callouts are kept for screen readers; a failed send writes one postscript below the letter, each sentence taking you to its blank. postmark: once it's sent, a postmark with the day and the time comes down askew on the form's corner and is read out; changing anything lifts it. |
onSubmit | (data: FormData) => Promise<void> | void | Runs once every control is valid. The submit button stays busy until the promise settles, and a second send is ignored meanwhile. | |
data-error | string (on a control) | Says what to fix in your own words. Without it, the browser's own validation message is used. | |
FormSubmit busy | string | What the button says while sending, keeping the action's name: Send becomes Sending. Without it the label stays and the periods breathe. | |
FormSubmit sent | string | What it says once sent, until something changes: Send becomes Sent. Without it the label stays. | |
FormPostscript, FormPostmark | parts | The letter's postscript and the postmark. The Form renders them itself; they're exported to pin a state in documentation. | |
FormPostscript errors | Record<string, string> | Required when rendering the part yourself: control IDs mapped to messages. Each message links to its control. Form supplies these automatically for its letter variant. | |
FormPostmark date | Date | Required when rendering the part yourself: when the form was sent. Form supplies it automatically for its postmark variant. | |
FormPostmark locale, timeZone | string | The locale and time zone the day and time are set in. Without them, the reader's own. Pin both when the postmark is rendered on the server, so the server and the browser write the same words. |
Craft
Where its move comes from
| Component | Reference | Move |
|---|---|---|
| Letter form | Weingart letter; the postscript | The fields are blanks in prose; what's wrong is said once, in a P.S. |
| Postmark form | The postal hand stamp; the dot calendar's day; the collage's hand rings | Sent, the day and the time in a double ring, set askew |
How it moves
| Component | Articulation | What moves |
|---|---|---|
| Letter form | Arrive | The postscript arrives once, after a failed send |
| Postmark form | Stamp | The postmark comes down from above, turning into its tilt, with spiccato |