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.astro
---
/**
 * LandmarkDemo - Demonstrates the 8 ARIA landmark roles
 *
 * This component visualizes proper landmark structure for educational purposes.
 * Since landmarks are static structural elements, no JavaScript is required.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/
 */

interface Props {
  /** Show landmark labels overlay */
  showLabels?: boolean;
  class?: string;
}

const { showLabels = false, class: className } = Astro.props;
---

<div class:list={['apg-landmark-demo', className]}>
  <!-- Banner Landmark -->
  <header class="apg-landmark-banner">
    <span class:list={['apg-landmark-label', !showLabels && 'hidden']} 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:list={['apg-landmark-label', !showLabels && 'hidden']} 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:list={['apg-landmark-label', !showLabels && 'hidden']} aria-hidden="true">main</span
    >
    <div class="apg-landmark-main-content">
      <!-- Region Landmark -->
      <section aria-labelledby="content-heading" class="apg-landmark-region">
        <span class:list={['apg-landmark-label', !showLabels && 'hidden']} 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:list={['apg-landmark-label', !showLabels && 'hidden']} 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:list={['apg-landmark-label', !showLabels && 'hidden']} 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:list={['apg-landmark-label', !showLabels && 'hidden']} 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:list={['apg-landmark-label', !showLabels && 'hidden']} aria-hidden="true"
      >contentinfo</span
    >
    <div class="apg-landmark-content">
      <!-- Navigation Landmark (Footer) -->
      <nav aria-label="Footer" class="apg-landmark-navigation">
        <span class:list={['apg-landmark-label', !showLabels && 'hidden']} 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.astro.ts
/**
 * LandmarkDemo Astro Component Tests using Container API
 *
 * These tests verify the LandmarkDemo.astro component output using Astro's Container API.
 * This ensures the component renders correct HTML structure and landmark elements.
 *
 * Note: Landmarks are static structural elements without JavaScript interaction,
 * so Container API tests are sufficient for most verification.
 *
 * @see https://docs.astro.build/en/reference/container-reference/
 */
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import LandmarkDemo from './LandmarkDemo.astro';

describe('LandmarkDemo (Astro Container API)', () => {
  let container: AstroContainer;

  beforeEach(async () => {
    container = await AstroContainer.create();
  });

  // Helper to render and parse HTML
  async function renderLandmarkDemo(
    props: {
      showLabels?: boolean;
      class?: string;
    } = {}
  ): Promise<Document> {
    const html = await container.renderToString(LandmarkDemo, { props });
    const dom = new JSDOM(html);
    return dom.window.document;
  }

  // 🔴 High Priority: Landmark Roles
  describe('Landmark Roles', () => {
    it('renders header element (banner landmark)', async () => {
      const doc = await renderLandmarkDemo();
      const header = doc.querySelector('header');
      expect(header).not.toBeNull();
    });

    it('renders nav element (navigation landmark)', async () => {
      const doc = await renderLandmarkDemo();
      const navs = doc.querySelectorAll('nav');
      expect(navs.length).toBeGreaterThanOrEqual(1);
    });

    it('renders main element (main landmark)', async () => {
      const doc = await renderLandmarkDemo();
      const main = doc.querySelector('main');
      expect(main).not.toBeNull();
    });

    it('renders footer element (contentinfo landmark)', async () => {
      const doc = await renderLandmarkDemo();
      const footer = doc.querySelector('footer');
      expect(footer).not.toBeNull();
    });

    it('renders aside element (complementary landmark)', async () => {
      const doc = await renderLandmarkDemo();
      const aside = doc.querySelector('aside');
      expect(aside).not.toBeNull();
    });

    it('renders section with aria-labelledby (region landmark)', async () => {
      const doc = await renderLandmarkDemo();
      const section = doc.querySelector('section[aria-labelledby]');
      expect(section).not.toBeNull();
    });

    it('renders form with role="search" (search landmark)', async () => {
      const doc = await renderLandmarkDemo();
      const search = doc.querySelector('form[role="search"]');
      expect(search).not.toBeNull();
    });

    it('renders form with aria-label (form landmark)', async () => {
      const doc = await renderLandmarkDemo();
      // Find form that has aria-label but not role="search"
      const forms = doc.querySelectorAll('form[aria-label]');
      const formLandmark = Array.from(forms).find((form) => form.getAttribute('role') !== 'search');
      expect(formLandmark).not.toBeNull();
    });

    it('renders exactly one main element', async () => {
      const doc = await renderLandmarkDemo();
      const mains = doc.querySelectorAll('main');
      expect(mains.length).toBe(1);
    });

    it('renders exactly one header at top level', async () => {
      const doc = await renderLandmarkDemo();
      // Header should not be inside article, aside, main, nav, section
      const headers = doc.querySelectorAll('header');
      // Check that there's one header that's not nested in other landmarks
      const topLevelHeaders = Array.from(headers).filter((header) => {
        const parent = header.parentElement;
        if (!parent) return true;
        const parentTag = parent.tagName.toLowerCase();
        return !['article', 'aside', 'main', 'nav', 'section'].includes(parentTag);
      });
      expect(topLevelHeaders.length).toBe(1);
    });

    it('renders exactly one footer at top level', async () => {
      const doc = await renderLandmarkDemo();
      const footers = doc.querySelectorAll('footer');
      // Check that there's one footer that's not nested in other landmarks
      const topLevelFooters = Array.from(footers).filter((footer) => {
        const parent = footer.parentElement;
        if (!parent) return true;
        const parentTag = parent.tagName.toLowerCase();
        return !['article', 'aside', 'main', 'nav', 'section'].includes(parentTag);
      });
      expect(topLevelFooters.length).toBe(1);
    });
  });

  // 🔴 High Priority: Landmark Labeling
  describe('Landmark Labeling', () => {
    it('navigation elements have aria-label when multiple', async () => {
      const doc = await renderLandmarkDemo();
      const navs = doc.querySelectorAll('nav');
      if (navs.length > 1) {
        const labels = Array.from(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('section element has aria-labelledby attribute', async () => {
      const doc = await renderLandmarkDemo();
      const section = doc.querySelector('section[aria-labelledby]');
      expect(section).not.toBeNull();
      const labelId = section?.getAttribute('aria-labelledby');
      expect(labelId).toBeTruthy();
      // Check that the referenced element exists
      const labelElement = doc.getElementById(labelId!);
      expect(labelElement).not.toBeNull();
    });

    it('search form has aria-label attribute', async () => {
      const doc = await renderLandmarkDemo();
      const search = doc.querySelector('form[role="search"]');
      expect(search?.hasAttribute('aria-label')).toBe(true);
    });

    it('form landmark has aria-label attribute', async () => {
      const doc = await renderLandmarkDemo();
      const forms = doc.querySelectorAll('form[aria-label]');
      const formLandmark = Array.from(forms).find((form) => form.getAttribute('role') !== 'search');
      expect(formLandmark).not.toBeNull();
      expect(formLandmark?.getAttribute('aria-label')).toBeTruthy();
    });

    it('aside element has aria-label attribute', async () => {
      const doc = await renderLandmarkDemo();
      const aside = doc.querySelector('aside');
      expect(aside?.hasAttribute('aria-label')).toBe(true);
    });
  });

  // 🟢 Low Priority: Props
  describe('Props', () => {
    it('applies custom class to container', async () => {
      const doc = await renderLandmarkDemo({ class: 'custom-class' });
      const container = doc.querySelector('.apg-landmark-demo');
      expect(container?.classList.contains('custom-class')).toBe(true);
    });

    it('shows landmark labels when showLabels is true', async () => {
      const doc = await renderLandmarkDemo({ showLabels: true });
      // Check for label overlay elements
      const labels = doc.querySelectorAll('.apg-landmark-label');
      expect(labels.length).toBeGreaterThan(0);
    });

    it('hides landmark labels by default', async () => {
      const doc = await renderLandmarkDemo({ showLabels: false });
      const labels = doc.querySelectorAll('.apg-landmark-label');
      // Labels should either not exist or have hidden class
      Array.from(labels).forEach((label) => {
        expect(label.classList.contains('hidden')).toBe(true);
      });
    });
  });

  // 🟢 Low Priority: Semantic Structure
  describe('Semantic Structure', () => {
    it('main contains region landmark', async () => {
      const doc = await renderLandmarkDemo();
      const main = doc.querySelector('main');
      const regionInMain = main?.querySelector('section[aria-labelledby]');
      expect(regionInMain).not.toBeNull();
    });

    it('main contains complementary landmark', async () => {
      const doc = await renderLandmarkDemo();
      const main = doc.querySelector('main');
      const asideInMain = main?.querySelector('aside');
      expect(asideInMain).not.toBeNull();
    });

    it('header contains navigation', async () => {
      const doc = await renderLandmarkDemo();
      const header = doc.querySelector('header');
      const navInHeader = header?.querySelector('nav');
      expect(navInHeader).not.toBeNull();
    });

    it('footer contains navigation', async () => {
      const doc = await renderLandmarkDemo();
      const footer = doc.querySelector('footer');
      const navInFooter = footer?.querySelector('nav');
      expect(navInFooter).not.toBeNull();
    });
  });

  // CSS Classes
  describe('CSS Classes', () => {
    it('has apg-landmark-demo class on container', async () => {
      const doc = await renderLandmarkDemo();
      const container = doc.querySelector('.apg-landmark-demo');
      expect(container).not.toBeNull();
    });

    it('landmarks have appropriate CSS classes', async () => {
      const doc = await renderLandmarkDemo();
      expect(doc.querySelector('.apg-landmark-banner')).not.toBeNull();
      expect(doc.querySelector('.apg-landmark-main')).not.toBeNull();
      expect(doc.querySelector('.apg-landmark-contentinfo')).not.toBeNull();
    });
  });
});

Resources