Once UI Handbook
Vibe coding
Introduction to vibe coding
Set up your local dev environment
Essential tools for vibe coding
Build your first Once UI app
Getting started
What is Once UI?
Install & config
Page skeleton
Your first page
Upgrade from 1.8 to 2.0
Once UI without Next.js
Build and launch
How to launch a portfolio site that actually gets you hired
How to build a documentation site with MDX and Next.js
How to ship a landing page and dashboard with authentication in Next.js
How to launch a social app with Next.js and Supabase
Common patterns
Hero section
Static panel
Form layout
Highlighted card
Responsive stacking
Decoration layers
Dialog & modal
Toast & feedback
Tables & lists
Loading states
App shell navigation
Dashboard & charts
Chat & messaging
Auth & verification
Settings & split panels
Data filters & toolbar
Command palette
Pricing & plans
Accordion & FAQ
Media & uploads
Empty & error states
SEO, Open Graph, and structured data
Documentation code blocks with live preview
Carousels, galleries, and before/after comparisons
Date pickers and scheduling
Context menus and dropdown actions
Tags, chips, and multi-value inputs
Roadmap & kanban
Form inputs & controls
Progress, status & badges
Scrolling & feeds
Profile, avatars & identity
Social proof & logo clouds
Masonry & media grids
Table pagination, search & bulk selection
Banners & announcements
Footer layouts
Onboarding & first run
Waitlist & coming soon
Forms that wire themselves
Design tips
Color & surfaces
Harness overview
Row, Column & Grid
Spacing & rhythm
Icons & buttons
Reveal & motion
Theme tokens, FOUC-free init, and runtime style controls
Tooltips and hover cards
Border radius & nested corners
Bring your own icons
Agent resources
Column, not Flex
Semantics over CSS
Typography scale
Common gotchas
Block anchors
Validate generated code
Pro block registry
Match task bundles
Block updates
Block updates changelog
TrademarkTrademark
Ctrl k
Search…
Sign up
Once UI Handbook
Vibe coding
Introduction to vibe coding
Set up your local dev environment
Essential tools for vibe coding
Build your first Once UI app
Getting started
What is Once UI?
Install & config
Page skeleton
Your first page
Upgrade from 1.8 to 2.0
Once UI without Next.js
Build and launch
How to launch a portfolio site that actually gets you hired
How to build a documentation site with MDX and Next.js
How to ship a landing page and dashboard with authentication in Next.js
How to launch a social app with Next.js and Supabase
Common patterns
Hero section
Static panel
Form layout
Highlighted card
Responsive stacking
Decoration layers
Dialog & modal
Toast & feedback
Tables & lists
Loading states
App shell navigation
Dashboard & charts
Chat & messaging
Auth & verification
Settings & split panels
Data filters & toolbar
Command palette
Pricing & plans
Accordion & FAQ
Media & uploads
Empty & error states
SEO, Open Graph, and structured data
Documentation code blocks with live preview
Carousels, galleries, and before/after comparisons
Date pickers and scheduling
Context menus and dropdown actions
Tags, chips, and multi-value inputs
Roadmap & kanban
Form inputs & controls
Progress, status & badges
Scrolling & feeds
Profile, avatars & identity
Social proof & logo clouds
Masonry & media grids
Table pagination, search & bulk selection
Banners & announcements
Footer layouts
Onboarding & first run
Waitlist & coming soon
Forms that wire themselves
Design tips
Color & surfaces
Harness overview
Row, Column & Grid
Spacing & rhythm
Icons & buttons
Reveal & motion
Theme tokens, FOUC-free init, and runtime style controls
Tooltips and hover cards
Border radius & nested corners
Bring your own icons
Agent resources
Column, not Flex
Semantics over CSS
Typography scale
Common gotchas
Block anchors
Validate generated code
Pro block registry
Match task bundles
Block updates
Block updates changelog
TrademarkTrademark
Once UIDocumentationBlog
© Once UI. All rights reserved.
Built with Aveiro
Common Patterns

Forms that wire themselves

Use Field, Fieldset, SubmitButton and FormErrorSummary inside Form to get accessible labels, hints, errors, pending state and an error summary without hand-written ids.
Updated 14 hours ago
Waitlist & coming soon
6 min · Pattern · Forms
Since 2.3, Once UI has form building blocks: Field wires labels, hints and errors to a control, Fieldset groups sections, SubmitButton handles pending state, and FormErrorSummary lists what went wrong.

Why hand-built forms break

The Form layout pattern stacks a label, an input and a helper line in a Column. It looks right, but every field needs ids kept in sync by hand: the label points at the input, and the input needs aria-describedby pointing at the hint and the error. When that bookkeeping drifts, nothing looks wrong. The error is on screen and a screen reader never announces it. @once-ui-system/core 2.3 adds four components that do the wiring for you: Field, Fieldset, SubmitButton and FormErrorSummary. They all work inside Form.

Field: label, hint and error in one place

Wrap one control in a Field. It gives the control its id, aria-describedby, aria-invalid and aria-required, merged with whatever the control already has.
<Field label="Email" hint="We only use it for receipts." error={errors.email} required>
  <Input id="email" type="email" />
</Field>
If the control already has an id, Field uses it, so you don't repeat it. While error is set the control is marked invalid. Input and Textarea, and the fields built on them such as Select, PasswordInput and NumberInput, pick that up and draw their error styling, so you don't pass a separate error prop to the input. The hint stays visible next to the error, so the instruction is still there when someone goes back to fix the value. For a custom control, pass a function as the child. It receives the same attributes to spread where you need them. Inside your own component, useField() returns the same state.

Form: one decision for spacing

Form lays fields out on a grid. density decides both the gap and how the fields read:
  • "stacked" fuses the fields into one block. Shared borders collapse to a single hairline and only the outer corners stay round.
  • "tight" (the default) and "spacious" are ordinary gaps where every field keeps its own border.
columns takes a number or a per-breakpoint map, and span on a Field makes it take more than one column:
<Form density="stacked" columns={{ xs: 1, m: 2 }}>
  <Field label="First name"><Input id="first" /></Field>
  <Field label="Last name"><Input id="last" /></Field>
  <Field label="Email" span={2}><Input id="email" type="email" /></Field>
</Form>
You never write corners="top" or corners="bottom" by hand. Form works out the corners from the fields that actually rendered, at every breakpoint, so hiding a field or changing the column count doesn't leave a square edge at the end of the group. size on the form sets the default size for every field inside it, and a field that sets its own size keeps it.

Fieldset: titled sections

A Fieldset is a real <fieldset> with a <legend>, so screen readers announce the section name when you enter it. It takes the same density and columns as Form, and disabled turns off every native control inside at once.
<Fieldset legend="Billing address" description="Where we send invoices." columns={2}>
  <Field label="Street" span={2}><Input id="street" /></Field>
  <Field label="City"><Input id="city" /></Field>
  <Field label="Postcode"><Input id="postcode" /></Field>
</Fieldset>
Inside an outer Form, span on the Fieldset sets how many columns the section takes, and the outer form's size carries in.

SubmitButton: no double submits

Render the form as a real <form> with as="form". If onSubmit returns a promise, the form is pending until it settles. While it's pending the form is aria-busy, a second submit from Enter or a second click is dropped, and SubmitButton shows a spinner.
<Form as="form" onSubmit={async (event) => {
  event.preventDefault();
  await saveProfile(new FormData(event.currentTarget));
}}>
  <Field label="Display name"><Input id="name" name="name" /></Field>
  <SubmitButton pendingLabel="Saving…">Save</SubmitButton>
</Form>
If the submit doesn't go through onSubmit, for example a server action or a mutation hook, pass pending to the Form yourself. While pending, the button uses aria-disabled rather than disabled, so a keyboard user keeps focus on it instead of being dropped back to the top of the page.

FormErrorSummary: after a failed submit

Per-field errors only help someone who scrolls past each field. FormErrorSummary lists them all in one place. It takes a map from control id to message, the shape most validation libraries return, or a list of { id, message }:
<Form as="form" onSubmit={handleSubmit}>
  <FormErrorSummary errors={errors} span={2} />
  {/* fields */}
</Form>
It renders nothing while there are no errors, so it can stay in the form permanently. When errors appear or change, it takes focus, so keyboard and screen-reader users land on it instead of staying on the submit button with no idea why nothing happened. Each entry links to its field, and choosing it moves focus into the control. The default heading is "Check the highlighted fields". Change it with title, and turn the focus behaviour off with autoFocus={false}.

Write this / not this

Do
Avoid
`<Field label hint error>` around the input
A `Text` label and hint with ids kept in sync by hand
`Form density="stacked"`
`corners="top"` and `corners="bottom"` written down the stack
`SubmitButton` in `Form as="form"`
A `Button` with a `loading` flag you reset yourself
`FormErrorSummary errors={errors}`
Errors that only show next to fields off screen

Reference

Every prop is documented in the Field page of the Once UI docs, alongside the pages for Form, Fieldset, SubmitButton and FormErrorSummary.