Skip to content
svelte-vitals
English
Esc
navigateopen⌘Jpreview
On this page

a11y/duplicate-landmark · Duplicate landmark

A route should have at most one main, banner, and contentinfo landmark.

Severity: warning · Category: a11y

What it checks

Flags a route whose composed layout chain (every +layout.svelte up to the route’s +page.svelte) plus resolved local components yields more than one main, banner, or contentinfo landmark. navigation (<nav>) and complementary are deliberately outside this set: APG allows several of each on a page. Detection is branch-aware: within an {#if}/{#await} block only the arm with the most occurrences counts (ties break to the first arm in document order), so the arms of one {#if} are not summed. Two separate {#if} blocks are independent, though, since nothing here evaluates their conditions, and {#each}/{#snippet} bodies are excluded since they render 0..N times.

Landmarks are detected cross-file. A <main> in +layout.svelte plus another <main> in +page.svelte is one route with two main landmarks, and so is a layout’s <main> plus a <main> rendered by an imported $lib component. <main> and an explicit role="banner"/role="contentinfo" count everywhere a route composes them. <header>/<footer> (the implicit banner/contentinfo landmarks) count only at a chain file’s template top level. Inside a nested component they may sit inside sectioning content in whatever parent uses that component, so counting them there could manufacture a duplicate that doesn’t exist.

A route with none of the three landmark kinds emits nothing.

Why it matters

Assistive technology users jump between landmarks (main, banner, contentinfo) with a keystroke to skip repeated boilerplate and reach page content. More than one of the same kind on a page leaves that jump ambiguous. Which one is the actual page content, and which is the actual site header?

How to fix

Keep one <main>, one <header>/role="banner", and one <footer>/role="contentinfo" per route. A layout that already renders one of these should not have the page (or an imported component) render another:

<header>Site navigation, shown on every route</header>
<main>{@render children()}</main>
<footer>Site footer, shown on every route</footer>
<h1>Page content</h1>
<!-- no second <main> here -->

Mode differences

Landmarks are collected in both modes, but from different sources, so results can differ:

  • Source analysis (the CLI, the dashboard’s static baseline) composes the route’s layout chain with its resolved local components, using the branch-aware fold: within an {#if}/{#await} block only the arm with the most occurrences is credited, so it can pick a branch that would not actually render. It cannot see landmarks contributed by an unresolvable component (node_modules, a dynamically chosen component), and {#each}/{#snippet} bodies are excluded from counting since they render 0..N times.
  • Source analysis (the CLI, the dashboard’s static baseline) also decides whether a <header>/<footer> is a landmark by where it sits in its own file: it counts as banner/contentinfo only at the template top level of a chain file (a layout or the page), never inside a component, whose header may sit under sectioning content the component cannot see. This is an approximation in both directions: a component’s own top-level <header> is not counted, so two of the same component on a route do not duplicate banner the way the rendered DOM would; and a page’s top-level <header> counts even when the layout renders the page inside <main>, where the rendered DOM would not treat it as a landmark at all.
  • Rendered analysis (the Vite plugin’s build pass, a route you visit in the dashboard) reads the rendered HTML, so it sees only the branches that actually rendered, and it sees every landmark an {#each} loop produced. It has no source files to attribute a finding to, so its findings anchor to the route itself rather than a specific file and line. The persisted finding key differs from the source-analysis key for the same defect.

When the two disagree, trust the rendered result. It reflects what ships to the browser.

Disabling

An inline svelte-vitals-disable-next-line comment above the line the finding names silences it, in source analysis only: a build-pass finding points at the prerendered HTML and has no source line to sit above. That line often sits in a composed component, and one directive there silences the finding on every route composing it. The suppressions file (npx svelte-vitals --update-suppressions) is the per-route mechanism. You can also scope the rule per route or path with overrides, or turn it off:

export default {
  rules: {
    'a11y/duplicate-landmark': 'off'
  }
};