Agent Skills: UX Form Design Skill

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)

UncategorizedID: matthewharwood/fantasy-phonics/ux-form-design

Install this agent skill to your local

pnpm dlx add-skill https://github.com/matthewharwood/fantasy-phonics/tree/HEAD/.claude/skills/ux-form-design

Skill Files

Browse the full folder contents for ux-form-design.

Download Skill

Loading file tree…

.claude/skills/ux-form-design/SKILL.md

Skill Metadata

Name
ux-form-design
Description
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)

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;
}