---
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)
allowed-tools:
- Read
- Write
- Edit
- Glob
- Grep
---
# 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.
```javascript
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
```javascript
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
```html
Optional hint text
```
### CSS
```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)
```css
.textarea {
field-sizing: content;
min-height: 3lh;
max-height: 12lh;
overflow-y: auto;
}
```
### Fallback for Older Browsers
Use direct element references (created in constructor):
```javascript
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
```css
.form {
display: flex;
flex-direction: column;
gap: var(--space-m);
}
```
### Inline Fields
```css
.form-row {
display: flex;
gap: var(--space-s);
flex-wrap: wrap;
}
.form-row > * {
flex: 1;
min-width: 150px;
}
```
### Form Actions
```css
.form-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-s);
margin-block-start: var(--space-m);
}
```
## Validation Patterns
### Real-Time Validation
```javascript
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):
```javascript
// 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
```javascript
// 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:
```html
```
### Required Fields
```html
```
### Error Association
```html
Please enter a valid email
```
### Keyboard Submission
Support Ctrl/Cmd+Enter for textarea forms:
```javascript
handleEvent(e) {
if (e.type === 'keydown') {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.submit();
}
}
}
```
## Input Types
### Text Variations
```html
```
### Autocomplete
```html
```
## Placeholder Best Practices
### Do
```css
/* 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
```html
Format: XXX-XXX-XXXX
```
## Touch Targets
Ensure inputs meet minimum touch target size:
```css
.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
```css
/* 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;
}
```