Files
gh-rknall-claude-skills-ui-…/design-patterns-library.md
2025-11-30 08:52:15 +08:00

17 KiB
Raw Permalink Blame History

Accessible Design Patterns Library

Reference implementations and best practices for common UI patterns with accessibility built-in.

Navigation Patterns

Purpose: Allow keyboard users to bypass repetitive navigation and jump to main content.

HTML:

<!-- First focusable element on page -->
<a href="#main-content" class="skip-link">Skip to main content</a>

<nav>
  <!-- Navigation content -->
</nav>

<main id="main-content">
  <!-- Main content -->
</main>

CSS:

.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: #000;
  color: #fff;
  padding: 8px;
  z-index: 100;
}

.skip-link:focus {
  top: 0;
}

Accessibility:

  • Must be first focusable element
  • Visible on keyboard focus
  • Jumps to main content area with id="main-content"

Responsive Navigation Menu

HTML:

<nav aria-label="Main navigation">
  <button
    aria-expanded="false"
    aria-controls="main-menu"
    class="menu-toggle"
  >
    <span class="sr-only">Menu</span>
    <svg aria-hidden="true"><!-- hamburger icon --></svg>
  </button>

  <ul id="main-menu" class="nav-menu">
    <li><a href="/home" aria-current="page">Home</a></li>
    <li><a href="/about">About</a></li>
    <li><a href="/contact">Contact</a></li>
  </ul>
</nav>

JavaScript:

const menuToggle = document.querySelector('.menu-toggle');
const menu = document.querySelector('#main-menu');

menuToggle.addEventListener('click', () => {
  const isExpanded = menuToggle.getAttribute('aria-expanded') === 'true';
  menuToggle.setAttribute('aria-expanded', !isExpanded);
  menu.hidden = isExpanded;
});

// Close on Escape key
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && !menu.hidden) {
    menuToggle.setAttribute('aria-expanded', 'false');
    menu.hidden = true;
    menuToggle.focus();
  }
});

Accessibility:

  • aria-label on nav for multiple navigation regions
  • aria-expanded indicates menu state
  • aria-current="page" for current page
  • Escape key closes menu
  • Focus returns to toggle button when closed

Breadcrumbs

HTML:

<nav aria-label="Breadcrumb">
  <ol class="breadcrumb">
    <li><a href="/">Home</a></li>
    <li><a href="/products">Products</a></li>
    <li><a href="/products/shoes">Shoes</a></li>
    <li aria-current="page">Running Shoes</li>
  </ol>
</nav>

CSS:

.breadcrumb {
  display: flex;
  gap: 0.5rem;
  list-style: none;
}

.breadcrumb li:not(:last-child)::after {
  content: '/';
  margin-left: 0.5rem;
  color: #666;
}

.breadcrumb [aria-current="page"] {
  font-weight: bold;
  color: #333;
}

Accessibility:

  • Use <nav> with descriptive aria-label
  • Use ordered list <ol> for hierarchy
  • aria-current="page" for current location
  • Last item is not a link

Form Patterns

Accessible Form Input

HTML:

<div class="form-field">
  <label for="email">
    Email address
    <span aria-label="required">*</span>
  </label>
  <input
    type="email"
    id="email"
    name="email"
    autocomplete="email"
    required
    aria-required="true"
    aria-describedby="email-hint email-error"
  />
  <span id="email-hint" class="hint">
    We'll never share your email with anyone else.
  </span>
  <span id="email-error" class="error" role="alert" hidden>
    Please enter a valid email address.
  </span>
</div>

CSS:

.form-field {
  margin-bottom: 1.5rem;
}

label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: 500;
}

input {
  display: block;
  width: 100%;
  padding: 0.5rem;
  border: 2px solid #ccc;
  border-radius: 4px;
  font-size: 1rem;
}

input:focus {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
  border-color: #0066cc;
}

input[aria-invalid="true"] {
  border-color: #d32f2f;
}

.hint {
  display: block;
  margin-top: 0.25rem;
  font-size: 0.875rem;
  color: #666;
}

.error {
  display: block;
  margin-top: 0.25rem;
  font-size: 0.875rem;
  color: #d32f2f;
}

JavaScript:

const emailInput = document.getElementById('email');
const emailError = document.getElementById('email-error');

emailInput.addEventListener('blur', () => {
  if (!emailInput.validity.valid) {
    emailInput.setAttribute('aria-invalid', 'true');
    emailError.hidden = false;
  } else {
    emailInput.removeAttribute('aria-invalid');
    emailError.hidden = true;
  }
});

Accessibility:

  • Label explicitly associated with input via for/id
  • Required indicator in label (not just asterisk)
  • autocomplete attribute for auto-fill
  • aria-describedby links to hint and error text
  • aria-invalid when validation fails
  • Error message has role="alert" for announcement

Radio Button Group

HTML:

<fieldset>
  <legend>Choose your shipping method</legend>
  <div class="radio-group">
    <div class="radio-option">
      <input
        type="radio"
        id="standard"
        name="shipping"
        value="standard"
        checked
      />
      <label for="standard">
        Standard (3-5 business days)
      </label>
    </div>
    <div class="radio-option">
      <input
        type="radio"
        id="express"
        name="shipping"
        value="express"
      />
      <label for="express">
        Express (1-2 business days)
      </label>
    </div>
  </div>
</fieldset>

Accessibility:

  • <fieldset> groups related radio buttons
  • <legend> provides group label
  • Each radio has explicit label
  • Keyboard navigation with arrow keys (native behavior)

Toggle Switch

HTML:

<div class="toggle-switch">
  <input
    type="checkbox"
    id="notifications"
    role="switch"
    aria-checked="false"
  />
  <label for="notifications">
    Enable notifications
  </label>
</div>

CSS:

.toggle-switch {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}

.toggle-switch input[type="checkbox"] {
  appearance: none;
  position: relative;
  width: 44px;
  height: 24px;
  background: #ccc;
  border-radius: 12px;
  cursor: pointer;
  transition: background 0.3s;
}

.toggle-switch input[type="checkbox"]::before {
  content: '';
  position: absolute;
  top: 2px;
  left: 2px;
  width: 20px;
  height: 20px;
  background: white;
  border-radius: 50%;
  transition: transform 0.3s;
}

.toggle-switch input[type="checkbox"]:checked {
  background: #0066cc;
}

.toggle-switch input[type="checkbox"]:checked::before {
  transform: translateX(20px);
}

.toggle-switch input[type="checkbox"]:focus {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}

Accessibility:

  • role="switch" indicates toggle behavior
  • aria-checked reflects state
  • Minimum 44x44px touch target
  • Clear focus indicator
  • Label describes purpose

Modal Patterns

Accessible Modal Dialog

HTML:

<button id="open-modal">Open Dialog</button>

<div
  id="modal"
  role="dialog"
  aria-modal="true"
  aria-labelledby="modal-title"
  aria-describedby="modal-description"
  hidden
  class="modal-overlay"
>
  <div class="modal-content">
    <h2 id="modal-title">Confirm Action</h2>
    <p id="modal-description">
      Are you sure you want to delete this item? This action cannot be undone.
    </p>
    <div class="modal-actions">
      <button id="confirm-btn">Delete</button>
      <button id="cancel-btn">Cancel</button>
    </div>
    <button
      id="close-modal"
      aria-label="Close dialog"
      class="close-button"
    >
      &times;
    </button>
  </div>
</div>

CSS:

.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.modal-content {
  position: relative;
  background: white;
  padding: 2rem;
  border-radius: 8px;
  max-width: 500px;
  width: 90%;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

.close-button {
  position: absolute;
  top: 1rem;
  right: 1rem;
  background: none;
  border: none;
  font-size: 2rem;
  cursor: pointer;
  padding: 0;
  width: 44px;
  height: 44px;
}

JavaScript:

const modal = document.getElementById('modal');
const openBtn = document.getElementById('open-modal');
const closeBtn = document.getElementById('close-modal');
const cancelBtn = document.getElementById('cancel-btn');
let lastFocusedElement;

// Open modal
openBtn.addEventListener('click', () => {
  lastFocusedElement = document.activeElement;
  modal.hidden = false;

  // Focus first focusable element
  const firstFocusable = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
  firstFocusable?.focus();

  // Trap focus
  trapFocus(modal);
});

// Close modal
function closeModal() {
  modal.hidden = true;
  lastFocusedElement?.focus();
}

closeBtn.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal);

// Close on Escape
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && !modal.hidden) {
    closeModal();
  }
});

// Close on overlay click
modal.addEventListener('click', (e) => {
  if (e.target === modal) {
    closeModal();
  }
});

// Focus trap
function trapFocus(element) {
  const focusableElements = element.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const firstFocusable = focusableElements[0];
  const lastFocusable = focusableElements[focusableElements.length - 1];

  element.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;

    if (e.shiftKey) {
      if (document.activeElement === firstFocusable) {
        lastFocusable.focus();
        e.preventDefault();
      }
    } else {
      if (document.activeElement === lastFocusable) {
        firstFocusable.focus();
        e.preventDefault();
      }
    }
  });
}

Accessibility:

  • role="dialog" and aria-modal="true"
  • aria-labelledby references dialog title
  • aria-describedby references dialog description
  • Focus moved to modal when opened
  • Focus trapped within modal
  • Escape key closes modal
  • Focus returned to trigger on close
  • Close button has aria-label

Button Patterns

Icon Button

HTML:

<button aria-label="Delete item" class="icon-button">
  <svg aria-hidden="true" focusable="false">
    <!-- trash icon -->
  </svg>
</button>

Accessibility:

  • aria-label provides accessible name
  • aria-hidden="true" hides icon from screen readers
  • focusable="false" prevents icon from receiving focus

Loading Button

HTML:

<button
  id="submit-btn"
  aria-live="polite"
  aria-busy="false"
>
  <span class="button-text">Submit</span>
  <span class="spinner" hidden aria-hidden="true"></span>
</button>

JavaScript:

const submitBtn = document.getElementById('submit-btn');
const buttonText = submitBtn.querySelector('.button-text');
const spinner = submitBtn.querySelector('.spinner');

submitBtn.addEventListener('click', async () => {
  // Start loading
  submitBtn.setAttribute('aria-busy', 'true');
  submitBtn.disabled = true;
  buttonText.textContent = 'Submitting...';
  spinner.hidden = false;

  // Simulate async operation
  await fetch('/api/submit');

  // End loading
  submitBtn.setAttribute('aria-busy', 'false');
  submitBtn.disabled = false;
  buttonText.textContent = 'Submit';
  spinner.hidden = true;
});

Accessibility:

  • aria-busy indicates loading state
  • Button text changes to describe current state
  • Button disabled during loading
  • Spinner has aria-hidden="true"

Dropdown/Select Patterns

Custom Dropdown (Combobox)

HTML:

<div class="combobox-wrapper">
  <label id="combo-label" for="combo-input">
    Choose a fruit
  </label>
  <div class="combobox">
    <input
      type="text"
      id="combo-input"
      role="combobox"
      aria-autocomplete="list"
      aria-expanded="false"
      aria-controls="combo-listbox"
      aria-labelledby="combo-label"
    />
    <ul
      id="combo-listbox"
      role="listbox"
      aria-labelledby="combo-label"
      hidden
    >
      <li role="option" id="option-1">Apple</li>
      <li role="option" id="option-2">Banana</li>
      <li role="option" id="option-3">Cherry</li>
    </ul>
  </div>
</div>

JavaScript:

const combobox = document.getElementById('combo-input');
const listbox = document.getElementById('combo-listbox');
const options = listbox.querySelectorAll('[role="option"]');
let activeIndex = -1;

// Open listbox
combobox.addEventListener('focus', () => {
  combobox.setAttribute('aria-expanded', 'true');
  listbox.hidden = false;
});

// Keyboard navigation
combobox.addEventListener('keydown', (e) => {
  switch(e.key) {
    case 'ArrowDown':
      e.preventDefault();
      activeIndex = Math.min(activeIndex + 1, options.length - 1);
      updateActiveOption();
      break;
    case 'ArrowUp':
      e.preventDefault();
      activeIndex = Math.max(activeIndex - 1, 0);
      updateActiveOption();
      break;
    case 'Enter':
      if (activeIndex >= 0) {
        selectOption(options[activeIndex]);
      }
      break;
    case 'Escape':
      closeListbox();
      break;
  }
});

function updateActiveOption() {
  options.forEach((option, index) => {
    if (index === activeIndex) {
      option.setAttribute('aria-selected', 'true');
      combobox.setAttribute('aria-activedescendant', option.id);
      option.scrollIntoView({ block: 'nearest' });
    } else {
      option.removeAttribute('aria-selected');
    }
  });
}

function selectOption(option) {
  combobox.value = option.textContent;
  closeListbox();
}

function closeListbox() {
  combobox.setAttribute('aria-expanded', 'false');
  listbox.hidden = true;
  activeIndex = -1;
}

Accessibility:

  • role="combobox" for input
  • aria-expanded indicates dropdown state
  • aria-controls links to listbox
  • aria-activedescendant tracks active option
  • Arrow keys navigate options
  • Enter selects option
  • Escape closes dropdown

Alert Patterns

Success Message

HTML:

<div role="status" aria-live="polite" class="alert alert-success">
  <svg aria-hidden="true"><!-- checkmark icon --></svg>
  <span>Your changes have been saved successfully.</span>
</div>

Error Message

HTML:

<div role="alert" aria-live="assertive" class="alert alert-error">
  <svg aria-hidden="true"><!-- error icon --></svg>
  <span>An error occurred. Please try again.</span>
</div>

Accessibility:

  • role="status" for non-critical updates
  • role="alert" for critical messages
  • aria-live="polite" waits for pause
  • aria-live="assertive" interrupts immediately
  • Icons are decorative (aria-hidden)

Accordion Pattern

HTML:

<div class="accordion">
  <h3>
    <button
      aria-expanded="false"
      aria-controls="panel-1"
      id="accordion-1"
      class="accordion-trigger"
    >
      Section 1
      <span class="accordion-icon" aria-hidden="true">+</span>
    </button>
  </h3>
  <div
    id="panel-1"
    role="region"
    aria-labelledby="accordion-1"
    class="accordion-panel"
    hidden
  >
    <p>Content for section 1.</p>
  </div>
</div>

JavaScript:

const triggers = document.querySelectorAll('.accordion-trigger');

triggers.forEach(trigger => {
  trigger.addEventListener('click', () => {
    const expanded = trigger.getAttribute('aria-expanded') === 'true';
    const panel = document.getElementById(trigger.getAttribute('aria-controls'));
    const icon = trigger.querySelector('.accordion-icon');

    trigger.setAttribute('aria-expanded', !expanded);
    panel.hidden = expanded;
    icon.textContent = expanded ? '+' : '';
  });
});

Accessibility:

  • Button wraps heading text
  • aria-expanded indicates state
  • aria-controls links to panel
  • Panel has role="region"
  • aria-labelledby links panel to heading
  • Icon is decorative

Best Practices Summary

General Principles

  1. Use semantic HTML first
  2. Add ARIA only when semantic HTML isn't sufficient
  3. Ensure keyboard accessibility for all interactions
  4. Provide visible focus indicators
  5. Test with actual assistive technologies
  6. Don't rely on color alone
  7. Maintain proper heading hierarchy
  8. Provide alternative text for images
  9. Ensure sufficient color contrast
  10. Support screen reader announcements

Common Mistakes to Avoid

  • Removing focus outlines without replacement
  • Using <div> or <span> for buttons
  • Placeholder text as labels
  • Click handlers on non-interactive elements
  • Missing alt text on images
  • Poor color contrast
  • Keyboard traps
  • Auto-playing audio/video
  • Time limits without extensions
  • Unlabeled form controls

Testing Checklist

  • Keyboard-only navigation
  • Screen reader testing
  • Automated accessibility scans
  • Color contrast verification
  • Zoom to 200%
  • Focus indicator visibility
  • Touch target sizes
  • Error message clarity
  • Form label associations
  • Semantic HTML validation