Best Practice

Form Design Best Practices: Layout, Copy, and UX

January 2026 · 12 min read

Good form design is invisible. Users fill it out without thinking. Bad form design creates friction, abandonment, and frustration. Here's how to design forms that people actually complete.

Visual Layout

Single Column is King

One field per row creates a clear visual path. Users scan top to bottom, no decisions about where to look next:

// Single column - one field per row (recommended)
form.addRow(row => {
    row.addTextbox('name', { label: 'Full Name', isRequired: true });
});
form.addRow(row => {
    row.addEmail('email', { label: 'Email', isRequired: true });
});
form.addRow(row => {
    row.addTextbox('company', { label: 'Company' });
});

Single column works on any screen size without reorganization. It's faster to complete and has fewer errors.

When to Use Multiple Columns

Two columns work for related, short fields that naturally pair. See the Multi-Column Layouts tutorial for all width options.

// Two columns for related, short fields
form.addRow(row => {
    row.addTextbox('firstName', {
        label: 'First Name',
        isRequired: true
    }, '1fr');
    row.addTextbox('lastName', {
        label: 'Last Name',
        isRequired: true
    }, '1fr');
});

Good candidates for side-by-side:

  • First name / Last name
  • City / State / Zip
  • Start date / End date
  • Min / Max values

Pro tip

On mobile, multi-column layouts stack automatically. Design for mobile first, then add columns for larger screens. The FormTs editor has a resize handle on the preview panel - drag it to test how your form looks at different widths.

Group Related Fields

Don't present 15 fields as a wall of inputs. Group them logically. See the Subforms tutorial for nesting and collapsible sections.

// Group related fields in subforms
const contact = form.addSubform('contact', {
    title: 'Contact Information'
});
contact.addRow(row => {
    row.addEmail('email', { label: 'Email', isRequired: true });
});
contact.addRow(row => {
    row.addTextbox('phone', { label: 'Phone' });
});

const address = form.addSubform('address', {
    title: 'Shipping Address'
});
address.addRow(row => {
    row.addTextbox('street', { label: 'Street Address' });
});

Subforms create visual separation and help users understand the form structure at a glance.

Use Whitespace

Cramped forms feel overwhelming. Add breathing room. See the Spacers tutorial for all divider styles.

// Add breathing room between sections
form.addRow(row => {
    row.addTextbox('name', { label: 'Name' });
});

form.addSpacer({ height: '24px' });

form.addRow(row => {
    row.addEmail('email', { label: 'Email' });
});

// Or with a divider line
form.addSpacer({
    height: '32px',
    showLine: true,
    lineStyle: 'dashed',
    lineColor: '#e5e5e5'
});

Whitespace isn't wasted space. It guides the eye and reduces cognitive load.

Microcopy

Clear, Specific Labels

Labels should tell users exactly what to enter:

// Bad: Vague labels
row.addTextbox('field1', { label: 'Name' });
row.addTextbox('field2', { label: 'Address' });

// Good: Specific labels
row.addTextbox('fullName', {
    label: 'Full Name',
    placeholder: 'John Smith'
});
row.addTextbox('streetAddress', {
    label: 'Street Address',
    placeholder: '123 Main St, Apt 4B'
});

"Name" is ambiguous. Full name? First name? Username? Be specific.

Placeholders as Examples

Placeholders show format, not purpose. Never use placeholder as the only label:

// Placeholder as example, not label
row.addTextbox('phone', {
    label: 'Phone Number',
    placeholder: '(555) 123-4567'  // Shows format
});

row.addMoney('budget', {
    label: 'Monthly Budget',
    placeholder: '5000',  // Suggests typical value
    currency: '$'
});

Placeholders disappear when typing. Users forget what they were supposed to enter.

Tooltips for Context

Some fields need explanation. Use tooltips for extra context without cluttering the form. See the Dynamic Labels tutorial for reactive labels and tooltips.

// Use tooltips for extra context
row.addTextbox('coupon', {
    label: 'Coupon Code',
    tooltip: 'Enter your discount code. Case-sensitive.',
    placeholder: 'SAVE20'
});

row.addCheckbox('newsletter', {
    label: 'Subscribe to newsletter',
    tooltip: 'Weekly updates on new features. Unsubscribe anytime.'
});

Tooltips appear on hover/focus. Available when needed, hidden when not.

See these patterns in action.

Reduce Cognitive Load

Progressive Disclosure

Don't show everything at once. Reveal fields when relevant. See the Conditional Visibility tutorial for more patterns.

// Show fields only when relevant
form.addRow(row => {
    row.addRadioButton('hasReferral', {
        label: 'Were you referred by someone?',
        options: [
            { id: 'yes', name: 'Yes' },
            { id: 'no', name: 'No' }
        ]
    });
});

form.addRow(row => {
    row.addTextbox('referralCode', {
        label: 'Referral Code',
        isVisible: () => form.radioButton('hasReferral')?.value() === 'yes'
    });
});

Users see a simpler form. Fewer visible fields = less overwhelming.

Conditional Requirements

Don't make everything required. Require only what you actually need, based on context. See the Conditional Required tutorial for more examples.

// Only require what's needed
form.addRow(row => {
    row.addDropdown('contactMethod', {
        label: 'Preferred Contact Method',
        options: [
            { id: 'email', name: 'Email' },
            { id: 'phone', name: 'Phone' },
            { id: 'either', name: 'Either' }
        ]
    });
});

form.addRow(row => {
    row.addEmail('email', {
        label: 'Email',
        isRequired: () => {
            const method = form.dropdown('contactMethod')?.value();
            return method === 'email' || method === 'either';
        }
    });
});

form.addRow(row => {
    row.addTextbox('phone', {
        label: 'Phone',
        isRequired: () => {
            const method = form.dropdown('contactMethod')?.value();
            return method === 'phone' || method === 'either';
        }
    });
});

If they choose email contact, require email. If phone, require phone. Adapt to their choices.

Smart Defaults

Pre-fill fields when you can predict the answer:

// Pre-fill with smart defaults
row.addDropdown('country', {
    label: 'Country',
    options: countries,
    defaultValue: 'US'  // Most common
});

row.addInteger('quantity', {
    label: 'Quantity',
    defaultValue: 1,
    min: 1,
    max: 100
});

row.addDatepicker('startDate', {
    label: 'Start Date',
    defaultValue: () => {
        // Default to tomorrow
        const tomorrow = new Date();
        tomorrow.setDate(tomorrow.getDate() + 1);
        return tomorrow.toISOString().split('T')[0];
    }
});

Defaults reduce typing and decision fatigue. Users can change them if wrong, but most won't need to.

Input Types

Match Input to Data

Use the right field type for the data:

// Use the right input for the data type
row.addEmail('email', { label: 'Email' });  // Shows @ keyboard on mobile
row.addInteger('age', { label: 'Age' });    // Number keyboard
row.addMoney('price', { label: 'Price' });  // Currency formatting
row.addDatepicker('date', { label: 'Date' });  // Date picker UI
row.addTimepicker('time', { label: 'Time' });  // Time picker UI

// Use dropdowns for limited options
row.addDropdown('size', {
    label: 'Size',
    options: [
        { id: 'S', name: 'Small' },
        { id: 'M', name: 'Medium' },
        { id: 'L', name: 'Large' }
    ]
});

Benefits:

  • Appropriate keyboard on mobile
  • Built-in validation
  • Better accessibility
  • Faster data entry

Constrain When Possible

Dropdowns, radio buttons, and sliders prevent invalid input:

  • 3-7 options - Radio buttons (all visible)
  • 8+ options - Dropdown (searchable)
  • Numeric range - Slider (visual)
  • Yes/No - Checkbox or thumb rating

Buttons and Actions

Action-Oriented Labels

"Submit" is generic. Tell users what happens:

// Clear, action-oriented button
form.configureSubmitButton({
    label: 'Get My Quote'  // Specific, not generic "Submit"
});

// Or for multi-step forms
form.configureSubmitButton({
    label: () => {
        const page = form.pages('wizard')?.currentPageIndex() ?? 0;
        const totalPages = 3;
        return page < totalPages - 1 ? 'Continue' : 'Submit Request';
    }
});

Good button labels:

  • "Get My Quote" (not "Submit")
  • "Create Account" (not "Register")
  • "Send Message" (not "Submit Form")
  • "Book Appointment" (not "Confirm")

Button Placement

Primary action button should be:

  • Left-aligned (follows natural reading flow)
  • Below the last field (clear progression)
  • Visually prominent (contrasting color)

Pro tip

For multi-step forms, "Back" goes left, "Continue" goes right. This matches mental models of progress.

Accessibility

Labels Are Required

Every input needs a label. Screen readers depend on them:

// Always use labels (never placeholder-only)
row.addTextbox('name', {
    label: 'Name',           // Screen readers need this
    placeholder: 'John'      // Supplementary, not replacement
});

// Mark required fields clearly
row.addEmail('email', {
    label: 'Email',
    isRequired: true  // Adds visual indicator + aria-required
});

// Provide helpful error context
row.addTextbox('username', {
    label: 'Username',
    pattern: '^[a-zA-Z0-9_]+$',
    tooltip: 'Letters, numbers, and underscores only'
});

FormTs automatically associates labels with inputs and adds ARIA attributes. Just provide the label text.

Keyboard Navigation

Users should be able to complete the form with keyboard alone:

  • Tab moves between fields
  • Enter submits the form
  • Arrow keys navigate options
  • Escape closes dropdowns

All FormTs components handle this automatically.

Color Isn't Enough

Don't rely solely on color to convey information:

  • Required fields get asterisk + color
  • Errors show icon + red text
  • Success shows checkmark + green text

Complete Example

A well-designed contact form applying all principles:

export function wellDesignedForm(form: FormTs) {
    // Section 1: Contact (grouped)
    const contact = form.addSubform('contact', {
        title: 'Contact Information'
    });

    contact.addRow(row => {
        row.addTextbox('firstName', {
            label: 'First Name',
            isRequired: true
        }, '1fr');
        row.addTextbox('lastName', {
            label: 'Last Name',
            isRequired: true
        }, '1fr');
    });

    contact.addRow(row => {
        row.addEmail('email', {
            label: 'Email',
            isRequired: true
        });
    });

    // Spacer between sections
    form.addSpacer({ height: '16px' });

    // Section 2: Project Details
    const project = form.addSubform('project', {
        title: 'Project Details'
    });

    project.addRow(row => {
        row.addDropdown('type', {
            label: 'Project Type',
            options: [
                { id: 'new', name: 'New Project' },
                { id: 'redesign', name: 'Redesign' },
                { id: 'maintenance', name: 'Maintenance' }
            ],
            isRequired: true
        });
    });

    project.addRow(row => {
        row.addSlider('budget', {
            label: 'Budget Range',
            min: 1000,
            max: 50000,
            step: 1000,
            unit: '$',
            defaultValue: 10000
        });
    });

    project.addRow(row => {
        row.addTextarea('description', {
            label: 'Tell us about your project',
            placeholder: 'What are you looking to achieve?',
            rows: 4
        });
    });

    form.configureSubmitButton({
        label: 'Request Quote'
    });
}

This form:

  • Groups related fields in sections
  • Uses two columns only for name (related, short)
  • Has clear, specific labels
  • Includes helpful placeholders
  • Uses appropriate input types
  • Has action-oriented button text

Testing Your Form

The 5-Second Test

Show someone your form for 5 seconds. Can they tell you:

  • What the form is for?
  • How many sections there are?
  • What the main action is?

Complete It Yourself

Fill out your form on mobile. Note every hesitation, confusion, or annoyance. Those are conversion killers.

Watch Real Users

Nothing beats watching someone else use your form. Where do they pause? What do they ask? What do they skip?

Common Questions

Should I use floating labels?

Floating labels (that move up when focused) can cause accessibility issues and confusion. Standard labels above fields work better. They're always visible, clearly associated with the input, and work with autofill.

How many fields is too many?

It depends on context. A quote request form with 15 fields is acceptable if users want an accurate quote. A newsletter signup with 3 fields is too many. Minimize friction relative to the value users receive.

Should optional fields be marked or required ones?

Mark required fields with an asterisk. It's the established convention. If most fields are optional, you can flip it and mark those instead, but this is less common.

Left-aligned or top-aligned labels?

Top-aligned labels are generally better. They're easier to scan, work on all screen sizes, and accommodate long labels. Left-aligned can work for very short forms with short labels, but top-aligned is safer.

Should I show validation errors inline or in a summary?

Both. Inline errors next to each field are most helpful for fixing issues. A summary at the top helps when there are multiple errors or when the error is scrolled out of view.

Ready to Build Better Forms?

Apply these patterns in minutes with FormTs. Start designing today.