Skip to content

Design system

Documentation

DesignPatterns / Forms

Patterns / Forms

Beta

Forms

A form pattern composes Field, Input, Button, and Card into a task that stays understandable when it grows beyond one screen.

On this page

Preview

Forms pattern

A long-form layout keeps sections, validation, and the action row predictable without hiding the source of an error.

Invite a teammate

Collect the minimum context before sending an invitation.

Contact details

Use a name and email the teammate will recognize.

Role and access

Explain why this person is joining before choosing permissions.

Keep this short; it appears in the invitation.

Invite teammate form with grouped fields and inline validation

When to use

Use this pattern for a form that collects related information and ends with an explicit review, save, or submit action. Keep domain rules in the screen or feature; the pattern only gives those rules a predictable structure.

Layout

  • Group fields by the decision the person is making, then separate groups with a quiet divider.
  • Use one visible label per control. Use a two-column row only when the fields remain easy to scan at the smallest supported width.
  • Keep the action row together at the end. The primary action says what will happen next; the secondary action cancels or returns without saving.
  • For a long form, keep the page or form body scrollable and leave the action row in a stable place. Do not hide the only submit action behind a clipped region.

Validation

Validate at the least noisy moment that still helps the task. The example validates on submit, clears a field error when that field changes, and keeps the message beside the control that needs attention.

forms-example.tsx
"use client";

import { useState } from "react";
import type { FormEvent } from "react";

import { Button } from "@phuctech/ui/components/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@phuctech/ui/components/card";
import { Field, FieldError, FieldLabel } from "@phuctech/ui/components/field";
import { Input } from "@phuctech/ui/components/input";

export function InviteForm() {
  const [email, setEmail] = useState("");
  const [error, setError] = useState("");

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setError(email.includes("@") ? "" : "Enter a valid email address.");
  }

  return (
    <Card>
      <form onSubmit={handleSubmit} noValidate>
        <CardHeader><CardTitle>Invite a teammate</CardTitle></CardHeader>
        <CardContent>
          <Field invalid={Boolean(error)}>
            <FieldLabel htmlFor="invite-email">Email</FieldLabel>
            <Input id="invite-email" type="email" value={email} onChange={(event) => setEmail(event.target.value)} aria-invalid={Boolean(error) || undefined} />
            <FieldError match={Boolean(error)}>{error}</FieldError>
          </Field>
        </CardContent>
        <CardFooter>
          <Button type="submit">Review invitation</Button>
          <Button type="button" variant="outline">Cancel</Button>
        </CardFooter>
      </form>
    </Card>
  );
}

Accessibility

  • Connect every FieldLabel to the control with htmlFor and a stable id.
  • Set aria-invalid only for an invalid value and include the error ID in aria-describedby.
  • Use required when the field is genuinely required; do not rely on an asterisk without text.
  • Announce a successful submit with a role="status" message that does not replace the form context.
  • Test the complete path with a keyboard: enter a value, submit with errors, correct the field, and submit again.

Do and don’t

Do

Keep errors specific, local to the field, and persistent until the value is corrected or the form is reset.

Don’t

Put fetch calls or business validation inside Field, or make a placeholder carry the only label.

  • Field owns the label, description, and error relationship.
  • Input provides the single-line control and invalid/read-only states.
  • Button provides submit, cancel, disabled, and loading behavior.