APG Patterns
日本語 GitHub
日本語 GitHub

Landmarks

A set of eight ARIA landmark roles that identify the major sections of a page for assistive technology users.

🤖 AI Implementation Guide

Demo

This demo visualizes the 8 landmark regions with distinct colored borders. Each landmark is labeled to help identify its role.

Main Content

This section demonstrates the region landmark. A section element only becomes a region landmark when it has an accessible name via aria-labelledby or aria-label.

Open demo only →

Native HTML

Prefer Native HTML Semantic Elements

Always use native HTML semantic elements when possible. They provide built-in landmark roles without needing ARIA attributes and are better supported by assistive technologies.

<header>...</header>      <!-- banner -->
<nav>...</nav>           <!-- navigation -->
<main>...</main>         <!-- main -->
<footer>...</footer>     <!-- contentinfo -->
<aside>...</aside>       <!-- complementary -->
<section aria-label="...">...</section>  <!-- region -->
HTML Element ARIA Role Auto-mapping Condition
<header> banner Only when direct child of <body>
<nav> navigation Always
<main> main Always
<footer> contentinfo Only when direct child of <body>
<aside> complementary Always
<section> region Only when aria-label or aria-labelledby is present
<form> form Only when aria-label or aria-labelledby is present

Note: For search functionality, use <form role="search"> as the <search> element has limited browser support.

Accessibility Features

WAI-ARIA Landmark Roles

Landmarks identify the major sections of a page. There are 8 landmark roles that enable assistive technology users to efficiently navigate page structure.

Role HTML Element Description Constraint
banner <header> Site-wide header One per page, top-level only
navigation <nav> Navigation links Multiple allowed, label when >1
main <main> Primary content One per page, top-level only
contentinfo <footer> Site-wide footer One per page, top-level only
complementary <aside> Supporting content Top-level recommended
region <section> Named section Requires aria-label/labelledby
search <form role="search"> Search functionality Use with form element
form <form> Form area Requires aria-label/labelledby

WAI-ARIA Properties

aria-label / aria-labelledby

Provides an accessible name for the landmark. Required when multiple landmarks of the same type exist, or for region and form landmarks.

Values String (aria-label) or ID reference (aria-labelledby)
Required
  • When multiple landmarks of same type exist
  • For region landmarks (always)
  • For form landmarks (always)
Example <nav aria-label="Main"> or <section aria-labelledby="heading-id">

Keyboard Support

Landmarks require no keyboard interaction. They are structural elements, not interactive widgets. Screen readers provide built-in landmark navigation:

Screen Reader Navigation
NVDA D key
VoiceOver Rotor (Web navigation)
JAWS R key or semicolon

Best Practices

  • Use semantic HTML elements - Prefer <header>, <nav>, <main>, etc. over ARIA roles
  • Label multiple landmarks - When you have more than one <nav> or <aside>, give each a unique accessible name
  • Limit landmark count - Too many landmarks (more than ~7) can be overwhelming for assistive technology users
  • Place all content in landmarks - Content outside landmarks may be missed by users navigating by landmarks
  • Top-level placement - <header> and <footer> only map to banner/contentinfo when they are direct children of <body>

References

Source Code

LandmarkDemo.svelte
<script lang="ts">
  /**
   * LandmarkDemo - Demonstrates the 8 ARIA landmark roles
   *
   * This component visualizes proper landmark structure for educational purposes.
   *
   * @see https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/
   */

  /** Show landmark labels overlay */
  export let showLabels: boolean = false;
  let className: string = '';
  export { className as class };
</script>

<div class="apg-landmark-demo {className}" {...$$restProps}>
  <!-- Banner Landmark -->
  <header class="apg-landmark-banner">
    <span class="apg-landmark-label" class:hidden={!showLabels} aria-hidden="true">banner</span>
    <div class="apg-landmark-content">
      <span class="apg-landmark-logo">Site Logo</span>
      <!-- Navigation Landmark (Main) -->
      <nav aria-label="Main" class="apg-landmark-navigation">
        <span class="apg-landmark-label" class:hidden={!showLabels} aria-hidden="true"
          >navigation</span
        >
        <ul class="apg-landmark-nav-list">
          <li><a href="#home">Home</a></li>
          <li><a href="#about">About</a></li>
          <li><a href="#contact">Contact</a></li>
        </ul>
      </nav>
    </div>
  </header>

  <!-- Main Landmark -->
  <main class="apg-landmark-main">
    <span class="apg-landmark-label" class:hidden={!showLabels} aria-hidden="true">main</span>
    <div class="apg-landmark-main-content">
      <!-- Region Landmark -->
      <section aria-labelledby="content-heading" class="apg-landmark-region">
        <span class="apg-landmark-label" class:hidden={!showLabels} aria-hidden="true">region</span>
        <h2 id="content-heading" class="apg-landmark-heading">Main Content</h2>
        <p>
          This section demonstrates the <code>region</code> landmark. A section element only becomes
          a region landmark when it has an accessible name via
          <code>aria-labelledby</code> or <code>aria-label</code>.
        </p>

        <!-- Search Landmark -->
        <form role="search" aria-label="Site search" class="apg-landmark-search">
          <span class="apg-landmark-label" class:hidden={!showLabels} aria-hidden="true"
            >search</span
          >
          <label for="search-input" class="apg-landmark-search-label">Search</label>
          <input
            type="search"
            id="search-input"
            class="apg-landmark-search-input"
            placeholder="Search..."
          />
          <button type="submit" class="apg-landmark-search-button">Search</button>
        </form>

        <!-- Form Landmark -->
        <form aria-label="Contact form" class="apg-landmark-form">
          <span class="apg-landmark-label" class:hidden={!showLabels} aria-hidden="true">form</span>
          <div class="apg-landmark-form-field">
            <label for="name-input">Name</label>
            <input type="text" id="name-input" class="apg-landmark-input" />
          </div>
          <div class="apg-landmark-form-field">
            <label for="email-input">Email</label>
            <input type="email" id="email-input" class="apg-landmark-input" />
          </div>
          <button type="submit" class="apg-landmark-submit">Submit</button>
        </form>
      </section>

      <!-- Complementary Landmark -->
      <aside aria-label="Related content" class="apg-landmark-complementary">
        <span class="apg-landmark-label" class:hidden={!showLabels} aria-hidden="true"
          >complementary</span
        >
        <h2 class="apg-landmark-heading">Related</h2>
        <ul>
          <li><a href="#related1">Related Link 1</a></li>
          <li><a href="#related2">Related Link 2</a></li>
        </ul>
      </aside>
    </div>
  </main>

  <!-- Contentinfo Landmark -->
  <footer class="apg-landmark-contentinfo">
    <span class="apg-landmark-label" class:hidden={!showLabels} aria-hidden="true">contentinfo</span
    >
    <div class="apg-landmark-content">
      <!-- Navigation Landmark (Footer) -->
      <nav aria-label="Footer" class="apg-landmark-navigation">
        <span class="apg-landmark-label" class:hidden={!showLabels} aria-hidden="true"
          >navigation</span
        >
        <ul class="apg-landmark-nav-list">
          <li><a href="#privacy">Privacy</a></li>
          <li><a href="#terms">Terms</a></li>
          <li><a href="#sitemap">Sitemap</a></li>
        </ul>
      </nav>
      <p class="apg-landmark-copyright">&copy; 2024 Example Site</p>
    </div>
  </footer>
</div>

Testing

Tests verify that all 8 landmark roles are present, properly labeled, and follow APG constraints. Since landmarks are static structural elements, testing focuses on HTML structure rather than interaction.

Testing Strategy

Unit Tests (Container API / Testing Library)

Verify the component's HTML output and landmark structure. These tests ensure correct rendering without requiring a browser.

  • All 8 landmark roles are present
  • Semantic HTML elements are used
  • Proper labeling (aria-label/aria-labelledby)
  • Constraint validation (one main, one banner, etc.)

E2E Tests (Playwright)

Verify landmark structure in a real browser environment and run accessibility audits.

  • Landmark roles are exposed correctly
  • axe-core accessibility audit
  • Cross-framework consistency

Test Categories

High Priority: Landmark Structure

Test Description
banner landmark Has header element with banner role
navigation landmark Has nav element(s) with navigation role
main landmark Has main element with main role
contentinfo landmark Has footer element with contentinfo role
complementary landmark Has aside element with complementary role
region landmark Has section with aria-labelledby (region role)
search landmark Has form with role="search"
form landmark Has form with aria-label (form role)
exactly one main Only one main landmark exists
exactly one banner Only one banner landmark exists
exactly one contentinfo Only one contentinfo landmark exists

High Priority: Landmark Labeling

Test Description
navigation labels Multiple navigations have unique aria-labels
region label Region has aria-label or aria-labelledby
form label Form landmark has aria-label
search label Search landmark has aria-label
complementary label Complementary landmark has aria-label

Medium Priority: Accessibility

Test Description
axe-core audit No WCAG 2.1 AA violations

Low Priority: Props & Structure

Test Description
className prop Custom className is applied to container
showLabels prop Landmark labels are visible when enabled
semantic HTML Uses header, nav, main, footer, aside, section elements

Checklist (Manual Verification)

These recommendations are best verified manually or with specialized tools:

  • All content is within landmarks (recommended but not required)
  • Total landmarks ≤ 7 (too many can be overwhelming)
  • Screen reader navigation works correctly (NVDA D key, VoiceOver rotor)

Testing Tools

See testing-strategy.md (opens in new tab) for full documentation.

LandmarkDemo.test.svelte.ts
import { render, screen, within } from '@testing-library/svelte';
import { axe } from 'jest-axe';
import { describe, expect, it } from 'vitest';
import LandmarkDemo from './LandmarkDemo.svelte';

describe('LandmarkDemo (Svelte)', () => {
  // 🔴 High Priority: Landmark Roles
  describe('APG: Landmark Roles', () => {
    it('has banner landmark (header)', () => {
      render(LandmarkDemo);
      expect(screen.getByRole('banner')).toBeInTheDocument();
    });

    it('has navigation landmark (nav)', () => {
      render(LandmarkDemo);
      expect(screen.getAllByRole('navigation').length).toBeGreaterThanOrEqual(1);
    });

    it('has main landmark (main)', () => {
      render(LandmarkDemo);
      expect(screen.getByRole('main')).toBeInTheDocument();
    });

    it('has contentinfo landmark (footer)', () => {
      render(LandmarkDemo);
      expect(screen.getByRole('contentinfo')).toBeInTheDocument();
    });

    it('has complementary landmark (aside)', () => {
      render(LandmarkDemo);
      expect(screen.getByRole('complementary')).toBeInTheDocument();
    });

    it('has region landmark with label', () => {
      render(LandmarkDemo);
      const region = screen.getByRole('region');
      expect(region).toBeInTheDocument();
      expect(region).toHaveAccessibleName();
    });

    it('has search landmark', () => {
      render(LandmarkDemo);
      expect(screen.getByRole('search')).toBeInTheDocument();
    });

    it('has form landmark with label', () => {
      render(LandmarkDemo);
      const form = screen.getByRole('form');
      expect(form).toBeInTheDocument();
      expect(form).toHaveAccessibleName();
    });

    it('has exactly one main landmark', () => {
      render(LandmarkDemo);
      expect(screen.getAllByRole('main')).toHaveLength(1);
    });

    it('has exactly one banner landmark', () => {
      render(LandmarkDemo);
      expect(screen.getAllByRole('banner')).toHaveLength(1);
    });

    it('has exactly one contentinfo landmark', () => {
      render(LandmarkDemo);
      expect(screen.getAllByRole('contentinfo')).toHaveLength(1);
    });
  });

  // 🔴 High Priority: Landmark Labeling
  describe('APG: Landmark Labeling', () => {
    it('navigation landmarks have unique labels when multiple', () => {
      render(LandmarkDemo);
      const navs = screen.getAllByRole('navigation');
      if (navs.length > 1) {
        const labels = navs.map(
          (nav) => nav.getAttribute('aria-label') || nav.getAttribute('aria-labelledby')
        );
        const uniqueLabels = new Set(labels.filter(Boolean));
        expect(uniqueLabels.size).toBe(navs.length);
      }
    });

    it('region landmark has accessible name', () => {
      render(LandmarkDemo);
      const region = screen.getByRole('region');
      const hasLabel = region.hasAttribute('aria-label') || region.hasAttribute('aria-labelledby');
      expect(hasLabel).toBe(true);
    });

    it('form landmark has accessible name', () => {
      render(LandmarkDemo);
      const form = screen.getByRole('form');
      const hasLabel = form.hasAttribute('aria-label') || form.hasAttribute('aria-labelledby');
      expect(hasLabel).toBe(true);
    });

    it('search landmark has accessible name', () => {
      render(LandmarkDemo);
      const search = screen.getByRole('search');
      const hasLabel = search.hasAttribute('aria-label') || search.hasAttribute('aria-labelledby');
      expect(hasLabel).toBe(true);
    });

    it('complementary landmark has accessible name', () => {
      render(LandmarkDemo);
      const aside = screen.getByRole('complementary');
      const hasLabel = aside.hasAttribute('aria-label') || aside.hasAttribute('aria-labelledby');
      expect(hasLabel).toBe(true);
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe-core violations', async () => {
      const { container } = render(LandmarkDemo);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟢 Low Priority: Props
  describe('Props', () => {
    it('applies class to container', () => {
      render(LandmarkDemo, {
        props: { class: 'custom-class', 'data-testid': 'landmark-demo' },
      });
      const container = screen.getByTestId('landmark-demo');
      expect(container).toHaveClass('custom-class');
    });

    it('shows landmark labels when showLabels is true', () => {
      render(LandmarkDemo, {
        props: { showLabels: true },
      });
      expect(screen.getByText('banner')).toBeInTheDocument();
      expect(screen.getByText('main')).toBeInTheDocument();
      expect(screen.getByText('contentinfo')).toBeInTheDocument();
    });
  });

  // 🟢 Low Priority: Semantic Structure
  describe('Semantic Structure', () => {
    it('uses semantic HTML elements', () => {
      const { container } = render(LandmarkDemo);
      expect(container.querySelector('header')).toBeInTheDocument();
      expect(container.querySelector('nav')).toBeInTheDocument();
      expect(container.querySelector('main')).toBeInTheDocument();
      expect(container.querySelector('footer')).toBeInTheDocument();
      expect(container.querySelector('aside')).toBeInTheDocument();
      expect(container.querySelector('section')).toBeInTheDocument();
    });

    it('main contains primary content area', () => {
      render(LandmarkDemo);
      const main = screen.getByRole('main');
      expect(within(main).getByRole('region')).toBeInTheDocument();
      expect(within(main).getByRole('complementary')).toBeInTheDocument();
    });
  });
});

Resources