ux-form-design
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;
}
More from matthewharwood/fantasy-phonics
audio-design
Game audio design patterns for creating sound effects and UI audio. Use when designing sounds for games, writing AI audio prompts (ElevenLabs, etc.), creating feedback sounds, or specifying audio for abilities/UI. Includes psychological principles from Kind Games, volume hierarchy, frequency masking prevention, and prompt engineering for AI audio generation.
10ux-design-principles
Cognitive psychology principles for UX design decisions. Use when planning features, structuring interfaces, reducing complexity, or optimizing user journeys. Covers choice architecture, cognitive load, attention, and experience design. (project)
9web-audio
Production-tested patterns for fault-tolerant browser audio with zero-lag rapid-fire support. Use when implementing sound effects, background music, voice feedback, or any audio playback in web applications. Covers AudioContext singleton, preloading, cloneNode for rapid-fire, autoplay handling, and Web Audio API effects.
8ux-typography
Typography patterns using Utopia fluid type scale with cqi units. Use when setting font sizes, line heights, font families, or text styling. Covers display vs body fonts, hierarchy, and readability. (project)
6ux-user-flow
Navigation and user flow patterns including routing, state management, progressive disclosure, and game progression. Use when designing multi-step flows, game phases, or navigation structures. (project)
5utopia-grid-layout
CSS Grid utilities using Utopia fluid spacing. Reference for the grid variables and utility classes defined in this project.
5