Steven's Knowledge
Accessibility

Forms & Media

Accessible forms, error handling, validation, images, video, and audio

Forms & Media

Forms are the primary interaction point for users, and media must be perceivable to all. This covers building accessible forms and ensuring media content is inclusive.

Form Labels

Every form input must have a programmatically associated label.

Labeling Techniques

// 1. Explicit label — most reliable and recommended
<label htmlFor="email">Email address</label>
<input id="email" type="email" />

// 2. Wrapping label — label wraps the input
<label>
  Email address
  <input type="email" />
</label>

// 3. aria-label — when no visible label exists
<input type="search" aria-label="Search products" />

// 4. aria-labelledby — reference external text
<span id="qty-label">Quantity</span>
<input type="number" aria-labelledby="qty-label" />

Always use visible labels. Placeholder text is not a substitute for labels — it disappears when the user starts typing, provides no persistent context, and has contrast issues.

Common Label Mistakes

// Bad — placeholder as label
<input type="email" placeholder="Email address" />

// Bad — hidden label with no alternative
<input type="text" />

// Bad — label not associated with input
<label>Email</label>
<input type="email" />  {/* Missing htmlFor/id connection */}

// Bad — same id for multiple inputs
<label htmlFor="name">First Name</label>
<input id="name" type="text" />
<label htmlFor="name">Last Name</label>  {/* Duplicate! */}
<input id="name" type="text" />

Form Structure

// Use fieldset and legend for related groups
<fieldset>
  <legend>Shipping Address</legend>
  <label htmlFor="street">Street</label>
  <input id="street" type="text" autoComplete="street-address" />

  <label htmlFor="city">City</label>
  <input id="city" type="text" autoComplete="address-level2" />

  <label htmlFor="zip">ZIP Code</label>
  <input id="zip" type="text" autoComplete="postal-code" />
</fieldset>

// Radio buttons must be grouped
<fieldset>
  <legend>Preferred contact method</legend>
  <label>
    <input type="radio" name="contact" value="email" /> Email
  </label>
  <label>
    <input type="radio" name="contact" value="phone" /> Phone
  </label>
  <label>
    <input type="radio" name="contact" value="mail" /> Mail
  </label>
</fieldset>

Autocomplete Attributes

Help users fill forms faster and reduce errors:

<form>
  <input type="text" autoComplete="name" />
  <input type="email" autoComplete="email" />
  <input type="tel" autoComplete="tel" />
  <input type="text" autoComplete="street-address" />
  <input type="text" autoComplete="postal-code" />
  <input type="password" autoComplete="current-password" />
  <input type="password" autoComplete="new-password" />
  <input type="text" autoComplete="one-time-code" />
</form>

Required Fields

// Use required attribute + visual indicator
<label htmlFor="name">
  Full Name <span aria-hidden="true">*</span>
</label>
<input id="name" type="text" required aria-required="true" />

// Explain the required indicator at the top of the form
<p>Fields marked with <span aria-hidden="true">*</span><span className="sr-only">asterisk</span> are required.</p>

Error Handling and Validation

Inline Validation

function EmailField() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');
  const errorId = useId();

  const validate = (value: string) => {
    if (!value) {
      setError('Email is required.');
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
      setError('Enter a valid email address.');
    } else {
      setError('');
    }
  };

  return (
    <div>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        onBlur={(e) => validate(e.target.value)}
        aria-invalid={!!error}
        aria-describedby={error ? errorId : undefined}
        aria-required="true"
      />
      {error && (
        <p id={errorId} role="alert" className="error-message">
          {error}
        </p>
      )}
    </div>
  );
}

Error Summary

For complex forms, provide an error summary at the top linking to each field with an error.

function ErrorSummary({ errors }: { errors: { field: string; id: string; message: string }[] }) {
  const summaryRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (errors.length > 0) {
      summaryRef.current?.focus();
    }
  }, [errors]);

  if (errors.length === 0) return null;

  return (
    <div ref={summaryRef} role="alert" tabIndex={-1} className="error-summary">
      <h2>There {errors.length === 1 ? 'is 1 error' : `are ${errors.length} errors`} in your form</h2>
      <ul>
        {errors.map((error) => (
          <li key={error.field}>
            <a href={`#${error.id}`}>{error.message}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

Accessible Error Message Pattern

Form Validation Flow
├── User submits form
├── Validation errors found
│   ├── Focus moves to error summary (or first error field)
│   ├── Each error links to its field
│   ├── Fields show inline error messages
│   └── aria-invalid="true" set on error fields
└── Successful submission
    └── Announce success via aria-live region

Accessible Custom Controls

Custom Checkbox

function Checkbox({ label, checked, onChange }: CheckboxProps) {
  const inputId = useId();

  return (
    <div className="checkbox-wrapper">
      <input
        id={inputId}
        type="checkbox"
        checked={checked}
        onChange={(e) => onChange(e.target.checked)}
        className="sr-only" // Hide native checkbox visually
      />
      <label htmlFor={inputId} className="checkbox-label">
        <span className={`checkbox-custom ${checked ? 'checked' : ''}`} aria-hidden="true">
          {checked && <CheckIcon />}
        </span>
        {label}
      </label>
    </div>
  );
}

Custom Select / Dropdown

function Select({ label, options, value, onChange }: SelectProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const buttonId = useId();
  const listboxId = useId();

  const selected = options.find((o) => o.value === value);

  return (
    <div className="select-wrapper">
      <label id={`${buttonId}-label`}>{label}</label>
      <button
        id={buttonId}
        role="combobox"
        aria-expanded={isOpen}
        aria-haspopup="listbox"
        aria-controls={listboxId}
        aria-labelledby={`${buttonId}-label`}
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={(e) => {
          if (e.key === 'ArrowDown') { setIsOpen(true); setActiveIndex(0); }
          if (e.key === 'Escape') setIsOpen(false);
        }}
      >
        {selected?.label ?? 'Select...'}
      </button>
      {isOpen && (
        <ul role="listbox" id={listboxId} aria-labelledby={`${buttonId}-label`}>
          {options.map((option, index) => (
            <li
              key={option.value}
              role="option"
              aria-selected={option.value === value}
              className={index === activeIndex ? 'active' : ''}
              onClick={() => { onChange(option.value); setIsOpen(false); }}
            >
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Images

Alt Text Guidelines

// Informative image — describe the content
<img src="chart.png" alt="Bar chart showing 40% increase in sales from Q1 to Q4 2024" />

// Decorative image — empty alt
<img src="divider.png" alt="" />

// Functional image (inside a link/button) — describe the action
<a href="/">
  <img src="logo.png" alt="Acme Corp — go to homepage" />
</a>

// Complex image — provide extended description
<figure>
  <img src="architecture.png" alt="System architecture diagram" aria-describedby="arch-desc" />
  <figcaption id="arch-desc">
    The system consists of three layers: a React frontend communicates with
    a Node.js API gateway, which routes requests to microservices backed
    by PostgreSQL and Redis.
  </figcaption>
</figure>

// SVG icons
<svg role="img" aria-label="Warning">
  <title>Warning</title>
  <path d="..." />
</svg>

// Decorative SVG
<svg aria-hidden="true" focusable="false">
  <path d="..." />
</svg>
Image TypeAlt Text Strategy
InformativeDescribe the content and purpose
Decorativealt="" or aria-hidden="true"
Functional (in link/button)Describe the action or destination
Text in imageRepeat the text in alt
Complex (charts, diagrams)Short alt + aria-describedby for detailed description
Background image with meaningAdd hidden text alternative or use ARIA

Video and Audio

Video Accessibility Requirements

<!-- Video with captions and audio description -->
<video controls>
  <source src="demo.mp4" type="video/mp4" />
  <track kind="captions" src="captions-en.vtt" srclang="en" label="English" default />
  <track kind="descriptions" src="descriptions-en.vtt" srclang="en" label="Audio descriptions" />
  <track kind="chapters" src="chapters-en.vtt" srclang="en" label="Chapters" />
  <!-- Fallback for no video support -->
  <p>Your browser does not support video. <a href="demo.mp4">Download the video</a>.</p>
</video>

WCAG Media Requirements

RequirementLevelDescription
Captions (prerecorded)ASynchronized text for audio content
Audio descriptions (prerecorded)ANarration of visual-only information
Captions (live)AAReal-time captions for live content
Sign languageAAASign language interpretation
TranscriptAFull text alternative for audio-only

WebVTT Caption Format

WEBVTT

00:00:01.000 --> 00:00:04.000
Welcome to our product demo.

00:00:04.500 --> 00:00:08.000
Today we'll walk through the new dashboard features.

00:00:08.500 --> 00:00:12.000
[Screen shows the main dashboard with three panels]

Accessible Media Player

function VideoPlayer({ src, captions }: VideoPlayerProps) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [isPlaying, setIsPlaying] = useState(false);

  return (
    <div role="region" aria-label="Video player">
      <video ref={videoRef} src={src}>
        {captions.map((track) => (
          <track
            key={track.lang}
            kind="captions"
            src={track.src}
            srcLang={track.lang}
            label={track.label}
            default={track.default}
          />
        ))}
      </video>

      <div role="toolbar" aria-label="Video controls">
        <button
          onClick={() => {
            isPlaying ? videoRef.current?.pause() : videoRef.current?.play();
            setIsPlaying(!isPlaying);
          }}
          aria-label={isPlaying ? 'Pause' : 'Play'}
        >
          {isPlaying ? <PauseIcon /> : <PlayIcon />}
        </button>

        <button
          onClick={() => {
            const track = videoRef.current?.textTracks[0];
            if (track) track.mode = track.mode === 'showing' ? 'hidden' : 'showing';
          }}
          aria-label="Toggle captions"
        >
          CC
        </button>
      </div>
    </div>
  );
}

Best Practices

Forms & Media Guidelines

  1. Every input needs a visible, programmatically associated label
  2. Group related fields with <fieldset> and <legend>
  3. Use aria-invalid and aria-describedby for error messages
  4. Provide an error summary for complex forms and focus it on submit
  5. Use autocomplete attributes for common fields
  6. Write meaningful alt text — describe content, purpose, and context
  7. Provide captions for all video content and transcripts for audio
  8. Never use placeholder text as a substitute for labels

On this page