VI Controls

Star on GitHubFeedback
VI ControlsNative HTML and a hookot-form

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 Form

Example

grid

What should we know before the call?

letter
Dear Studio,
My name is
and you can write back to me at
We have to spend, and this is what you should know before we speak:
postmark
grid, after a failed send

Check the address.

Sending
letter, after a failed send
Yours,

Sign it with your name.

postmark, sent

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.json

Usage

This example also uses Select.

npx shadcn@latest add https://thedirectors.agency/ui/r/select.json
form-example.tsx
"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> with noValidate, 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"> holding ot-fields on a grid and one FormSubmit, 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-invalid and aria-describedby, and focus goes to the first. Typing clears or updates that field's error.
  • Sending: FormSubmit goes 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 once onSubmit resolves, 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-invalid and aria-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, CanvasText rings on Canvas.

Props

Props of Form
PropTypeDefaultWhat 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> | voidRuns once every control is valid. The submit button stays busy until the promise settles, and a second send is ignored meanwhile.
data-errorstring (on a control)Says what to fix in your own words. Without it, the browser's own validation message is used.
FormSubmit busystringWhat the button says while sending, keeping the action's name: Send becomes Sending. Without it the label stays and the periods breathe.
FormSubmit sentstringWhat it says once sent, until something changes: Send becomes Sent. Without it the label stays.
FormPostscript, FormPostmarkpartsThe letter's postscript and the postmark. The Form renders them itself; they're exported to pin a state in documentation.
FormPostscript errorsRecord<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 dateDateRequired when rendering the part yourself: when the form was sent. Form supplies it automatically for its postmark variant.
FormPostmark locale, timeZonestringThe 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

Where its move comes from
ComponentReferenceMove
Letter formWeingart letter; the postscriptThe fields are blanks in prose; what's wrong is said once, in a P.S.
Postmark formThe postal hand stamp; the dot calendar's day; the collage's hand ringsSent, the day and the time in a double ring, set askew

How it moves

How it moves
ComponentArticulationWhat moves
Letter formArriveThe postscript arrives once, after a failed send
Postmark formStampThe postmark comes down from above, turning into its tilt, with spiccato