| name | design-forms |
| description | Design the forms of a full-stack Rails 8.1 Hotwire app for usability and accessibility — the form builder (form_with helpers, simple_form, or a form object), the layout (stacked, floating, or inline labels), and the validation/error UX (server-rendered errors via Turbo Streams with a 422, inline hints, or full reload), plus multi-step flows and accessible input patterns. Menu-driven with a Recommended default per menu; detects the installed form gem and validation approach first, builds from design-foundations tokens and design-components inputs, and verifies forms submit, show errors correctly, and pass an accessibility check. Owns form layout and error UX; defers params to rails-controllers, validations to rails-models, and Turbo response codes to rails-hotwire. Web apps only. |
| metadata | {"owner":"rails-design","status":"stable"} |
| user-invocable | true |
| argument-hint | [form] |
design-forms
Purpose
Owns the form UX: how a form is laid out (labels, grouping, spacing, help text),
how it's built (form_with helpers, simple_form, or a form object), and — most
importantly — its validation and error experience (where errors appear, how they're
announced, server-rendered via Turbo with the right status). It builds inputs from
../design-components/ using ../design-foundations/ tokens, and meets the field/label/
error a11y rules from ../design-accessibility/. It does not own the data:
validations live on the model (../rails-models/), strong params and the action live on
the controller (../rails-controllers/), and the Turbo response contract (422 on
invalid) is ../rails-hotwire/.
When to Apply
Use this skill when the task is:
- Laying out a form: label placement, field grouping, help text, actions, spacing
- Choosing a form builder (
form_with vs simple_form vs a form object)
- Designing the validation / error UX (inline errors, error summary, live re-render)
- Multi-step / wizard forms and their progress + state UX
- Accessible input patterns (selects, comboboxes, file upload, date, password, OTP)
Do not use this skill when the task is:
- The model validations themselves → read
../rails-models/SKILL.md
- Strong params, the controller action, redirects → read
../rails-controllers/SKILL.md
- The Turbo response mechanics (returning 422 so Turbo re-renders) → read
../rails-hotwire/SKILL.md
- Inline edit-in-place of a single field/row → read
../design-interactions/SKILL.md (it links here for the field UX)
- Tokens / components the inputs are built from → read
../design-foundations/SKILL.md · ../design-components/SKILL.md
- The full field/label/error a11y spec → read
../design-accessibility/SKILL.md
- An API-only app — no rendered forms
Detect Before You Generate
grep -nE "api_only" config/application.rb
grep -E "simple_form|cocoon|nested_form|view_component" Gemfile.lock 2>/dev/null
ls config/initializers/simple_form*.rb app/inputs 2>/dev/null
grep -rlnE "form_with|simple_form_for|form_for" app/views 2>/dev/null | head
grep -rnE "status: :unprocessable_entity|:unprocessable_content|422" app/controllers 2>/dev/null | head
grep -E "daisyui|flowbite" Gemfile.lock package.json 2>/dev/null
- Match the existing form style. If
simple_form_for is used app-wide, build new
forms with it; if it's plain form_with, stay there. Don't mix builders per-form.
- Honor the Turbo error contract. Invalid submits must return 422 so Turbo
re-renders the form with errors — verify controllers already do this (
../rails-hotwire/).
- Use the UI kit's input/label classes if a kit is installed.
Menu
Three menus via AskUserQuestion; defaults marked Recommended. Ask unless detected.
Form builder
| Option | One-line trade-off | Deep dive |
|---|
form_with + helpers (Recommended) | Rails default; zero deps, full control of markup (and Tailwind classes). More boilerplate per field. | form-builder.md |
| simple_form | DRY label+input+error+hint wrappers, custom inputs; great for big forms. A dependency + its own conventions. | form-builder.md |
| Form object (plus either builder) | A PORO/ActiveModel for multi-model or non-AR forms; clean validations off the model. Extra layer. | form-builder.md |
Label / layout
| Option | One-line trade-off | Deep dive |
|---|
| Stacked labels (Recommended) | Label above input; most scannable, mobile-friendly, accessible by default. | input-patterns.md |
| Floating labels | Label animates into the field; compact and modern, riskier for a11y/placeholder confusion. | input-patterns.md |
| Inline / horizontal labels | Label beside input; dense desktop forms/settings, weaker on small screens. | input-patterns.md |
Validation / error UX
| Option | One-line trade-off | Deep dive |
|---|
| Server-rendered errors via Turbo (422) (Recommended) | Submit → controller re-renders form with model.errors and a 422; inline messages + summary, no full reload. The Rails+Hotwire default. | validation-error-ux.md |
| Inline client hints | Lightweight Stimulus/HTML5 hints before submit (format, required); must still server-validate. | validation-error-ux.md |
| Full-page reload | Classic non-Turbo redirect/re-render; simplest, loses scroll/state, jarring. | validation-error-ux.md |
Decision Flow
- Builder:
form_with is the default — full markup control to apply tokens/kit
classes. Adopt simple_form for large apps where DRY label/input/error/hint wrappers
pay off. Add a form object when a form spans multiple models or isn't backed by AR —
keep validations off the controller.
- Layout: stacked labels by default (scannable, mobile-first, accessible).
Floating for compact/modern looks (watch the placeholder-vs-label a11y trap).
Inline for dense desktop settings. Never use a placeholder as the label
(
../design-accessibility/).
- Errors: server-rendered via Turbo with a 422 is the Rails-native UX — show a
message inline at each field and a summary at the top that focuses/links to the
first error. Client hints are a nicety, never the source of truth — always validate on
the server. Avoid full reloads.
- Multi-step: show progress, persist between steps, validate per step, and don't ask
for the same thing twice (WCAG 2.2 redundant-entry) — see
multi-step-forms.md.
- A11y is mandatory, not optional: every field has a bound
<label>, errors use
aria-describedby + aria-invalid, required is conveyed in text — all from
../design-accessibility/ (form-accessibility.md).
Problem → Reference
| Task | Read |
|---|
Choose/structure the builder (form_with, simple_form, form object) | references/form-builder.md |
| Validation & error UX: inline errors, summary, Turbo 422 re-render, focus | references/validation-error-ux.md |
| Multi-step / wizard forms: progress, state, per-step validation | references/multi-step-forms.md |
| Input patterns + layout: labels, selects, file upload, date, password, help text | references/input-patterns.md |
Form accessibility: labels, aria-describedby/aria-invalid, required, error focus | references/form-accessibility.md |
Verify
A form is "done" when it submits, shows errors correctly without a full reload, and is
accessible:
grep -rnE "unprocessable_entity|unprocessable_content|status: 422" app/controllers 2>/dev/null \
&& echo "✓ invalid submit returns 422"
bin/rails test test/system 2>/dev/null || bundle exec rspec spec/system
Manually: every field has a visible label; an invalid submit shows the message at the
field and in a summary, announces it, and keeps the user's input. Then route to
../design-accessibility/ for the form a11y audit and ../rails-models/ /
../rails-controllers/ for the validations and params behind it.