HTML5 Form Validation and User Experience

1. Required Fields and Validation States

Attribute/Pseudo-class Purpose Applied To Behavior
required Mark field as mandatory input, select, textarea Prevents submission if empty
:valid Style valid inputs All form controls Matches when validation passes
:invalid Style invalid inputs All form controls Matches when validation fails
:required Style required fields input, select, textarea Matches fields with required attribute
:optional Style optional fields input, select, textarea Matches fields without required
:user-invalid Style invalid after user interaction All form controls Only triggers after user edits/blurs
:placeholder-shown When placeholder is visible input, textarea Empty field showing placeholder
:blank Empty field (experimental) input, textarea Field has no value

Validation Timing

When What Validates
On Submit All fields checked, first error focused
On Blur Individual field (with JS)
On Input Real-time validation (with JS)
Immediately Type-specific validation (email, url)

Validity States (JS)

Property Description
valid All constraints satisfied
valueMissing Required field is empty
typeMismatch Type doesn't match (email, url)
patternMismatch Pattern constraint failed
tooLong Exceeds maxlength
tooShort Below minlength
rangeUnderflow Below min value
rangeOverflow Above max value
stepMismatch Not a valid step

Example: Required fields and validation states

<!-- HTML: Required fields -->
<form id="signupForm">
  <label for="email">Email (required):</label>
  <input type="email" id="email" name="email" required>
  
  <label for="username">Username (required):</label>
  <input type="text" id="username" name="username" required minlength="3">
  
  <label for="age">Age (optional):</label>
  <input type="number" id="age" name="age" min="13">
  
  <button type="submit">Sign Up</button>
</form>

<!-- CSS: Validation state styling -->
<style>
  /* Style all required fields */
  input:required {
    border-left: 3px solid #ff9800;
  }
  
  /* Valid state (avoid on page load) */
  input:not(:placeholder-shown):valid {
    border-color: #4caf50;
    background-image: url('check-icon.svg');
    background-repeat: no-repeat;
    background-position: right 10px center;
  }
  
  /* Invalid state (only after user interaction) */
  input:user-invalid,
  input:not(:placeholder-shown):invalid {
    border-color: #f44336;
    background-color: #ffebee;
  }
  
  /* Focus states */
  input:invalid:focus {
    outline: 2px solid #f44336;
  }
  
  input:valid:focus {
    outline: 2px solid #4caf50;
  }
  
  /* Optional fields (subtle styling) */
  input:optional {
    border-left: 3px solid #ccc;
  }
</style>

<!-- JavaScript: Check validity -->
<script>
  const form = document.getElementById('signupForm');
  const email = document.getElementById('email');
  
  form.addEventListener('submit', (e) => {
    if (!form.checkValidity()) {
      e.preventDefault();
      alert('Please fill in all required fields correctly.');
    }
  });
  
  // Check individual field validity
  email.addEventListener('blur', () => {
    const validity = email.validity;
    
    if (validity.valueMissing) {
      console.log('Email is required');
    } else if (validity.typeMismatch) {
      console.log('Invalid email format');
    } else if (validity.valid) {
      console.log('Email is valid');
    }
  });
</script>
Warning: :invalid triggers immediately on page load for empty required fields. Use :user-invalid or :not(:placeholder-shown):invalid to only style after user interaction.

2. Pattern Validation and Regular Expressions

Pattern Regex Use Case Example
ZIP Code (US) [0-9]{5} 5-digit postal code 12345
ZIP+4 [0-9]{5}(-[0-9]{4})? Extended ZIP 12345-6789
Phone (US) \d{3}-\d{3}-\d{4} Formatted phone 555-123-4567
Phone (Flexible) [\d\s\-\(\)]+ Various formats (555) 123-4567
Credit Card \d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4} 16-digit card 1234-5678-9012-3456
Username [a-zA-Z0-9_]{3,16} Alphanumeric + underscore user_name123
Hex Color #?([a-fA-F0-9]{6}|[a-fA-F0-9]{3}) Hex color code #ff5733
URL Slug [a-z0-9]+(?:-[a-z0-9]+)* URL-friendly string my-article-title
IPv4 ^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$ IP address 192.168.1.1
Strong Password ^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$ Min 8, upper, lower, digit, special Pass@123
Date (MM/DD/YYYY) (0[1-9]|1[0-2])\/(0[1-9]|[12][0-9]|3[01])\/\d{4} US date format 12/31/2024
Time (24hr) ([01]?[0-9]|2[0-3]):[0-5][0-9] HH:MM format 14:30

Example: Pattern validation with helpful messages

<!-- ZIP Code -->
<label for="zip">ZIP Code:</label>
<input type="text" id="zip" name="zip"
       pattern="[0-9]{5}"
       title="Please enter a 5-digit ZIP code"
       placeholder="12345"
       required>

<!-- Phone Number -->
<label for="phone">Phone:</label>
<input type="tel" id="phone" name="phone"
       pattern="\d{3}-\d{3}-\d{4}"
       title="Format: 555-123-4567"
       placeholder="555-123-4567">

<!-- Username -->
<label for="username">Username:</label>
<input type="text" id="username" name="username"
       pattern="[a-zA-Z0-9_]{3,16}"
       title="3-16 characters: letters, numbers, and underscores only"
       placeholder="user_name"
       required>

<!-- Strong Password -->
<label for="password">Password:</label>
<input type="password" id="password" name="password"
       pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$"
       title="Minimum 8 characters, at least one uppercase, lowercase, number, and special character"
       required>

<!-- Credit Card -->
<label for="card">Credit Card:</label>
<input type="text" id="card" name="card"
       pattern="\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}"
       title="Enter 16-digit card number"
       placeholder="1234-5678-9012-3456"
       maxlength="19">

<!-- URL Slug -->
<label for="slug">Article Slug:</label>
<input type="text" id="slug" name="slug"
       pattern="[a-z0-9]+(?:-[a-z0-9]+)*"
       title="Lowercase letters, numbers, and hyphens only"
       placeholder="my-article-title">

<!-- Hex Color -->
<label for="color">Hex Color:</label>
<input type="text" id="color" name="color"
       pattern="#?([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"
       title="Enter hex color code (e.g., #ff5733 or #fff)"
       placeholder="#ff5733">

<!-- Custom validation message (JavaScript) -->
<script>
  const usernameInput = document.getElementById('username');
  
  usernameInput.addEventListener('invalid', (e) => {
    if (usernameInput.validity.patternMismatch) {
      usernameInput.setCustomValidity('Username must be 3-16 characters and contain only letters, numbers, and underscores.');
    } else {
      usernameInput.setCustomValidity('');
    }
  });
  
  usernameInput.addEventListener('input', () => {
    usernameInput.setCustomValidity('');
  });
</script>
Note: Always include title attribute with pattern - it's shown in the validation message. Regex in pattern is automatically anchored (^...$), so don't add anchors. Use setCustomValidity() for better error messages.

3. Custom Validation Messages and Styling

Method/Property Purpose Usage Returns/Effect
checkValidity() Check if element is valid element.checkValidity() Boolean (true/false)
reportValidity() Check and show validation UI element.reportValidity() Boolean + shows browser message
setCustomValidity() Set custom error message element.setCustomValidity('msg') Sets validation message
validationMessage Get current validation message element.validationMessage String (error message)
validity Access validity state object element.validity.valid ValidityState object

ValidityState Properties

Property Triggered By
valueMissing required field empty
typeMismatch Invalid email/url format
patternMismatch pattern not matched
tooLong Exceeds maxlength
tooShort Below minlength
rangeUnderflow Below min
rangeOverflow Above max
stepMismatch Invalid step
customError setCustomValidity() called
Styling Strategies:
  • Use :invalid / :valid for CSS styling
  • Combine with :focus for interactive feedback
  • Use :user-invalid for post-interaction styling
  • Add icons/colors to indicate validation state
  • Show inline error messages below fields
  • Use aria-invalid for accessibility

Example: Custom validation messages and styling

<!-- HTML: Form with custom validation -->
<form id="customForm" novalidate>
  <div class="form-group">
    <label for="email">Email:</label>
    <input type="email" id="email" name="email" required>
    <span class="error-message" id="email-error"></span>
  </div>
  
  <div class="form-group">
    <label for="password">Password:</label>
    <input type="password" id="password" name="password" required minlength="8">
    <span class="error-message" id="password-error"></span>
  </div>
  
  <div class="form-group">
    <label for="confirm-password">Confirm Password:</label>
    <input type="password" id="confirm-password" name="confirm-password" required>
    <span class="error-message" id="confirm-error"></span>
  </div>
  
  <button type="submit">Submit</button>
</form>

<!-- CSS: Custom validation styling -->
<style>
  .form-group {
    margin-bottom: 20px;
    position: relative;
  }
  
  input {
    width: 100%;
    padding: 10px;
    border: 2px solid #ddd;
    border-radius: 4px;
    transition: border-color 0.3s;
  }
  
  /* Valid state */
  input:valid:not(:placeholder-shown) {
    border-color: #4caf50;
  }
  
  input:valid:not(:placeholder-shown)::after {
    content: '✓';
    color: #4caf50;
    position: absolute;
    right: 10px;
  }
  
  /* Invalid state */
  input:invalid:not(:placeholder-shown),
  input[aria-invalid="true"] {
    border-color: #f44336;
    background-color: #ffebee;
  }
  
  /* Error message styling */
  .error-message {
    display: none;
    color: #f44336;
    font-size: 0.875em;
    margin-top: 5px;
  }
  
  .error-message.visible {
    display: block;
  }
  
  /* Focus states */
  input:focus {
    outline: none;
    border-color: #2196f3;
    box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.1);
  }
</style>

<!-- JavaScript: Custom validation logic -->
<script>
  const form = document.getElementById('customForm');
  const email = document.getElementById('email');
  const password = document.getElementById('password');
  const confirmPassword = document.getElementById('confirm-password');
  
  // Custom error messages
  const errorMessages = {
    valueMissing: 'This field is required.',
    typeMismatch: {
      email: 'Please enter a valid email address.',
      url: 'Please enter a valid URL.'
    },
    tooShort: (minLength) => `Please enter at least ${minLength} characters.`,
    patternMismatch: 'Please match the requested format.',
  };
  
  // Validate individual field
  function validateField(field) {
    const errorElement = document.getElementById(`${field.id}-error`);
    const validity = field.validity;
    let errorMessage = '';
    
    if (validity.valueMissing) {
      errorMessage = errorMessages.valueMissing;
    } else if (validity.typeMismatch) {
      errorMessage = errorMessages.typeMismatch[field.type] || 'Invalid format.';
    } else if (validity.tooShort) {
      errorMessage = errorMessages.tooShort(field.minLength);
    } else if (validity.patternMismatch) {
      errorMessage = field.title || errorMessages.patternMismatch;
    }
    
    // Custom password match validation
    if (field === confirmPassword && field.value !== password.value) {
      errorMessage = 'Passwords do not match.';
      field.setCustomValidity(errorMessage);
    } else if (field === confirmPassword) {
      field.setCustomValidity('');
    }
    
    // Display error
    if (errorMessage) {
      errorElement.textContent = errorMessage;
      errorElement.classList.add('visible');
      field.setAttribute('aria-invalid', 'true');
      return false;
    } else {
      errorElement.textContent = '';
      errorElement.classList.remove('visible');
      field.removeAttribute('aria-invalid');
      return true;
    }
  }
  
  // Validate on blur
  [email, password, confirmPassword].forEach(field => {
    field.addEventListener('blur', () => validateField(field));
    field.addEventListener('input', () => {
      if (field.getAttribute('aria-invalid') === 'true') {
        validateField(field);
      }
    });
  });
  
  // Validate on submit
  form.addEventListener('submit', (e) => {
    e.preventDefault();
    
    const fields = [email, password, confirmPassword];
    const isValid = fields.every(validateField);
    
    if (isValid) {
      console.log('Form is valid, submitting...');
      // form.submit();
    } else {
      // Focus first invalid field
      const firstInvalid = fields.find(field => !field.validity.valid);
      firstInvalid?.focus();
    }
  });
</script>
Warning: When using setCustomValidity(), you must clear it with setCustomValidity('') when the field becomes valid, or the field will remain invalid even if the constraint is satisfied.

4. HTML5 Input Constraints and Limits

Constraint Attribute Applies To Example
Required required Most inputs, select, textarea <input required>
Min Length minlength="N" text, email, url, tel, password, search, textarea <input minlength="3">
Max Length maxlength="N" text, email, url, tel, password, search, textarea <input maxlength="50">
Min Value min="N" number, range, date, time, datetime-local, month, week <input type="number" min="0">
Max Value max="N" number, range, date, time, datetime-local, month, week <input type="number" max="100">
Step step="N" number, range, date, time, datetime-local, month, week <input type="number" step="0.01">
Pattern pattern="regex" text, email, url, tel, password, search <input pattern="[0-9]{5}">
Accept accept="types" file <input type="file" accept="image/*">
Multiple multiple email, file, select <input type="email" multiple>

Step Values

Step Use Case
1 Integers only (default)
0.01 Decimal values (currency)
0.1 Single decimal place
5 Multiples of 5
any No step constraint
900 (time) 15-minute intervals

Accept MIME Types

Value Accepts
image/* Any image type
video/* Any video type
audio/* Any audio type
.pdf PDF files only
.jpg,.png Specific extensions
image/png Specific MIME type

Example: Input constraints in practice

<!-- Length constraints -->
<label for="username">Username (3-15 characters):</label>
<input type="text" id="username" name="username"
       minlength="3" maxlength="15" required>

<!-- Number range with step -->
<label for="price">Price ($):</label>
<input type="number" id="price" name="price"
       min="0" max="10000" step="0.01" 
       placeholder="0.00">

<!-- Age restriction -->
<label for="age">Age (must be 18+):</label>
<input type="number" id="age" name="age"
       min="18" max="120" required>

<!-- Date range (current year only) -->
<label for="event-date">Event Date:</label>
<input type="date" id="event-date" name="event-date"
       min="2024-01-01" max="2024-12-31" required>

<!-- Time with 15-minute intervals -->
<label for="appointment">Appointment Time:</label>
<input type="time" id="appointment" name="appointment"
       min="09:00" max="17:00" step="900"> <!-- 900 seconds = 15 min -->

<!-- Percentage (0-100 with 0.1 precision) -->
<label for="discount">Discount (%):</label>
<input type="number" id="discount" name="discount"
       min="0" max="100" step="0.1">

<!-- File upload (images only) -->
<label for="avatar">Profile Picture:</label>
<input type="file" id="avatar" name="avatar"
       accept="image/png, image/jpeg, image/webp"
       required>

<!-- Multiple file upload -->
<label for="documents">Upload Documents:</label>
<input type="file" id="documents" name="documents"
       accept=".pdf,.doc,.docx"
       multiple>

<!-- Multiple emails -->
<label for="recipients">Email Recipients:</label>
<input type="email" id="recipients" name="recipients"
       multiple
       placeholder="email1@example.com, email2@example.com">

<!-- Range slider with step -->
<label for="volume">Volume (0-100, steps of 5):</label>
<input type="range" id="volume" name="volume"
       min="0" max="100" step="5" value="50">
<output for="volume">50</output>

<!-- No step constraint (any decimal) -->
<label for="precise">Precise Value:</label>
<input type="number" id="precise" name="precise"
       step="any">
Note: maxlength prevents typing beyond the limit, while minlength only validates on submit. Use step="any" to allow any decimal value. For time inputs, step is in seconds (900 = 15 minutes).

5. Form Submission and Method Handling

Method Use Case Data in URL Cacheable
GET Search, filter, idempotent operations Yes (query string) Yes
POST Create, update, delete, file uploads, sensitive data No (request body) No
DIALOG Close dialog without submission N/A N/A

Form Submission Events

Event When Fired
submit Form submitted (before send)
formdata FormData object created
invalid Validation fails on field
reset Form reset triggered

Submit Methods (JS)

Method Description
form.submit() Submit programmatically (no validation)
form.requestSubmit() Submit with validation (fires submit event)
form.reset() Reset to default values
new FormData(form) Extract form data as FormData object

Example: Form submission handling

<!-- GET form (search) -->
<form action="/search" method="GET">
  <input type="search" name="q" placeholder="Search...">
  <input type="checkbox" name="category" value="products"> Products
  <input type="checkbox" name="category" value="articles"> Articles
  <button type="submit">Search</button>
</form>
<!-- Submits to: /search?q=keyword&category=products&category=articles -->

<!-- POST form (user registration) -->
<form action="/register" method="POST" id="registerForm">
  <input type="text" name="username" required>
  <input type="email" name="email" required>
  <input type="password" name="password" required>
  <button type="submit">Register</button>
</form>

<!-- JavaScript: Intercept and handle submission -->
<script>
  const form = document.getElementById('registerForm');
  
  // Method 1: Prevent default and use Fetch API
  form.addEventListener('submit', async (e) => {
    e.preventDefault(); // Prevent traditional form submission
    
    // Get form data
    const formData = new FormData(form);
    
    // Convert to JSON (if needed)
    const data = Object.fromEntries(formData.entries());
    
    try {
      const response = await fetch('/register', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(data),
      });
      
      if (response.ok) {
        const result = await response.json();
        console.log('Success:', result);
        // Redirect or show success message
      } else {
        console.error('Error:', response.statusText);
      }
    } catch (error) {
      console.error('Network error:', error);
    }
  });
  
  // Method 2: FormData with all form fields
  form.addEventListener('submit', async (e) => {
    e.preventDefault();
    
    const formData = new FormData(form);
    
    // Send as multipart/form-data (good for file uploads)
    const response = await fetch('/register', {
      method: 'POST',
      body: formData, // Browser sets correct Content-Type
    });
  });
  
  // Method 3: Programmatic submission
  function submitForm() {
    if (form.checkValidity()) {
      // Triggers validation and submit event
      form.requestSubmit();
    } else {
      // Show validation errors
      form.reportValidity();
    }
  }
</script>

<!-- Multiple submit buttons with different actions -->
<form method="POST">
  <input type="text" name="content" required>
  
  <button type="submit" name="action" value="save">Save Draft</button>
  <button type="submit" name="action" value="publish">Publish</button>
  <button type="submit" name="action" value="delete" formnovalidate>Delete</button>
</form>

<!-- Override form attributes on button -->
<form action="/default" method="POST">
  <input type="email" name="email" required>
  
  <button type="submit">Submit to Default</button>
  <button type="submit" 
          formaction="/alternative"
          formmethod="GET"
          formnovalidate>
    Submit to Alternative (Skip Validation)
  </button>
</form>
Warning: form.submit() bypasses validation and doesn't fire the submit event. Use form.requestSubmit() instead to trigger validation. Use formnovalidate on submit buttons to skip validation for actions like "Save Draft" or "Delete".

6. Form Data Encoding and File Uploads

Encoding Type (enctype) Value Use Case Content-Type Header
URL Encoded application/x-www-form-urlencoded Standard forms (default) application/x-www-form-urlencoded
Multipart multipart/form-data File uploads multipart/form-data; boundary=...
Plain Text text/plain Debugging (not recommended) text/plain

File Input Attributes

Attribute Purpose
accept Filter file types in picker
multiple Allow multiple file selection
capture Use camera (mobile): user, environment
files FileList object (read-only, JS)

FormData Methods

Method Description
append() Add field (allows duplicates)
set() Set field (overwrites existing)
get() Get single value
getAll() Get all values for key
delete() Remove field
has() Check if field exists

Example: File uploads and form data encoding

<!-- File upload form -->
<form action="/upload" method="POST" enctype="multipart/form-data" id="uploadForm">
  <label for="file">Select File:</label>
  <input type="file" id="file" name="file" accept="image/*" required>
  
  <label for="description">Description:</label>
  <input type="text" id="description" name="description">
  
  <button type="submit">Upload</button>
</form>

<!-- Multiple file upload with preview -->
<form id="multiUploadForm">
  <input type="file" id="files" name="files" multiple accept="image/*">
  <div id="preview"></div>
  <button type="submit">Upload All</button>
</form>

<script>
  // Single file upload
  const uploadForm = document.getElementById('uploadForm');
  
  uploadForm.addEventListener('submit', async (e) => {
    e.preventDefault();
    
    const formData = new FormData(uploadForm);
    
    try {
      const response = await fetch('/upload', {
        method: 'POST',
        body: formData, // Browser automatically sets multipart/form-data
      });
      
      if (response.ok) {
        const result = await response.json();
        console.log('Upload successful:', result);
      }
    } catch (error) {
      console.error('Upload failed:', error);
    }
  });
  
  // Multiple files with preview
  const filesInput = document.getElementById('files');
  const preview = document.getElementById('preview');
  
  filesInput.addEventListener('change', (e) => {
    preview.innerHTML = ''; // Clear previous previews
    
    const files = e.target.files;
    
    for (let i = 0; i < files.length; i++) {
      const file = files[i];
      
      // Validate file
      if (!file.type.startsWith('image/')) {
        alert(`${file.name} is not an image`);
        continue;
      }
      
      if (file.size > 5 * 1024 * 1024) { // 5MB limit
        alert(`${file.name} is too large (max 5MB)`);
        continue;
      }
      
      // Create preview
      const reader = new FileReader();
      reader.onload = (event) => {
        const img = document.createElement('img');
        img.src = event.target.result;
        img.style.width = '100px';
        img.style.margin = '5px';
        preview.appendChild(img);
      };
      reader.readAsDataURL(file);
    }
  });
  
  // Multi-file upload
  const multiUploadForm = document.getElementById('multiUploadForm');
  
  multiUploadForm.addEventListener('submit', async (e) => {
    e.preventDefault();
    
    const formData = new FormData();
    const files = filesInput.files;
    
    // Add each file
    for (let i = 0; i < files.length; i++) {
      formData.append('files', files[i]);
    }
    
    // Add additional data
    formData.append('userId', '12345');
    formData.append('category', 'photos');
    
    const response = await fetch('/upload-multiple', {
      method: 'POST',
      body: formData,
    });
  });
  
  // Manual FormData construction
  const manualFormData = new FormData();
  
  // Add text fields
  manualFormData.append('username', 'john_doe');
  manualFormData.append('email', 'john@example.com');
  
  // Add file (from input element)
  const fileInput = document.getElementById('file');
  manualFormData.append('avatar', fileInput.files[0]);
  
  // Add file (blob/programmatically)
  const blob = new Blob(['Hello, world!'], { type: 'text/plain' });
  manualFormData.append('note', blob, 'note.txt');
  
  // Get values
  console.log(manualFormData.get('username')); // 'john_doe'
  console.log(manualFormData.getAll('username')); // ['john_doe']
  
  // Check if field exists
  console.log(manualFormData.has('email')); // true
  
  // Delete field
  manualFormData.delete('note');
  
  // Iterate over entries
  for (const [key, value] of manualFormData.entries()) {
    console.log(`${key}:`, value);
  }
</script>

<!-- Camera capture (mobile) -->
<form>
  <!-- Use front camera -->
  <input type="file" accept="image/*" capture="user">
  
  <!-- Use back camera -->
  <input type="file" accept="image/*" capture="environment">
  
  <!-- Video capture -->
  <input type="file" accept="video/*" capture>
</form>
Note: Always set enctype="multipart/form-data" for file uploads. When using FormData with fetch(), don't set Content-Type header - browser adds it automatically with boundary. Use capture attribute on mobile to directly access camera.

Section 8 Key Takeaways

  • Use :user-invalid or :not(:placeholder-shown):invalid to avoid styling empty fields as invalid
  • Always include title with pattern for user-friendly error messages
  • Use setCustomValidity() for custom validation, but remember to clear it with setCustomValidity('')
  • Use form.requestSubmit() instead of form.submit() to trigger validation
  • maxlength prevents typing, minlength validates on submit
  • Set enctype="multipart/form-data" for file uploads
  • Use formnovalidate on buttons for actions that should skip validation (e.g., "Save Draft")
  • FormData automatically handles multipart encoding - don't manually set Content-Type when using fetch()