ux-form-design

matthewharwood's avatarfrom matthewharwood

Form and input design patterns including validation, labels, error handling, and form-associated custom elements. Use when building forms, inputs, or interactive data collection. (project)

⭐0stars🔀0forks📁View on GitHub🕐Updated Dec 9, 2025

When & Why to Use This Skill

This Claude skill provides a comprehensive framework for modern UX form design, focusing on accessible, high-performance data collection interfaces. It offers standardized patterns for form-associated custom elements, robust validation logic, and responsive CSS layouts to streamline front-end development and ensure a seamless user experience.

Use Cases

  • Developing accessible web forms with proper ARIA labeling and real-time error handling to improve usability and compliance.
  • Implementing encapsulated UI components using Web Components and Shadow DOM for consistent form styling and behavior across complex projects.
  • Creating advanced input behaviors such as auto-resizing textareas and specialized touch targets for mobile-optimized forms.
  • Standardizing form validation workflows using modern browser APIs like ElementInternals to bridge the gap between custom elements and native form submission.
nameux-form-design
descriptionForm and input design patterns including validation, labels, error handling, and form-associated custom elements. Use when building forms, inputs, or interactive data collection. (project)

UX Form Design Skill

Form patterns for data collection, validation, and user feedback. This skill covers accessible form design with custom elements.

Form-Associated Custom Elements

Basic Setup

Important: Store element references during construction - NEVER use querySelector.

class CustomInput extends HTMLElement {
  static formAssociated = true;

  // Direct element references - created in constructor
  #input;
  #label;
  #hint;
  #error;

  constructor() {
    super();
    this.internals = this.attachInternals();
    this.attachShadow({ mode: 'open' });

    // Build DOM and store direct references
    this.#label = document.createElement('label');
    this.#label.setAttribute('part', 'label');

    this.#input = document.createElement('input');
    this.#input.setAttribute('part', 'input');

    this.#hint = document.createElement('span');
    this.#hint.className = 'hint';
    this.#hint.setAttribute('part', 'hint');

    this.#error = document.createElement('span');
    this.#error.className = 'error';
    this.#error.setAttribute('role', 'alert');
    this.#error.setAttribute('part', 'error');

    // Assemble shadow DOM
    const field = document.createElement('div');
    field.className = 'field';
    field.appendChild(this.#label);
    field.appendChild(this.#input);
    field.appendChild(this.#hint);
    field.appendChild(this.#error);

    this.shadowRoot.appendChild(field);
  }

  connectedCallback() {
    this.addEventListener('input', this);
    this.addEventListener('blur', this);
  }

  disconnectedCallback() {
    this.removeEventListener('input', this);
    this.removeEventListener('blur', this);
  }

  // Required: Set form value
  set value(val) {
    this.#input.value = val;
    this.internals.setFormValue(val);
  }

  get value() {
    return this.#input.value;
  }

  // Form lifecycle
  formResetCallback() {
    this.value = '';
  }

  formDisabledCallback(disabled) {
    this.toggleAttribute('disabled', disabled);
    this.#input.disabled = disabled;
  }
}

Validation

validate() {
  const value = this.#input.value.trim();  // Direct reference

  if (!value && this.hasAttribute('required')) {
    this.internals.setValidity(
      { valueMissing: true },
      'This field is required',
      this.#input  // Direct reference
    );
    this.setAttribute('aria-invalid', 'true');
    return false;
  }

  // Clear validation
  this.internals.setValidity({});
  this.removeAttribute('aria-invalid');
  return true;
}

Input Field Structure

Anatomy

<div class="field">
  <label class="label" for="input-id">Field Label</label>
  <input class="input" id="input-id" aria-describedby="hint-id error-id">
  <span class="hint" id="hint-id">Optional hint text</span>
  <span class="error" id="error-id" role="alert"></span>
</div>

CSS

.field {
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
}

.label {
  font-family: var(--font-display);
  font-size: var(--step--1);
  font-weight: 600;
  color: var(--theme-on-surface);
}

.input {
  padding: var(--space-s);
  border: 1px solid var(--theme-outline);
  border-radius: var(--space-2xs);
  background: var(--theme-surface-variant);
  color: var(--theme-on-surface);
  font-size: var(--step-0);
  font-family: var(--font-sans);
}

.input:focus {
  outline: none;
  border-color: var(--theme-primary);
  box-shadow: 0 0 0 3px var(--color-active-overlay);
}

.hint {
  font-size: var(--step--2);
  color: var(--theme-on-surface-variant);
}

.error {
  font-size: var(--step--2);
  color: var(--color-error);
}

Textarea (Auto-Resize)

Modern Approach (field-sizing)

.textarea {
  field-sizing: content;
  min-height: 3lh;
  max-height: 12lh;
  overflow-y: auto;
}

Fallback for Older Browsers

Use direct element references (created in constructor):

class AutoResizeTextarea extends HTMLElement {
  #textarea;  // Direct reference - NO querySelector
  #maxHeight = 300;

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });

    this.#textarea = document.createElement('textarea');
    this.#textarea.setAttribute('part', 'textarea');
    this.shadowRoot.appendChild(this.#textarea);
  }

  connectedCallback() {
    if (!CSS.supports('field-sizing', 'content')) {
      this.addEventListener('input', this);
    }
  }

  disconnectedCallback() {
    this.removeEventListener('input', this);
  }

  handleEvent(e) {
    if (e.type === 'input') {
      this.#autoResize();
    }
  }

  #autoResize() {
    this.#textarea.style.height = 'auto';
    this.#textarea.style.height = `${Math.min(this.#textarea.scrollHeight, this.#maxHeight)}px`;
  }
}

Form Layout

Vertical Stack

.form {
  display: flex;
  flex-direction: column;
  gap: var(--space-m);
}

Inline Fields

.form-row {
  display: flex;
  gap: var(--space-s);
  flex-wrap: wrap;
}

.form-row > * {
  flex: 1;
  min-width: 150px;
}

Form Actions

.form-actions {
  display: flex;
  justify-content: flex-end;
  gap: var(--space-s);
  margin-block-start: var(--space-m);
}

Validation Patterns

Real-Time Validation

handleEvent(e) {
  if (e.type === 'input') {
    // Validate on input after first blur
    if (this.#touched) {
      this.validate();
    }
  }
  if (e.type === 'blur') {
    this.#touched = true;
    this.validate();
  }
}

Submit Validation

Use direct element references (stored during construction):

// Assumes #input, #container, #error are private fields
submit() {
  const value = this.#input.value.trim();  // Direct reference

  if (!value) {
    this.#input.focus();  // Direct reference
    this.internals.setValidity(
      { valueMissing: true },
      'Please enter a value',
      this.#input  // Direct reference
    );
    // Visual shake feedback using Anime.js
    import { shake } from '../../utils/animations.js';
    shake(this.#container);  // Direct reference
    return;
  }

  // Clear and submit
  this.internals.setValidity({});
  this.dispatchEvent(new CustomEvent('form-submit', {
    bubbles: true,
    composed: true,
    detail: { value }
  }));
}

Error Display

// Assumes #error and #input are private fields
showError(message) {
  this.#error.textContent = message;  // Direct reference
  this.setAttribute('aria-invalid', 'true');
}

clearError() {
  this.#error.textContent = '';  // Direct reference
  this.removeAttribute('aria-invalid');
}

Accessibility Requirements

Labels

Every input MUST have an associated label:

<!-- Explicit association -->
<label for="name">Name</label>
<input id="name">

<!-- Implicit association -->
<label>
  Name
  <input>
</label>

<!-- ARIA label for icon-only -->
<input aria-label="Search">

Required Fields

<label>
  Email <span aria-hidden="true">*</span>
  <span class="sr-only">(required)</span>
</label>
<input required aria-required="true">

Error Association

<input aria-invalid="true" aria-describedby="email-error">
<span id="email-error" role="alert">Please enter a valid email</span>

Keyboard Submission

Support Ctrl/Cmd+Enter for textarea forms:

handleEvent(e) {
  if (e.type === 'keydown') {
    if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
      e.preventDefault();
      this.submit();
    }
  }
}

Input Types

Text Variations

<input type="text" inputmode="text">
<input type="email" inputmode="email">
<input type="tel" inputmode="tel">
<input type="url" inputmode="url">
<input type="number" inputmode="numeric">

Autocomplete

<input name="name" autocomplete="name">
<input name="email" autocomplete="email">
<input name="current-password" autocomplete="current-password">

Placeholder Best Practices

Do

/* Subtle placeholder */
.input::placeholder {
  color: var(--theme-on-surface-variant);
  opacity: 0.7;
}

Don't

  • Never use placeholder as label replacement
  • Avoid long placeholder text
  • Don't include required format in placeholder alone

Correct Pattern

<label for="phone">Phone Number</label>
<input id="phone" placeholder="555-555-5555" aria-describedby="phone-format">
<span id="phone-format" class="hint">Format: XXX-XXX-XXXX</span>

Touch Targets

Ensure inputs meet minimum touch target size:

.input {
  min-height: var(--min-touch-target);
  padding: var(--space-s);
}

.checkbox-wrapper {
  min-width: var(--min-touch-target);
  min-height: var(--min-touch-target);
  display: flex;
  align-items: center;
  justify-content: center;
}

Disabled vs Read-Only

/* Disabled: Cannot interact */
.input:disabled {
  opacity: 0.6;
  cursor: not-allowed;
  background: var(--theme-surface);
}

/* Read-only: Can select/copy but not edit */
.input:read-only {
  background: var(--theme-surface);
  border-style: dashed;
}