APG Patterns
日本語
日本語

Landmarks

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

Demo

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 ElementARIA RoleAuto-mapping Condition
<header>banner<body>Only when direct child of
<nav>navigationAlways
<main>mainAlways
<footer>contentinfo<body>Only when direct child of
<aside>complementaryAlways
<section>regionOnly when aria-label or aria-labelledby is present
<form>formOnly when aria-label or aria-labelledby is present

Note: The <search> element is supported in all major browsers. If legacy browser compatibility is required, <form role="search"> can be used as an alternative.

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
banner <header> Site-wide header
navigation <nav> Navigation links
main <main> Primary content
contentinfo <footer> Site-wide footer
complementary <aside> Supporting content
region <section> Named section
search <form role="search"> Search functionality
form <form> Form area

WAI-ARIA Properties

aria-label

Provides an accessible name for the landmark

Values
String
Required

Conditional: when multiple of same type, or region/form

aria-labelledby

References a visible heading element

Values
ID reference
Required
Conditional: reference visible heading
  • 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>

Usage

Example
---
import LandmarkDemo from './LandmarkDemo.astro';
---

<LandmarkDemo showLabels={true} />

API

PropTypeDefaultDescription
showLabelsbooleanfalseWhether to show landmark role labels

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

TestDescription
banner landmarkHas header element with banner role
navigation landmarkHas nav element(s) with navigation role
main landmarkHas main element with main role
contentinfo landmarkHas footer element with contentinfo role
complementary landmarkHas aside element with complementary role
region landmarkHas section with aria-labelledby (region role)
search landmarkHas form with role="search"
form landmarkHas form with aria-label (form role)
exactly one mainOnly one main landmark exists
exactly one bannerOnly one banner landmark exists
exactly one contentinfoOnly one contentinfo landmark exists

High Priority: Landmark Labeling

TestDescription
navigation labelsMultiple navigations have unique aria-labels
region labelRegion has aria-label or aria-labelledby
form labelForm landmark has aria-label
search labelSearch landmark has aria-label
complementary labelComplementary landmark has aria-label

Medium Priority: Accessibility

TestDescription
axe-core auditNo WCAG 2.1 AA violations

Low Priority: Props & Structure

TestDescription
className propCustom className is applied to container
showLabels propLandmark labels are visible when enabled
semantic HTMLUses 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 the Testing Strategy guide for details.

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