Skip to content
svelte-vitals
Esc
↑↓navigate↵open⌘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. The page (and each inner layout) is placed where its parent layout renders {@render children()} or <slot />, so a layout’s other arms are exclusive with the page too. A layout that renders them in several arms of one {#if} places the page in each of those arms; places in separate blocks leave it where they all agree. An arm whose test reads page.url.pathname (or $page.url.pathname, directly or through a $derived) with startsWith, endsWith, includes or === against a string literal is left out on a route whose path cannot satisfy it. The same reading decides any {#if} in a layout or page by the route, and its test may also be page.route.id (or $page.route.id) against a string literal or in an array literal or a const list of strings the file never changes (['/a', '/b'].includes(page.route.id)), a regex literal’s .test() of the path, a .some over such a list testing the path’s startsWith, endsWith or includes of each entry (PUBLIC.some((r) => page.url.pathname.startsWith(r))), or the path’s === against a template literal (`/c/${slug}`, each ${…} taken as the route parameter in its place). An imported binding that one of the app’s own modules exports by name as a const set to the literal false (export const FLAG = false) decides an {#if} whose result it fixes on its own, and the arm it rules out is left out too: the first arm of {#if FLAG} and {#if FLAG && …}, the {:else} of {#if !FLAG} and {#if !FLAG || …}; {#if !FLAG && …} and {#if FLAG || …} stay undecided; when that leaves no place the layout renders its children, the page and the layouts below it are not counted on that route. An {#if} in a component that one prop decides counts only the arm that prop selects, when the use passes it literally or not at all (as for seo/single-h1). A test joining such tests with && or || ({#if showcase && layout === 'cover'}) is decided when the use decides each of them. So is one reading the prop as a member of an undestructured $props() binding ({#if props.title}). So is one on a $derived holding such a test ({#if embedded} on $derived(variant === 'embedded')). A layout’s or page’s own landmark is not placed where its arm and the arm a layout above renders the children in cannot both hold on one field of the load data, read alike in every file of the chain: the data prop, page.data from $app/state, $page.data from $app/stores, or a $derived holding one of them. A layout with {#if data.user}<main>{@render children()}</main>{:else}{@render children()}{/if} and a page with {#if !data.user}<main>…</main>{/if} count one <main>. A load below the layout that returns its own value for that field is not read, so the files are taken to see one value.

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 component picked from data or a lookup table at runtime), 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 layout that wraps the page in <article>, <section> or <nav> is not seen, so a page’s top-level <header> still counts there. A page the layout renders inside <main> or <aside> is handled: its top-level <header>/<footer> is not counted.
  • 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'
  }
};