Front-end Engineering Lab

Accessible Forms Advanced

Build complex, multi-step, and conditional forms that work for everyone

Forms are the backbone of web interactions. Complex forms with validation, multi-step flows, and conditional fields require careful accessibility implementation.

Form Structure Basics

// ✅ GOOD: Proper structure
export function AccessibleForm() {
  return (
    <form onSubmit={handleSubmit}>
      {/* Each field in fieldset if grouped */}
      <fieldset>
        <legend>Personal Information</legend>
        
        <div>
          <label htmlFor="name">
            Full Name <span aria-label="required">*</span>
          </label>
          <input
            id="name"
            name="name"
            type="text"
            required
            aria-required="true"
            aria-invalid={!!errors.name}
            aria-describedby={errors.name ? 'name-error' : undefined}
          />
          {errors.name && (
            <div id="name-error" role="alert">
              {errors.name}
            </div>
          )}
        </div>
      </fieldset>
      
      <button type="submit">Submit</button>
    </form>
  );
}

Field Validation

Inline Validation

export function EmailField() {
  const [value, setValue] = useState('');
  const [error, setError] = useState('');
  const [touched, setTouched] = useState(false);

  const validate = (email: string) => {
    if (!email) {
      return 'Email is required';
    }
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      return 'Please enter a valid email';
    }
    return '';
  };

  const handleBlur = () => {
    setTouched(true);
    setError(validate(value));
  };

  return (
    <div>
      <label htmlFor="email">
        Email Address <abbr title="required" aria-label="required">*</abbr>
      </label>
      <input
        id="email"
        type="email"
        value={value}
        onChange={(e) => setValue(e.target.value)}
        onBlur={handleBlur}
        required
        aria-required="true"
        aria-invalid={touched && !!error}
        aria-describedby={`email-hint ${error ? 'email-error' : ''}`}
      />
      
      <div id="email-hint" className="hint">
        We'll never share your email
      </div>
      
      {touched && error && (
        <div id="email-error" role="alert" className="error">
          {error}
        </div>
      )}
    </div>
  );
}

Form-Level Validation

export function FormWithValidation() {
  const [formData, setFormData] = useState({});
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  const errorSummaryRef = useRef<HTMLDivElement>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    const newErrors = validateForm(formData);
    setErrors(newErrors);
    
    if (Object.keys(newErrors).length > 0) {
      // Focus error summary
      errorSummaryRef.current?.focus();
      
      // Announce errors
      const errorCount = Object.keys(newErrors).length;
      announceToScreenReader(
        `Form has ${errorCount} error${errorCount > 1 ? 's' : ''}. Please fix them and try again.`
      );
      
      return;
    }
    
    setIsSubmitting(true);
    await submitForm(formData);
    setIsSubmitting(false);
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      {/* Error Summary */}
      {Object.keys(errors).length > 0 && (
        <div
          ref={errorSummaryRef}
          role="alert"
          aria-labelledby="error-summary-title"
          className="error-summary"
          tabIndex={-1}
        >
          <h2 id="error-summary-title">There are {Object.keys(errors).length} errors</h2>
          <ul>
            {Object.entries(errors).map(([field, message]) => (
              <li key={field}>
                <a href={`#${field}`}>{message}</a>
              </li>
            ))}
          </ul>
        </div>
      )}
      
      {/* Form fields */}
      <FormFields data={formData} errors={errors} onChange={setFormData} />
      
      <button
        type="submit"
        disabled={isSubmitting}
        aria-busy={isSubmitting}
      >
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}

Multi-Step Forms

export function MultiStepForm() {
  const [step, setStep] = useState(1);
  const [formData, setFormData] = useState({});
  const stepHeaderRef = useRef<HTMLHeadingElement>(null);
  const totalSteps = 3;

  useEffect(() => {
    // Focus step header on step change
    stepHeaderRef.current?.focus();
    
    // Announce step change
    announceToScreenReader(`Step ${step} of ${totalSteps}`);
  }, [step]);

  const nextStep = () => {
    if (step < totalSteps) {
      setStep(step + 1);
      window.scrollTo(0, 0);
    }
  };

  const prevStep = () => {
    if (step > 1) {
      setStep(step - 1);
      window.scrollTo(0, 0);
    }
  };

  return (
    <form>
      {/* Progress Indicator */}
      <div
        role="progressbar"
        aria-valuenow={step}
        aria-valuemin={1}
        aria-valuemax={totalSteps}
        aria-label={`Step ${step} of ${totalSteps}`}
      >
        <div className="progress-bar">
          {Array.from({ length: totalSteps }).map((_, i) => (
            <div
              key={i}
              className={i < step ? 'completed' : i === step - 1 ? 'current' : 'future'}
              aria-current={i === step - 1 ? 'step' : undefined}
            >
              {i + 1}
            </div>
          ))}
        </div>
      </div>
      
      {/* Step Content */}
      <div role="group" aria-labelledby="step-title">
        <h2 id="step-title" ref={stepHeaderRef} tabIndex={-1}>
          {getStepTitle(step)}
        </h2>
        
        {step === 1 && <Step1 data={formData} onChange={setFormData} />}
        {step === 2 && <Step2 data={formData} onChange={setFormData} />}
        {step === 3 && <Step3 data={formData} onChange={setFormData} />}
      </div>
      
      {/* Navigation */}
      <div className="form-navigation">
        <button
          type="button"
          onClick={prevStep}
          disabled={step === 1}
          aria-label="Go to previous step"
        >
          Previous
        </button>
        
        {step < totalSteps ? (
          <button
            type="button"
            onClick={nextStep}
            aria-label="Go to next step"
          >
            Next
          </button>
        ) : (
          <button type="submit">Submit</button>
        )}
      </div>
    </form>
  );
}

Conditional Fields

export function ConditionalForm() {
  const [hasAccount, setHasAccount] = useState(false);
  const [showDetails, setShowDetails] = useState(false);

  return (
    <form>
      {/* Primary Question */}
      <fieldset>
        <legend>Do you have an existing account?</legend>
        <div>
          <input
            id="has-account-yes"
            type="radio"
            name="hasAccount"
            value="yes"
            checked={hasAccount}
            onChange={() => setHasAccount(true)}
            aria-controls="account-details"
          />
          <label htmlFor="has-account-yes">Yes</label>
        </div>
        <div>
          <input
            id="has-account-no"
            type="radio"
            name="hasAccount"
            value="no"
            checked={!hasAccount}
            onChange={() => setHasAccount(false)}
          />
          <label htmlFor="has-account-no">No</label>
        </div>
      </fieldset>
      
      {/* Conditional Fields */}
      {hasAccount && (
        <div
          id="account-details"
          role="group"
          aria-labelledby="account-details-title"
        >
          <h3 id="account-details-title">Account Details</h3>
          
          <label htmlFor="username">Username</label>
          <input id="username" type="text" required />
          
          <label htmlFor="password">Password</label>
          <input id="password" type="password" required />
        </div>
      )}
      
      {!hasAccount && (
        <div>
          <p>You'll need to create an account first.</p>
          <a href="/signup">Create Account</a>
        </div>
      )}
    </form>
  );
}

File Upload

export function AccessibleFileUpload() {
  const [files, setFiles] = useState<File[]>([]);
  const [error, setError] = useState('');
  const inputRef = useRef<HTMLInputElement>(null);

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const selectedFiles = Array.from(e.target.files || []);
    
    // Validate
    const maxSize = 5 * 1024 * 1024; // 5MB
    const invalidFiles = selectedFiles.filter(f => f.size > maxSize);
    
    if (invalidFiles.length > 0) {
      setError(`Files must be under 5MB: ${invalidFiles.map(f => f.name).join(', ')}`);
      return;
    }
    
    setFiles(selectedFiles);
    setError('');
    
    // Announce
    announceToScreenReader(`${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''} selected`);
  };

  const removeFile = (index: number) => {
    const newFiles = files.filter((_, i) => i !== index);
    setFiles(newFiles);
    
    // Return focus to input
    inputRef.current?.focus();
    
    announceToScreenReader(`File removed. ${newFiles.length} file${newFiles.length !== 1 ? 's' : ''} remaining`);
  };

  return (
    <div>
      <label htmlFor="file-upload">
        Upload Files
        <span className="hint">(Max 5MB each)</span>
      </label>
      
      <input
        ref={inputRef}
        id="file-upload"
        type="file"
        multiple
        accept=".pdf,.doc,.docx"
        onChange={handleFileChange}
        aria-describedby="file-hint file-error"
        aria-invalid={!!error}
      />
      
      <div id="file-hint" className="hint">
        Accepted formats: PDF, DOC, DOCX
      </div>
      
      {error && (
        <div id="file-error" role="alert" className="error">
          {error}
        </div>
      )}
      
      {/* File List */}
      {files.length > 0 && (
        <ul aria-label="Selected files">
          {files.map((file, index) => (
            <li key={`${file.name}-${index}`}>
              <span>{file.name}</span>
              <span className="sr-only">
                {(file.size / 1024).toFixed(2)} KB
              </span>
              <button
                type="button"
                onClick={() => removeFile(index)}
                aria-label={`Remove ${file.name}`}
              >

              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Date Picker

export function AccessibleDatePicker() {
  const [date, setDate] = useState('');
  const [showCalendar, setShowCalendar] = useState(false);

  return (
    <div>
      <label htmlFor="date-input">Select Date</label>
      
      <div className="date-picker">
        <input
          id="date-input"
          type="date"
          value={date}
          onChange={(e) => setDate(e.target.value)}
          aria-describedby="date-hint"
        />
        
        <button
          type="button"
          onClick={() => setShowCalendar(!showCalendar)}
          aria-label="Toggle calendar"
          aria-expanded={showCalendar}
          aria-controls="calendar"
        >
          📅
        </button>
      </div>
      
      <div id="date-hint" className="hint">
        Format: MM/DD/YYYY
      </div>
      
      {/* Alternative: Use native date input for best accessibility */}
      {/* Native date pickers are already accessible */}
    </div>
  );
}

Autocomplete

export function AccessibleAutocomplete() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<string[]>([]);
  const [selectedIndex, setSelectedIndex] = useState(-1);
  const [isOpen, setIsOpen] = useState(false);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (!isOpen) return;

    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setSelectedIndex(prev => Math.min(prev + 1, results.length - 1));
        break;
      case 'ArrowUp':
        e.preventDefault();
        setSelectedIndex(prev => Math.max(prev - 1, -1));
        break;
      case 'Enter':
        if (selectedIndex >= 0) {
          e.preventDefault();
          selectResult(results[selectedIndex]);
        }
        break;
      case 'Escape':
        setIsOpen(false);
        setSelectedIndex(-1);
        break;
    }
  };

  const selectResult = (value: string) => {
    setQuery(value);
    setIsOpen(false);
    setSelectedIndex(-1);
  };

  useEffect(() => {
    if (query.length > 2) {
      searchAPI(query).then(setResults);
      setIsOpen(true);
    } else {
      setIsOpen(false);
    }
  }, [query]);

  return (
    <div className="autocomplete">
      <label htmlFor="search-input">Search</label>
      
      <input
        id="search-input"
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        onKeyDown={handleKeyDown}
        autoComplete="off"
        aria-autocomplete="list"
        aria-controls="search-results"
        aria-expanded={isOpen}
        aria-activedescendant={
          selectedIndex >= 0 ? `result-${selectedIndex}` : undefined
        }
      />
      
      {isOpen && results.length > 0 && (
        <ul
          id="search-results"
          role="listbox"
          aria-label="Search results"
        >
          {results.map((result, index) => (
            <li
              key={index}
              id={`result-${index}`}
              role="option"
              aria-selected={index === selectedIndex}
              onClick={() => selectResult(result)}
            >
              {result}
            </li>
          ))}
        </ul>
      )}
      
      {/* Announce results */}
      <div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
        {isOpen && `${results.length} result${results.length !== 1 ? 's' : ''} available`}
      </div>
    </div>
  );
}

Best Practices

  1. Label everything: Every input needs a label
  2. Required indicators: Mark required fields clearly
  3. Error messages: Specific, helpful, announced
  4. Focus management: Focus errors, announce changes
  5. Keyboard navigation: Tab order, arrow keys
  6. ARIA attributes: aria-invalid, aria-describedby
  7. Validation timing: On blur, not every keystroke
  8. Error summary: List all errors at top
  9. Progress indicators: Show current step
  10. Success confirmation: Announce successful submission

Common Pitfalls

Placeholder as label: Disappears on focus
Use persistent labels

No error messages: User confused
Specific, actionable error messages

Color-only errors: Red border only
Icons, text, ARIA attributes

Auto-focus on page load: Unexpected
Focus errors after submit

No keyboard support: Tab doesn't work
Full keyboard navigation

Complex forms require careful accessibility—validate thoroughly, manage focus, and test with real assistive technologies!

On this page